From f59e2b0eec7aab4ba5209123000f18dcea440266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Dec 2023 21:52:32 +0100 Subject: [PATCH 0001/1544] Bump dawidd6/action-download-artifact from 2 to 3.0.0 (#105712) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Franck Nijhof --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 378208fbdf4..51b8fb286ef 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -102,7 +102,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v3.0.0 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -113,7 +113,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v2 + uses: dawidd6/action-download-artifact@v3.0.0 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From 178e4f9e25cd8e7562fe4b8801523f4a9acc043c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Dec 2023 13:06:36 -1000 Subject: [PATCH 0002/1544] Use converter factory in sensor platform (#106508) This is a bit faster than calling .covert --- homeassistant/components/sensor/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 5fca119d5b5..c82254bdcb1 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -671,11 +671,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): converter := UNIT_CONVERTERS.get(device_class) ): # Unit conversion needed - converted_numerical_value = converter.convert( - float(numerical_value), + converted_numerical_value = converter.converter_factory( native_unit_of_measurement, unit_of_measurement, - ) + )(float(numerical_value)) # If unit conversion is happening, and there's no rounding for display, # do a best effort rounding here. From 7ad44a02b75ec3c3b0836831c068e0463656ebff Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Dec 2023 02:08:16 +0100 Subject: [PATCH 0003/1544] Bump version to 2024.2.0dev0 (#106504) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2255b3f145c..d77d2166e1d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2024.1" + HA_SHORT_VERSION: "2024.2" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index f6d479aeb42..002b9b873c2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 1 +MINOR_VERSION: Final = 2 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 3371ec81146..f2f577cfa19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.0.dev0" +version = "2024.2.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5b706cedeb6f3ef3c1cfaf073b29b5982314f7a6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 28 Dec 2023 18:00:34 +1000 Subject: [PATCH 0004/1544] Fix Tessie honk button (#106518) --- homeassistant/components/tessie/button.py | 2 +- tests/components/tessie/test_button.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index 817bdb3a87c..86065d389a4 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -35,7 +35,7 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( TessieButtonEntityDescription( key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" ), - TessieButtonEntityDescription(key="honk", func=honk, icon="mdi:bullhorn"), + TessieButtonEntityDescription(key="honk", func=lambda: honk, icon="mdi:bullhorn"), TessieButtonEntityDescription( key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" ), diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index 72e458cb5d6..153171c8b9f 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -1,6 +1,8 @@ """Test the Tessie button platform.""" from unittest.mock import patch +import pytest + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -8,19 +10,30 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -async def test_buttons(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("entity_id", "func"), + [ + ("button.test_wake", "wake"), + ("button.test_flash_lights", "flash_lights"), + ("button.test_honk_horn", "honk"), + ("button.test_homelink", "trigger_homelink"), + ("button.test_keyless_driving", "enable_keyless_driving"), + ("button.test_play_fart", "boombox"), + ], +) +async def test_buttons(hass: HomeAssistant, entity_id, func) -> None: """Tests that the button entities are correct.""" await setup_platform(hass) # Test wake button with patch( - "homeassistant.components.tessie.button.wake", + f"homeassistant.components.tessie.button.{func}", ) as mock_wake: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: ["button.test_wake"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_wake.assert_called_once() From fb893a5315c5c637feb8f5791aeae7a31ff3920f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 28 Dec 2023 18:02:04 +1000 Subject: [PATCH 0005/1544] Fix run errors in Tessie (#106521) --- homeassistant/components/tessie/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index fc6e8939da9..be80caf50cb 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -52,7 +52,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): return self.coordinator.data.get(key or self.key, default) async def run( - self, func: Callable[..., Awaitable[dict[str, bool]]], **kargs: Any + self, func: Callable[..., Awaitable[dict[str, bool | str]]], **kargs: Any ) -> None: """Run a tessie_api function and handle exceptions.""" try: @@ -66,7 +66,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): raise HomeAssistantError from e if response["result"] is False: raise HomeAssistantError( - response.get("reason"), "An unknown issue occurred" + response.get("reason", "An unknown issue occurred") ) def set(self, *args: Any) -> None: From a6af2be67596ad3470e78ac2db13ce21acdc5f62 Mon Sep 17 00:00:00 2001 From: Bart Janssens Date: Thu, 28 Dec 2023 09:31:35 +0100 Subject: [PATCH 0006/1544] Skip activating/deactivating Vicare standby preset (#106476) --- homeassistant/components/vicare/climate.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c14f940ffe6..0b8e3cab865 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -311,8 +311,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) _LOGGER.debug("Current preset %s", self._current_program) - if self._current_program and self._current_program != VICARE_PROGRAM_NORMAL: - # We can't deactivate "normal" + if self._current_program and self._current_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_STANDBY, + ]: + # We can't deactivate "normal" or "standby" _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -326,8 +329,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) from err _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) - if target_program != VICARE_PROGRAM_NORMAL: - # And we can't explicitly activate "normal", either + if target_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_STANDBY, + ]: + # And we can't explicitly activate "normal" or "standby", either _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) From af881d7ac8eeff509ecfe3528ecd54bd99d5af77 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 28 Dec 2023 09:35:39 +0100 Subject: [PATCH 0007/1544] Handle AttributeError in ViCare integration (#106470) --- homeassistant/components/vicare/utils.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 5b3fb38337f..a084eee383b 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -21,13 +21,12 @@ def is_supported( try: entity_description.value_getter(vicare_device) _LOGGER.debug("Found entity %s", name) + return True except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return False + _LOGGER.debug("Feature not supported %s", name) except AttributeError as error: - _LOGGER.debug("Attribute Error %s: %s", name, error) - return False - return True + _LOGGER.debug("Feature not supported %s: %s", name, error) + return False def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: @@ -36,6 +35,8 @@ def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: return device.burners except PyViCareNotSupportedFeatureError: _LOGGER.debug("No burners found") + except AttributeError as error: + _LOGGER.debug("No burners found: %s", error) return [] @@ -45,6 +46,8 @@ def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent] return device.circuits except PyViCareNotSupportedFeatureError: _LOGGER.debug("No circuits found") + except AttributeError as error: + _LOGGER.debug("No circuits found: %s", error) return [] @@ -54,4 +57,6 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone return device.compressors except PyViCareNotSupportedFeatureError: _LOGGER.debug("No compressors found") + except AttributeError as error: + _LOGGER.debug("No compressors found: %s", error) return [] From 6eec4998bd24198fd8e50a8ae39249b69866d12b Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 28 Dec 2023 11:17:13 +0100 Subject: [PATCH 0008/1544] Avoid changing state of reduced preset in ViCare integration (#105642) --- homeassistant/components/vicare/climate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 0b8e3cab865..2bb0a19924e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -313,9 +313,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _LOGGER.debug("Current preset %s", self._current_program) if self._current_program and self._current_program not in [ VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, VICARE_PROGRAM_STANDBY, ]: - # We can't deactivate "normal" or "standby" + # We can't deactivate "normal", "reduced" or "standby" _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -331,9 +332,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) if target_program not in [ VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, VICARE_PROGRAM_STANDBY, ]: - # And we can't explicitly activate "normal" or "standby", either + # And we can't explicitly activate "normal", "reduced" or "standby", either _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) From 1a6e81767d7cb406df997bbbf2fd8a6df45616a6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 28 Dec 2023 14:00:24 +0100 Subject: [PATCH 0009/1544] Improve trace helper typing (#105964) --- homeassistant/helpers/trace.py | 43 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index fd7a3081f7a..41be606488a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -2,17 +2,20 @@ from __future__ import annotations from collections import deque -from collections.abc import Callable, Generator +from collections.abc import Callable, Coroutine, Generator from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, cast +from typing import Any, TypeVar, TypeVarTuple from homeassistant.core import ServiceResponse import homeassistant.util.dt as dt_util from .typing import TemplateVarsType +_T = TypeVar("_T") +_Ts = TypeVarTuple("_Ts") + class TraceElement: """Container for trace data.""" @@ -125,21 +128,23 @@ def trace_id_get() -> tuple[str, str] | None: return trace_id_cv.get() -def trace_stack_push(trace_stack_var: ContextVar, node: Any) -> None: +def trace_stack_push(trace_stack_var: ContextVar[list[_T] | None], node: _T) -> None: """Push an element to the top of a trace stack.""" + trace_stack: list[_T] | None if (trace_stack := trace_stack_var.get()) is None: trace_stack = [] trace_stack_var.set(trace_stack) trace_stack.append(node) -def trace_stack_pop(trace_stack_var: ContextVar) -> None: +def trace_stack_pop(trace_stack_var: ContextVar[list[Any] | None]) -> None: """Remove the top element from a trace stack.""" trace_stack = trace_stack_var.get() - trace_stack.pop() + if trace_stack is not None: + trace_stack.pop() -def trace_stack_top(trace_stack_var: ContextVar) -> Any | None: +def trace_stack_top(trace_stack_var: ContextVar[list[_T] | None]) -> _T | None: """Return the element at the top of a trace stack.""" trace_stack = trace_stack_var.get() return trace_stack[-1] if trace_stack else None @@ -198,21 +203,20 @@ def trace_clear() -> None: def trace_set_child_id(child_key: str, child_run_id: str) -> None: """Set child trace_id of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - if node: + if node := trace_stack_top(trace_stack_cv): node.set_child_id(child_key, child_run_id) def trace_set_result(**kwargs: Any) -> None: """Set the result of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - node.set_result(**kwargs) + if node := trace_stack_top(trace_stack_cv): + node.set_result(**kwargs) def trace_update_result(**kwargs: Any) -> None: """Update the result of TraceElement at the top of the stack.""" - node = cast(TraceElement, trace_stack_top(trace_stack_cv)) - node.update_result(**kwargs) + if node := trace_stack_top(trace_stack_cv): + node.update_result(**kwargs) class StopReason: @@ -238,7 +242,7 @@ def script_execution_get() -> str | None: @contextmanager -def trace_path(suffix: str | list[str]) -> Generator: +def trace_path(suffix: str | list[str]) -> Generator[None, None, None]: """Go deeper in the config tree. Can not be used as a decorator on couroutine functions. @@ -250,17 +254,24 @@ def trace_path(suffix: str | list[str]) -> Generator: trace_path_pop(count) -def async_trace_path(suffix: str | list[str]) -> Callable: +def async_trace_path( + suffix: str | list[str], +) -> Callable[ + [Callable[[*_Ts], Coroutine[Any, Any, None]]], + Callable[[*_Ts], Coroutine[Any, Any, None]], +]: """Go deeper in the config tree. To be used as a decorator on coroutine functions. """ - def _trace_path_decorator(func: Callable) -> Callable: + def _trace_path_decorator( + func: Callable[[*_Ts], Coroutine[Any, Any, None]], + ) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: """Decorate a coroutine function.""" @wraps(func) - async def async_wrapper(*args: Any) -> None: + async def async_wrapper(*args: *_Ts) -> None: """Catch and log exception.""" with trace_path(suffix): await func(*args) From 21dbc57fc1ad16e75951ee6abc7cfd55a278af7e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 28 Dec 2023 14:20:56 +0100 Subject: [PATCH 0010/1544] Remove default value from modbus retries (#106551) Solve retries issue. --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 74a1de48c0a..141f2b0cca6 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -374,7 +374,7 @@ MODBUS_SCHEMA = vol.Schema( vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, - vol.Optional(CONF_RETRIES, default=3): cv.positive_int, + vol.Optional(CONF_RETRIES): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1d755adace7..95c0cd45332 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -278,6 +278,8 @@ class ModbusHub: _LOGGER.warning( "`retries`: is deprecated and will be removed in version 2024.7" ) + else: + client_config[CONF_RETRIES] = 3 if CONF_CLOSE_COMM_ON_ERROR in client_config: async_create_issue( hass, From 1cbd9bded0c4c6d29839ca4777bda23ca72b3016 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 28 Dec 2023 16:05:11 +0100 Subject: [PATCH 0011/1544] Update frontend to 20231228.0 (#106556) --- 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 9c631b4cfd5..227fa96edf7 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==20231227.0"] + "requirements": ["home-assistant-frontend==20231228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 18a8b14b9d5..a6c59c98dc0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.0 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ef621c9ce94..d3f947c140d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.38 # homeassistant.components.frontend -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e3256385fc..7d993ac7cb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -828,7 +828,7 @@ hole==0.8.0 holidays==0.38 # homeassistant.components.frontend -home-assistant-frontend==20231227.0 +home-assistant-frontend==20231228.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 0605c499aa7bca22e39e7915255f6dbfc437060f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 16:05:52 +0100 Subject: [PATCH 0012/1544] Bump python-holidays to 0.39 (#106550) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 50536bc201d..7417cc5cd51 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.38", "babel==2.13.1"] + "requirements": ["holidays==0.39", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 92face1ecdb..ae7c42c1868 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.38"] + "requirements": ["holidays==0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3f947c140d..c2ba8cccf52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1035,7 +1035,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.38 +holidays==0.39 # homeassistant.components.frontend home-assistant-frontend==20231228.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d993ac7cb6..1171fbf1970 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -825,7 +825,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.38 +holidays==0.39 # homeassistant.components.frontend home-assistant-frontend==20231228.0 From b852eb7e23cc9b66ea408126173c147f72ac745a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 16:10:27 +0100 Subject: [PATCH 0013/1544] Fix holiday HA language not supported (#106554) --- .../components/holiday/config_flow.py | 16 +++++++++++++--- tests/components/holiday/test_config_flow.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 1ba4a2a0c26..842849a7c57 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from babel import Locale +from babel import Locale, UnknownLocaleError from holidays import list_supported_countries import voluptuous as vol @@ -46,7 +46,12 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) - locale = Locale(self.hass.config.language) + try: + locale = Locale(self.hass.config.language) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") title = locale.territories[selected_country] return self.async_create_entry(title=title, data=user_input) @@ -81,7 +86,12 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) - locale = Locale(self.hass.config.language) + try: + locale = Locale(self.hass.config.language) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") province_str = f", {province}" if province else "" name = f"{locale.territories[country]}{province_str}" diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index e99d310762e..c88d66d843b 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -126,3 +126,22 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: ) assert result_de_step2["type"] == FlowResultType.ABORT assert result_de_step2["reason"] == "already_configured" + + +async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: + """Test the config flow if using not babel supported language.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Sweden" From 34c9ef42e9144ddc2f9e57e536ebd34dedd152ed Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 16:32:55 +0100 Subject: [PATCH 0014/1544] Add myself as codeowner for holiday (#106560) --- CODEOWNERS | 4 ++-- homeassistant/components/holiday/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 494f3d42bee..12477a683a3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -530,8 +530,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard -/homeassistant/components/holiday/ @jrieger -/tests/components/holiday/ @jrieger +/homeassistant/components/holiday/ @jrieger @gjohansson-ST +/tests/components/holiday/ @jrieger @gjohansson-ST /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 7417cc5cd51..c8ef6c88b13 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -1,7 +1,7 @@ { "domain": "holiday", "name": "Holiday", - "codeowners": ["@jrieger"], + "codeowners": ["@jrieger", "@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", From 5dd63d86f26bbb570240b64eec6bccb25e8c5d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 28 Dec 2023 17:14:25 +0100 Subject: [PATCH 0015/1544] Update aioairzone-cloud to v0.3.7 (#106544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v0.3.7 Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 32 +++++++++++++++++++ tests/components/airzone_cloud/util.py | 30 +++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ab8e08835a3..e10669d6a93 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.6"] + "requirements": ["aioairzone-cloud==0.3.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2ba8cccf52..97f8fed9848 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ aio-georss-gdacs==0.8 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.7 # homeassistant.components.airzone aioairzone==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1171fbf1970..8a51d44cf29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.6 +aioairzone-cloud==0.3.7 # homeassistant.components.airzone aioairzone==0.7.2 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4a7217a08c5..d1a8d74cc08 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -324,6 +324,12 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'errors': list([ dict({ @@ -398,6 +404,19 @@ 'zone1': dict({ 'action': 1, 'active': True, + 'aq-active': False, + 'aq-index': 1, + 'aq-mode-conf': 'auto', + 'aq-mode-values': list([ + 'off', + 'on', + 'auto', + ]), + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'humidity': 30, 'id': 'zone1', @@ -445,6 +464,19 @@ 'zone2': dict({ 'action': 6, 'active': False, + 'aq-active': False, + 'aq-index': 1, + 'aq-mode-conf': 'auto', + 'aq-mode-values': list([ + 'off', + 'on', + 'auto', + ]), + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', 'available': True, 'humidity': 24, 'id': 'zone2', diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 6924344a092..98ff7c65478 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -6,6 +6,14 @@ from unittest.mock import patch from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, + API_AQ_ACTIVE, + API_AQ_MODE_CONF, + API_AQ_MODE_VALUES, + API_AQ_PM_1, + API_AQ_PM_2P5, + API_AQ_PM_10, + API_AQ_PRESENT, + API_AQ_QUALITY, API_AZ_AIDOO, API_AZ_AIDOO_PRO, API_AZ_SYSTEM, @@ -291,6 +299,12 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: } if device.get_id() == "system1": return { + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -310,6 +324,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone1": return { API_ACTIVE: True, + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, API_MODE_AVAIL: [ @@ -346,6 +368,14 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone2": return { API_ACTIVE: False, + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, API_MODE_AVAIL: [], From 2abf7d75e9a97a2f8fa72ab793b468ccf6eddfed Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 28 Dec 2023 17:37:48 +0100 Subject: [PATCH 0016/1544] Remove default value for modbus lazy_error (#106561) --- homeassistant/components/modbus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 141f2b0cca6..89a50862b6c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -157,7 +157,7 @@ BASE_COMPONENT_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, - vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) From 353f33f4aceefd1b94248576537039e516bba13d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 17:45:21 +0100 Subject: [PATCH 0017/1544] Add missing disks to Systemmonitor (#106541) --- homeassistant/components/systemmonitor/util.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index bb81d0c9715..27c4c449634 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -11,14 +11,16 @@ _LOGGER = logging.getLogger(__name__) def get_all_disk_mounts() -> list[str]: """Return all disk mount points on system.""" disks: list[str] = [] - for part in psutil.disk_partitions(all=False): + for part in psutil.disk_partitions(all=True): if os.name == "nt": if "cdrom" in part.opts or part.fstype == "": # skip cd-rom drives with no disk in it; they may raise # ENOENT, pop-up a Windows GUI error for a non-ready # partition or just hang. continue - disks.append(part.mountpoint) + usage = psutil.disk_usage(part.mountpoint) + if usage.total > 0 and part.device != "": + disks.append(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) return disks From 454201d0a8cd4ff9a6058a8141745953a74bd671 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 28 Dec 2023 11:47:04 -0500 Subject: [PATCH 0018/1544] Add device info to Netgear LTE (#106568) * Add device info to Netgear LTE * uno mas --- .../components/netgear_lte/binary_sensor.py | 12 +------- .../components/netgear_lte/entity.py | 25 ++++++++++++---- .../components/netgear_lte/sensor.py | 15 +++------- .../netgear_lte/snapshots/test_init.ambr | 29 +++++++++++++++++++ tests/components/netgear_lte/test_init.py | 16 ++++++++++ 5 files changed, 69 insertions(+), 28 deletions(-) create mode 100644 tests/components/netgear_lte/snapshots/test_init.ambr diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 810e3733fbe..ce179c9e980 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -10,7 +10,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ModemData from .const import DOMAIN from .entity import LTEEntity @@ -36,22 +35,13 @@ async def async_setup_entry( modem_data = hass.data[DOMAIN].get_modem_data(entry.data) async_add_entities( - NetgearLTEBinarySensor(modem_data, sensor) for sensor in BINARY_SENSORS + NetgearLTEBinarySensor(entry, modem_data, sensor) for sensor in BINARY_SENSORS ) class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" - def __init__( - self, - modem_data: ModemData, - entity_description: BinarySensorEntityDescription, - ) -> None: - """Initialize a Netgear LTE binary sensor entity.""" - super().__init__(modem_data, entity_description.key) - self.entity_description = entity_description - @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 33e0aaab749..02827e7df3f 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -1,10 +1,13 @@ """Entity representing a Netgear LTE entity.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from . import ModemData -from .const import DISPATCHER_NETGEAR_LTE +from .const import DISPATCHER_NETGEAR_LTE, DOMAIN, MANUFACTURER class LTEEntity(Entity): @@ -14,14 +17,24 @@ class LTEEntity(Entity): def __init__( self, + config_entry: ConfigEntry, modem_data: ModemData, - sensor_type: str, + description: EntityDescription, ) -> None: """Initialize a Netgear LTE entity.""" + self.entity_description = description self.modem_data = modem_data - self.sensor_type = sensor_type - self._attr_name = f"Netgear LTE {sensor_type}" - self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}" + self._attr_name = f"Netgear LTE {description.key}" + self._attr_unique_id = f"{description.key}_{modem_data.data.serial_number}" + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{config_entry.data[CONF_HOST]}", + identifiers={(DOMAIN, modem_data.data.serial_number)}, + manufacturer=MANUFACTURER, + model=modem_data.data.items["general.model"], + serial_number=modem_data.data.serial_number, + sw_version=modem_data.data.items["general.fwversion"], + hw_version=modem_data.data.items["general.hwversion"], + ) async def async_added_to_hass(self) -> None: """Register callback.""" diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index b91bb9b561a..d281c93d795 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -76,7 +76,9 @@ async def async_setup_entry( """Set up the Netgear LTE sensor.""" modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - async_add_entities(NetgearLTESensor(modem_data, sensor) for sensor in SENSORS) + async_add_entities( + NetgearLTESensor(entry, modem_data, sensor) for sensor in SENSORS + ) class NetgearLTESensor(LTEEntity, SensorEntity): @@ -84,18 +86,9 @@ class NetgearLTESensor(LTEEntity, SensorEntity): entity_description: NetgearLTESensorEntityDescription - def __init__( - self, - modem_data: ModemData, - entity_description: NetgearLTESensorEntityDescription, - ) -> None: - """Initialize a Netgear LTE sensor entity.""" - super().__init__(modem_data, entity_description.key) - self.entity_description = entity_description - @property def native_value(self) -> StateType: """Return the state of the sensor.""" if self.entity_description.value_fn is not None: return self.entity_description.value_fn(self.modem_data) - return getattr(self.modem_data.data, self.sensor_type) + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr new file mode 100644 index 00000000000..2eb2fff89ef --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.5.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'netgear_lte', + 'FFFFFFFFFFFFF', + ), + }), + 'is_new': False, + 'manufacturer': 'Netgear', + 'model': 'LM1200', + 'name': 'Netgear LM1200', + 'name_by_user': None, + 'serial_number': 'FFFFFFFFFFFFF', + 'suggested_area': None, + 'sw_version': 'EC25AFFDR07A09M4G', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py index 7c48d9d87d2..9d9b43f5a16 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -1,7 +1,10 @@ """Test Netgear LTE integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.netgear_lte.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import CONF_DATA @@ -26,3 +29,16 @@ async def test_async_setup_entry_not_ready( entry = hass.config_entries.async_entries(DOMAIN)[0] assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: None, + snapshot: SnapshotAssertion, +) -> None: + """Test device info.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.async_block_till_done() + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) + assert device == snapshot From 756292234ec4183220803098233ca3336379038f Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Thu, 28 Dec 2023 19:27:06 +0100 Subject: [PATCH 0019/1544] Add Record distance sensor to MyPermobil (#106519) * add record-distance-sensor * simplify UOM property * remove uom for record_distance description * remove redundant code * Update homeassistant/components/permobil/sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/permobil/sensor.py | 29 +++++++++++++++++-- .../components/permobil/strings.json | 3 ++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index a48741b0886..8a504248f5a 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -15,6 +15,8 @@ from mypermobil import ( BATTERY_MAX_DISTANCE_LEFT, BATTERY_STATE_OF_CHARGE, BATTERY_STATE_OF_HEALTH, + RECORDS_DISTANCE, + RECORDS_DISTANCE_UNIT, RECORDS_SEATING, USAGE_ADJUSTMENTS, USAGE_DISTANCE, @@ -32,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN +from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES from .coordinator import MyPermobilCoordinator _LOGGER = logging.getLogger(__name__) @@ -159,7 +161,7 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), PermobilSensorEntityDescription( - # Largest number of adjustemnts in a single 24h period, never resets + # Largest number of adjustemnts in a single 24h period, monotonically increasing, never resets value_fn=lambda data: data.records[RECORDS_SEATING[0]], available_fn=lambda data: RECORDS_SEATING[0] in data.records, key="record_adjustments", @@ -168,8 +170,22 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( native_unit_of_measurement="adjustments", state_class=SensorStateClass.TOTAL_INCREASING, ), + PermobilSensorEntityDescription( + # Record of largest distance travelled in a day, monotonically increasing, never resets + value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], + available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, + key="record_distance", + translation_key="record_distance", + icon="mdi:map-marker-distance", + state_class=SensorStateClass.TOTAL_INCREASING, + ), ) +DISTANCE_UNITS: dict[Any, UnitOfLength] = { + KM: UnitOfLength.KILOMETERS, + MILES: UnitOfLength.MILES, +} + async def async_setup_entry( hass: HomeAssistant, @@ -209,6 +225,15 @@ class PermobilSensor(CoordinatorEntity[MyPermobilCoordinator], SensorEntity): f"{coordinator.p_api.email}_{self.entity_description.key}" ) + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor.""" + if self.entity_description.key == "record_distance": + return DISTANCE_UNITS.get( + self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]] + ) + return self.entity_description.native_unit_of_measurement + @property def available(self) -> bool: """Return True if the sensor has value.""" diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index b0b630eff08..b500bbdb9ea 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -64,6 +64,9 @@ }, "record_adjustments": { "name": "Record number of adjustments" + }, + "record_distance": { + "name": "Record distance" } } } From 43384effcde2e82ddcb7b5385899eb0b834e4e2f Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 28 Dec 2023 13:36:57 -0500 Subject: [PATCH 0020/1544] Bump plexapi to 4.15.7 (#106576) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 6dbd6118d7c..8fc01140787 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.6", + "PlexAPI==4.15.7", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/requirements_all.txt b/requirements_all.txt index 97f8fed9848..14295d41ce6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.6 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a51d44cf29..35e066cacb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.8.0 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.6 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 From fb280229c210e4eeb03342143b1164d5eb2952ef Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:48:34 +0100 Subject: [PATCH 0021/1544] Revert "Set volume_step in sonos media_player" (#106581) Revert "Set volume_step in sonos media_player (#105671)" This reverts commit 6dc8c2c37014de201578b5cbe880f7a1bbcecfc4. --- homeassistant/components/sonos/media_player.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 031e4606148..27059bba180 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -67,6 +67,7 @@ _LOGGER = logging.getLogger(__name__) LONG_SERVICE_TIMEOUT = 30.0 UNJOIN_SERVICE_TIMEOUT = 0.1 +VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { RepeatMode.OFF: False, @@ -211,7 +212,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER - _attr_volume_step = 2 / 100 def __init__(self, speaker: SonosSpeaker) -> None: """Initialize the media player entity.""" @@ -373,6 +373,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Name of the current input source.""" return self.media.source_name or None + @soco_error() + def volume_up(self) -> None: + """Volume up media player.""" + self.soco.volume += VOLUME_INCREMENT + + @soco_error() + def volume_down(self) -> None: + """Volume down media player.""" + self.soco.volume -= VOLUME_INCREMENT + @soco_error() def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" From 29dea2e0eae66a8df30dbe78c4b072906ed499b3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:50:57 +0100 Subject: [PATCH 0022/1544] Revert "Set volume_step in frontier_silicon media_player" (#106583) Revert "Set volume_step in frontier_silicon media_player (#105953)" This reverts commit 3e50ca6cda06a693002e0e7c69cbaa214145d053. --- .../components/frontier_silicon/media_player.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 565ee79b108..223abe26e55 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -152,9 +152,6 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._max_volume: self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 - if self._max_volume: - self._attr_volume_step = 1 / self._max_volume - if self._attr_state != MediaPlayerState.OFF: info_name = await afsapi.get_play_name() info_text = await afsapi.get_play_text() @@ -242,6 +239,18 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_mute(mute) # volume + async def async_volume_up(self) -> None: + """Send volume up command.""" + volume = await self.fs_device.get_volume() + volume = int(volume or 0) + 1 + await self.fs_device.set_volume(min(volume, self._max_volume)) + + async def async_volume_down(self) -> None: + """Send volume down command.""" + volume = await self.fs_device.get_volume() + volume = int(volume or 0) - 1 + await self.fs_device.set_volume(max(volume, 0)) + async def async_set_volume_level(self, volume: float) -> None: """Set volume command.""" if self._max_volume: # Can't do anything sensible if not set From 1fc0a305e7046d30ef5661a1be616cddf2be3b80 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:53:56 +0100 Subject: [PATCH 0023/1544] Revert "Set volume_step in aquostv media_player" (#106577) Revert "Set volume_step in aquostv media_player (#105665)" This reverts commit bb8dce6187b93ea17bf04902574b9c133a887e05. --- .../components/aquostv/media_player.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index cd93ddf9e15..34d5e4161fb 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -112,7 +112,6 @@ class SharpAquosTVDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 2 / 60 def __init__( self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False @@ -157,6 +156,22 @@ class SharpAquosTVDevice(MediaPlayerEntity): """Turn off tvplayer.""" self._remote.power(0) + @_retry + def volume_up(self) -> None: + """Volume up the media player.""" + if self.volume_level is None: + _LOGGER.debug("Unknown volume in volume_up") + return + self._remote.volume(int(self.volume_level * 60) + 2) + + @_retry + def volume_down(self) -> None: + """Volume down media player.""" + if self.volume_level is None: + _LOGGER.debug("Unknown volume in volume_down") + return + self._remote.volume(int(self.volume_level * 60) - 2) + @_retry def set_volume_level(self, volume: float) -> None: """Set Volume media player.""" From 52cc6a10081d58a100913b874e454eb2652ea29e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:10 +0100 Subject: [PATCH 0024/1544] Revert "Set volume_step in clementine media_player" (#106578) Revert "Set volume_step in clementine media_player (#105666)" This reverts commit 36eeb15feedac126b3465d3c522a858a9cc9ac2e. --- homeassistant/components/clementine/media_player.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index eb0da23d360..770f19e9970 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -65,7 +65,6 @@ class ClementineDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 4 / 100 def __init__(self, client, name): """Initialize the Clementine device.""" @@ -124,6 +123,16 @@ class ClementineDevice(MediaPlayerEntity): return None, None + def volume_up(self) -> None: + """Volume up the media player.""" + newvolume = min(self._client.volume + 4, 100) + self._client.set_volume(newvolume) + + def volume_down(self) -> None: + """Volume down media player.""" + newvolume = max(self._client.volume - 4, 0) + self._client.set_volume(newvolume) + def mute_volume(self, mute: bool) -> None: """Send mute command.""" self._client.set_volume(0) From 744f06b5a8b7c2248dd72164212192e861feff26 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:21 +0100 Subject: [PATCH 0025/1544] Revert "Set volume_step in cmus media_player" (#106579) Revert "Set volume_step in cmus media_player (#105667)" This reverts commit c10b460c6bf71cb0329dca991b7a09fc5cd963c4. --- homeassistant/components/cmus/media_player.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index a242a5a772c..65bfef3a0cb 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -94,7 +94,6 @@ class CmusDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.PLAY ) - _attr_volume_step = 5 / 100 def __init__(self, device, name, server): """Initialize the CMUS device.""" @@ -154,6 +153,30 @@ class CmusDevice(MediaPlayerEntity): """Set volume level, range 0..1.""" self._remote.cmus.set_volume(int(volume * 100)) + def volume_up(self) -> None: + """Set the volume up.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self._remote.cmus.set_volume(int(current_volume) + 5) + + def volume_down(self) -> None: + """Set the volume down.""" + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") + if left != right: + current_volume = float(left + right) / 2 + else: + current_volume = left + + if current_volume <= 100: + self._remote.cmus.set_volume(int(current_volume) - 5) + def play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: From 20d1560b01fea74c4b08f64b454ec6cc83e615c3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:36 +0100 Subject: [PATCH 0026/1544] Revert "Set volume_step in monoprice media_player" (#106580) Revert "Set volume_step in monoprice media_player (#105670)" This reverts commit cffb51ebec5a681878f7acd88d10e1e53e8130ce. --- .../components/monoprice/media_player.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 40ea9f85a7c..92b98abf374 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -127,7 +127,6 @@ class MonopriceZone(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None - _attr_volume_step = 1 / MAX_VOLUME def __init__(self, monoprice, sources, namespace, zone_id): """Initialize new zone.""" @@ -211,3 +210,17 @@ class MonopriceZone(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME)) + + def volume_up(self) -> None: + """Volume up the media player.""" + if self.volume_level is None: + return + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME)) + + def volume_down(self) -> None: + """Volume down media player.""" + if self.volume_level is None: + return + volume = round(self.volume_level * MAX_VOLUME) + self._monoprice.set_volume(self._zone_id, max(volume - 1, 0)) From 1909163c8e6ef3637671970d227f7d1ee016072f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 19:54:51 +0100 Subject: [PATCH 0027/1544] Revert "Set volume_step in bluesound media_player" (#106582) Revert "Set volume_step in bluesound media_player (#105672)" This reverts commit 7fa55ffdd29af9d428b0ebd06b59b7be16130e3a. --- .../components/bluesound/media_player.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index cfe2fedebdc..eba03963ebc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -200,7 +200,6 @@ class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC - _attr_volume_step = 0.01 def __init__(self, hass, host, port=None, name=None, init_callback=None): """Initialize the media player.""" @@ -1028,6 +1027,20 @@ class BluesoundPlayer(MediaPlayerEntity): return await self.send_bluesound_command(url) + async def async_volume_up(self) -> None: + """Volume up the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol >= 1: + return + return await self.async_set_volume_level(current_vol + 0.01) + + async def async_volume_down(self) -> None: + """Volume down the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol <= 0: + return + return await self.async_set_volume_level(current_vol - 0.01) + async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" if volume < 0: From e7e0ae8f6a8e2b371b813530bf22e85989a2741e Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 28 Dec 2023 13:56:40 -0500 Subject: [PATCH 0028/1544] Move services to entity services in blink (#105413) * Use device name to lookup camera * Fix device registry serial * Move to entity based services * Update tests * Use config_entry Move refresh service out of camera * Use config entry for services * Fix service schema * Add depreciation note * Depreciation note * key error changes deprecated (not depreciated) repair issue * tweak message * deprication v2 * back out update field change * backout update schema changes * Finish rollback on update service * update doc strings * move to 2024.7.0 More verbosity to deprecation message --- homeassistant/components/blink/camera.py | 61 +++- homeassistant/components/blink/const.py | 1 + homeassistant/components/blink/services.py | 124 ++------ homeassistant/components/blink/services.yaml | 42 +-- homeassistant/components/blink/strings.json | 50 ++- tests/components/blink/test_services.py | 318 +++++-------------- 6 files changed, 209 insertions(+), 387 deletions(-) diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index f507364f17f..4d05aea88a5 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -8,17 +8,26 @@ import logging from typing import Any from requests.exceptions import ChunkedEncodingError +import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .const import ( + DEFAULT_BRAND, + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_TRIGGER, +) from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,6 +52,16 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") + platform.async_register_entity_service( + SERVICE_SAVE_RECENT_CLIPS, + {vol.Required(CONF_FILE_PATH): cv.string}, + "save_recent_clips", + ) + platform.async_register_entity_service( + SERVICE_SAVE_VIDEO, + {vol.Required(CONF_FILENAME): cv.string}, + "save_video", + ) class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): @@ -64,7 +83,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): manufacturer=DEFAULT_BRAND, model=camera.camera_type, ) - _LOGGER.debug("Initialized blink camera %s", self.name) + _LOGGER.debug("Initialized blink camera %s", self._camera.name) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -121,3 +140,39 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): except TypeError: _LOGGER.debug("No cached image for %s", self._camera.name) return None + + async def save_recent_clips(self, file_path) -> None: + """Save multiple recent clips to output directory.""" + if not self.hass.config.is_allowed_path(file_path): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": file_path}, + ) + + try: + await self._camera.save_recent_clips(output_dir=file_path) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def save_video(self, filename) -> None: + """Handle save video service calls.""" + if not self.hass.config.is_allowed_path(filename): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": filename}, + ) + + try: + await self._camera.video_to_file(filename) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d394b5c0008..7aa3d0d388e 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -24,6 +24,7 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index dae2f0ad951..5c034cdb7c5 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -4,25 +4,16 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr - -from .const import ( - DOMAIN, - SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, - SERVICE_SEND_PIN, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, ) + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, SERVICE_SEND_PIN from .coordinator import BlinkUpdateCoordinator SERVICE_UPDATE_SCHEMA = vol.Schema( @@ -30,26 +21,12 @@ SERVICE_UPDATE_SCHEMA = vol.Schema( vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), } ) -SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILENAME): cv.string, - } -) SERVICE_SEND_PIN_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_PIN): cv.string, } ) -SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILE_PATH): cv.string, - } -) def setup_services(hass: HomeAssistant) -> None: @@ -94,57 +71,22 @@ def setup_services(hass: HomeAssistant) -> None: coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators - async def async_handle_save_video_service(call: ServiceCall) -> None: - """Handle save video service calls.""" - camera_name = call.data[CONF_NAME] - video_path = call.data[CONF_FILENAME] - if not hass.config.is_allowed_path(video_path): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": video_path}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].video_to_file(video_path) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - - async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: - """Save multiple recent clips to output directory.""" - camera_name = call.data[CONF_NAME] - clips_dir = call.data[CONF_FILE_PATH] - if not hass.config.is_allowed_path(clips_dir): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": clips_dir}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].save_recent_clips( - output_dir=clips_dir - ) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]: + if not (config_entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinator = hass.data[DOMAIN][entry_id] await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -152,22 +94,24 @@ def setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services + # Refresh service is deprecated and will be removed in 7/2024 service_mapping = [ (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), - ( - async_handle_save_video_service, - SERVICE_SAVE_VIDEO, - SERVICE_SAVE_VIDEO_SCHEMA, - ), - ( - async_handle_save_recent_clips_service, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_RECENT_CLIPS_SCHEMA, - ), (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), ] diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index aaecde64353..87083a990ef 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -9,25 +9,17 @@ blink_update: integration: blink trigger_camera: - fields: - device_id: - required: true - selector: - device: - integration: blink + target: + entity: + integration: blink + domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: filename: required: true example: "/tmp/video.mp4" @@ -35,17 +27,11 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: file_path: required: true example: "/tmp" @@ -54,10 +40,10 @@ save_recent_clips: send_pin: fields: - device_id: + config_entry_id: required: true selector: - device: + config_entry: integration: blink pin: example: "abc123" diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index fc0450dc8ea..87e2fc68c20 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -67,29 +67,15 @@ }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image.", - "fields": { - "device_id": { - "name": "Device ID", - "description": "The Blink device id." - } - } + "description": "Requests camera to take new image." }, "save_video": { "name": "Save video", "description": "Saves last recorded video clip to local file.", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab video from." - }, "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -97,17 +83,9 @@ "name": "Save recent clips", "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", "fields": { - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "Name of camera to grab recent clips from." - }, "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -119,19 +97,16 @@ "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." + "config_entry_id": { + "name": "Integration ID", + "description": "The Blink Integration id." } } } }, "exceptions": { - "invalid_device": { - "message": "Device '{target}' is not a {domain} device" - }, - "device_not_found": { - "message": "Device '{target}' not found in device registry" + "integration_not_found": { + "message": "Integraion '{target}' not found in registry" }, "no_path": { "message": "Can't write to directory {target}, no access to path!" @@ -142,5 +117,18 @@ "not_loaded": { "message": "{target} is not loaded" } + }, + "issues": { + "service_deprecation": { + "title": "Blink update service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::blink::issues::service_deprecation::title%]", + "description": "Blink update service is deprecated and will be removed.\nPlease update your automations and scripts to use `Home Assistant Core Integration: Update entity`." + } + } + } + } } } diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index ccc326dac1f..1c2faa32d04 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,22 +4,15 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from homeassistant.components.blink.const import ( + ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -43,7 +36,6 @@ async def test_refresh_service_calls( await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - assert device_entry assert mock_config_entry.state is ConfigEntryState.LOADED @@ -67,163 +59,8 @@ async def test_refresh_service_calls( ) -async def test_video_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test video service calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - caplog.clear() - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=False) - - -async def test_picture_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test picture servcie calls.""" - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() - - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( - side_effect=OSError - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - async def test_pin_service_calls( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, @@ -234,17 +71,13 @@ async def test_pin_service_calls( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: [device_entry.id], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, blocking=True, ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once @@ -253,41 +86,18 @@ async def test_pin_service_calls( await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: ["bad-device_id"], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: ["bad-config_id"], CONF_PIN: PIN}, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_non_blink_device( +async def test_service_pin_called_with_non_blink_device( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with non blink device.""" + """Test pin service calls with non blink device.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -295,11 +105,48 @@ async def test_service_called_with_non_blink_device( other_domain = "NotBlink" other_config_id = "555" - await hass.config_entries.async_add( - MockConfigEntry( - title="Not Blink", domain=other_domain, entry_id=other_config_id - ) + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id ) + await hass.config_entries.async_add(other_mock_config_entry) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = { + ATTR_CONFIG_ENTRY_ID: [other_mock_config_entry.entry_id], + CONF_PIN: PIN, + } + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_non_blink_device( + hass: HomeAssistant, + mock_blink_api: MagicMock, + device_registry: dr.DeviceRegistry, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update service calls with non blink device.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + other_domain = "NotBlink" + other_config_id = "555" + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + await hass.config_entries.async_add(other_mock_config_entry) + device_entry = device_registry.async_get_or_create( config_entry_id=other_config_id, identifiers={ @@ -311,67 +158,68 @@ async def test_service_called_with_non_blink_device( mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) - with pytest.raises(ServiceValidationError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_unloaded_entry( +async def test_service_pin_called_with_unloaded_entry( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls with not ready config entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_config_entry.state = ConfigEntryState.SETUP_ERROR + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_unloaded_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with unloaded config entry.""" + """Test update service calls with not ready config entry.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - await mock_config_entry.async_unload(hass) - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry + mock_config_entry.state = ConfigEntryState.SETUP_ERROR hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + assert device_entry + parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) From 648afe121dcf0ce8bec948c74fb284096d9bb715 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 20:16:14 +0100 Subject: [PATCH 0029/1544] Replace dash in language if needed (#106559) * Replace dash in language if needed * Add tests --- .../components/holiday/config_flow.py | 4 +- tests/components/holiday/test_config_flow.py | 79 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 842849a7c57..33268de92b6 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -47,7 +47,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) try: - locale = Locale(self.hass.config.language) + locale = Locale(self.hass.config.language.replace("-", "_")) except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" @@ -87,7 +87,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - locale = Locale(self.hass.config.language) + locale = Locale(self.hass.config.language.replace("-", "_")) except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py index c88d66d843b..7dce6131616 100644 --- a/tests/components/holiday/test_config_flow.py +++ b/tests/components/holiday/test_config_flow.py @@ -130,13 +130,13 @@ async def test_single_combination_country_province(hass: HomeAssistant) -> None: async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: """Test the config flow if using not babel supported language.""" - hass.config.language = "en-GB" + hass.config.language = "en-XX" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_COUNTRY: "SE", @@ -144,4 +144,77 @@ async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["title"] == "Sweden" + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } + + +async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> None: + """Test the config flow if using language with dash.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } From 756847eea8e20caad4edfe87c6c71c5b1eaa5b7e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 28 Dec 2023 20:20:59 +0100 Subject: [PATCH 0030/1544] Only check known attributes in significant change support (#106572) only check known attributes --- .../alarm_control_panel/significant_change.py | 13 +++++++----- .../components/climate/significant_change.py | 12 ++++++----- .../components/cover/significant_change.py | 11 +++++----- .../components/fan/significant_change.py | 20 ++++++++++++------- .../humidifier/significant_change.py | 11 +++++----- .../media_player/significant_change.py | 19 +++++++++++++----- .../components/vacuum/significant_change.py | 11 +++++----- .../water_heater/significant_change.py | 11 +++++----- .../components/weather/significant_change.py | 11 +++++----- 9 files changed, 72 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py index d33347a67f1..bde6d151393 100644 --- a/homeassistant/components/alarm_control_panel/significant_change.py +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -26,13 +26,16 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} - for attr_name in changed_attrs: - if attr_name in SIGNIFICANT_ATTRIBUTES: - return True + if changed_attrs: + return True # no significant attribute change detected return False diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 01d3ef98558..7198153f9af 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -52,15 +52,17 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} ha_unit = hass.config.units.temperature_unit for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ ATTR_AUX_HEAT, ATTR_FAN_MODE, diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py index 8762af496c8..ca822c5e9e1 100644 --- a/homeassistant/components/cover/significant_change.py +++ b/homeassistant/components/cover/significant_change.py @@ -30,14 +30,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - old_attr_value = old_attrs.get(attr_name) new_attr_value = new_attrs.get(attr_name) if new_attr_value is None or not check_valid_float(new_attr_value): diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py index 19c43522f35..b8038b93f79 100644 --- a/homeassistant/components/fan/significant_change.py +++ b/homeassistant/components/fan/significant_change.py @@ -9,9 +9,14 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP +from . import ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE -INSIGNIFICANT_ATTRIBUTES: set[str] = {ATTR_PERCENTAGE_STEP} +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, +} @callback @@ -27,14 +32,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name in INSIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_PERCENTAGE: return True diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py index 7acc1033d3f..cc279a9fa41 100644 --- a/homeassistant/components/humidifier/significant_change.py +++ b/homeassistant/components/humidifier/significant_change.py @@ -32,14 +32,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ATTR_ACTION, ATTR_MODE]: return True diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index b2a2e57d84f..3e11cbdb9cd 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -43,14 +43,23 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + { + k: v + for k, v in old_attrs.items() + if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES + }.items() + ) + new_attrs_s = set( + { + k: v + for k, v in new_attrs.items() + if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES + }.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_MEDIA_VOLUME_LEVEL: return True diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py index 3031d60305a..5699050c7cb 100644 --- a/homeassistant/components/vacuum/significant_change.py +++ b/homeassistant/components/vacuum/significant_change.py @@ -30,14 +30,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name != ATTR_BATTERY_LEVEL: return True diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py index 903c80bb714..bacb0232ee3 100644 --- a/homeassistant/components/water_heater/significant_change.py +++ b/homeassistant/components/water_heater/significant_change.py @@ -42,15 +42,16 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} ha_unit = hass.config.units.temperature_unit for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: return True diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py index 4bb67c54e19..87e1246ce85 100644 --- a/homeassistant/components/weather/significant_change.py +++ b/homeassistant/components/weather/significant_change.py @@ -88,14 +88,15 @@ def async_check_significant_change( if old_state != new_state: return True - old_attrs_s = set(old_attrs.items()) - new_attrs_s = set(new_attrs.items()) + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name not in SIGNIFICANT_ATTRIBUTES: - continue - old_attr_value = old_attrs.get(attr_name) new_attr_value = new_attrs.get(attr_name) absolute_change: float | None = None From 6deb6ddbc4de5843d0a12957f2b55c1dcfb547c3 Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Thu, 28 Dec 2023 20:30:26 +0100 Subject: [PATCH 0031/1544] Use correct state for emulated_hue covers (#106516) --- .../components/emulated_hue/hue_api.py | 15 ++++++++--- tests/components/emulated_hue/test_hue_api.py | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index ad6b0541cd6..05e5c1ece07 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -57,6 +57,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, + STATE_CLOSED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -73,6 +74,7 @@ from homeassistant.util.network import is_local from .config import Config _LOGGER = logging.getLogger(__name__) +_OFF_STATES: dict[str, str] = {cover.DOMAIN: STATE_CLOSED} # How long to wait for a state change to happen STATE_CHANGE_WAIT_TIMEOUT = 5.0 @@ -394,7 +396,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: - parsed[STATE_ON] = entity.state != STATE_OFF + parsed[STATE_ON] = _hass_to_hue_state(entity) for key, attr in ( (HUE_API_STATE_BRI, STATE_BRIGHTNESS), @@ -585,7 +587,7 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: - state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity) hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) @@ -643,7 +645,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: cached_state = entry_state elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ STATE_ON - ] == (entity.state != STATE_OFF): + ] == _hass_to_hue_state(entity): # We only want to use the cache if the actual state of the entity # is in sync so that it can be detected as an error by Alexa. cached_state = entry_state @@ -676,7 +678,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" - is_on = entity.state != STATE_OFF + is_on = _hass_to_hue_state(entity) data: dict[str, Any] = { STATE_ON: is_on, STATE_BRIGHTNESS: None, @@ -891,6 +893,11 @@ def hass_to_hue_brightness(value: int) -> int: return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) +def _hass_to_hue_state(entity: State) -> bool: + """Convert hass entity states to simple True/False on/off state for Hue.""" + return entity.state != _OFF_STATES.get(entity.domain, STATE_OFF) + + async def wait_for_state_change_or_timeout( hass: core.HomeAssistant, entity_id: str, timeout: float ) -> None: diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 3febc42730b..167562578f2 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1019,6 +1019,12 @@ async def test_set_position_cover(hass_hue, hue_client) -> None: cover_test = hass_hue.states.get(cover_id) assert cover_test.state == "closed" + cover_json = await perform_get_light_state( + hue_client, "cover.living_room_window", HTTPStatus.OK + ) + assert cover_json["state"][HUE_API_STATE_ON] is False + assert cover_json["state"][HUE_API_STATE_BRI] == 1 + level = 20 brightness = round(level / 100 * 254) @@ -1095,6 +1101,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 await perform_put_light_state( @@ -1112,6 +1119,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert ( round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 66 ) # small rounding error in inverse operation @@ -1132,8 +1140,27 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + False, + brightness=0, + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_PERCENTAGE] == 0 + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTPStatus.OK + ) + assert fan_json["state"][HUE_API_STATE_ON] is False + assert fan_json["state"][HUE_API_STATE_BRI] == 1 + async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" From 67629111f9bdbfa8e0c5312f4590c9f3eedae0d7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 28 Dec 2023 20:39:39 +0100 Subject: [PATCH 0032/1544] Systemmonitor always load imported disks (#106546) * Systemmonitor always load legacy disks * loaded_resources --- .../components/systemmonitor/sensor.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 57838c45dc7..2bc1406308c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -389,6 +389,7 @@ async def async_setup_entry( entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} legacy_resources: list[str] = entry.options.get("resources", []) + loaded_resources: list[str] = [] disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) @@ -404,6 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -423,6 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -446,6 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) + loaded_resources.append(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -459,6 +463,7 @@ async def async_setup_entry( sensor_registry[(_type, "")] = SensorData("", None, None, None, None) is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) + loaded_resources.append(f"{_type}_") entities.append( SystemMonitorSensor( sensor_registry, @@ -469,6 +474,31 @@ async def async_setup_entry( ) ) + # Ensure legacy imported disk_* resources are loaded if they are not part + # of mount points automatically discovered + for resource in legacy_resources: + if resource.startswith("disk_"): + _LOGGER.debug( + "Check resource %s already loaded in %s", resource, loaded_resources + ) + if resource not in loaded_resources: + split_index = resource.rfind("_") + _type = resource[:split_index] + argument = resource[split_index + 1 :] + _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + SENSOR_TYPES[_type], + entry.entry_id, + argument, + True, + ) + ) + scan_interval = DEFAULT_SCAN_INTERVAL await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) async_add_entities(entities) From 90744b0a8efc88d9f5cacdba233d5e0d138f9b99 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Dec 2023 21:08:16 +0100 Subject: [PATCH 0033/1544] Revert "Set volume_step in enigma2 media_player" (#106584) --- homeassistant/components/enigma2/media_player.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 4c0911b2462..432823d781b 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -119,7 +119,6 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.SELECT_SOURCE ) - _attr_volume_step = 5 / 100 def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" @@ -141,6 +140,18 @@ class Enigma2Device(MediaPlayerEntity): """Set volume level, range 0..1.""" await self._device.set_volume(int(volume * 100)) + async def async_volume_up(self) -> None: + """Volume up the media player.""" + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) + 5) + + async def async_volume_down(self) -> None: + """Volume down media player.""" + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) - 5) + async def async_media_stop(self) -> None: """Send stop command.""" await self._device.send_remote_control_action(RemoteControlCodes.STOP) From ad199aaba23e88fcbc6a288fc0b87e39a9265dc2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 28 Dec 2023 15:08:55 -0500 Subject: [PATCH 0034/1544] Cleanup Sonos subscription used during setup (#106575) --- homeassistant/components/sonos/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e6b328cbcb0..c79856c58b6 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -290,6 +290,17 @@ class SonosDiscoveryManager: sub.callback = _async_subscription_succeeded # Hold lock to prevent concurrent subscription attempts await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2) + try: + # Cancel this subscription as we create an autorenewing + # subscription when setting up the SonosSpeaker instance + await sub.unsubscribe() + except ClientError as ex: + # Will be rejected if already replaced by new subscription + _LOGGER.debug( + "Cleanup unsubscription from %s was rejected: %s", ip_address, ex + ) + except (OSError, Timeout) as ex: + _LOGGER.error("Cleanup unsubscription from %s failed: %s", ip_address, ex) async def _async_stop_event_listener(self, event: Event | None = None) -> None: for speaker in self.data.discovered.values(): From 7441962211d978247deb873258fb83faeefe801a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 10:18:05 -1000 Subject: [PATCH 0035/1544] Bump aiohomekit to 3.1.1 (#106591) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e6ef6d58df6..edb81c14a72 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.0"], + "requirements": ["aiohomekit==3.1.1"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 14295d41ce6..52ebe2e2d7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.0 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35e066cacb9..ea2d7072dc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.0 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From c9ecf3af542dfa0d7267fb60d8b52f08f1180c32 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Dec 2023 21:58:34 +0100 Subject: [PATCH 0036/1544] Move aeptexas to aep_texas (#106595) --- .../components/{aeptexas => aep_texas}/__init__.py | 0 .../components/{aeptexas => aep_texas}/manifest.json | 2 +- homeassistant/generated/integrations.json | 8 ++++---- 3 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/{aeptexas => aep_texas}/__init__.py (100%) rename homeassistant/components/{aeptexas => aep_texas}/manifest.json (77%) diff --git a/homeassistant/components/aeptexas/__init__.py b/homeassistant/components/aep_texas/__init__.py similarity index 100% rename from homeassistant/components/aeptexas/__init__.py rename to homeassistant/components/aep_texas/__init__.py diff --git a/homeassistant/components/aeptexas/manifest.json b/homeassistant/components/aep_texas/manifest.json similarity index 77% rename from homeassistant/components/aeptexas/manifest.json rename to homeassistant/components/aep_texas/manifest.json index d6260a2f51a..5de0e0ffd77 100644 --- a/homeassistant/components/aeptexas/manifest.json +++ b/homeassistant/components/aep_texas/manifest.json @@ -1,5 +1,5 @@ { - "domain": "aeptexas", + "domain": "aep_texas", "name": "AEP Texas", "integration_type": "virtual", "supported_by": "opower" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 995609ec226..2bba24c0204 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,13 +65,13 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "aepohio": { - "name": "AEP Ohio", + "aep_texas": { + "name": "AEP Texas", "integration_type": "virtual", "supported_by": "opower" }, - "aeptexas": { - "name": "AEP Texas", + "aepohio": { + "name": "AEP Ohio", "integration_type": "virtual", "supported_by": "opower" }, From f99c37b2b5eef84f5bcb0977d8485045fd4c083b Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 28 Dec 2023 21:59:56 +0100 Subject: [PATCH 0037/1544] bump openwebifpy to 4.0.3 (#106593) --- homeassistant/components/enigma2/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 7909db3b7c7..19a2cf863f9 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.0.2"] + "requirements": ["openwebifpy==4.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52ebe2e2d7d..3eb0bd1e913 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.0.2 +openwebifpy==4.0.3 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 From 858e01ea42ccc1ec8443501f180af3accbc908c2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 28 Dec 2023 13:24:11 -0800 Subject: [PATCH 0038/1544] Rename domain aepohio to aep_ohio (#106536) Co-authored-by: Joost Lekkerkerker --- .../components/{aepohio => aep_ohio}/__init__.py | 0 .../components/{aepohio => aep_ohio}/manifest.json | 2 +- homeassistant/generated/integrations.json | 8 ++++---- 3 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/{aepohio => aep_ohio}/__init__.py (100%) rename homeassistant/components/{aepohio => aep_ohio}/manifest.json (78%) diff --git a/homeassistant/components/aepohio/__init__.py b/homeassistant/components/aep_ohio/__init__.py similarity index 100% rename from homeassistant/components/aepohio/__init__.py rename to homeassistant/components/aep_ohio/__init__.py diff --git a/homeassistant/components/aepohio/manifest.json b/homeassistant/components/aep_ohio/manifest.json similarity index 78% rename from homeassistant/components/aepohio/manifest.json rename to homeassistant/components/aep_ohio/manifest.json index f659a712016..9b85e537fc8 100644 --- a/homeassistant/components/aepohio/manifest.json +++ b/homeassistant/components/aep_ohio/manifest.json @@ -1,5 +1,5 @@ { - "domain": "aepohio", + "domain": "aep_ohio", "name": "AEP Ohio", "integration_type": "virtual", "supported_by": "opower" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2bba24c0204..45bcc1788cd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,13 +65,13 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "aep_texas": { - "name": "AEP Texas", + "aep_ohio": { + "name": "AEP Ohio", "integration_type": "virtual", "supported_by": "opower" }, - "aepohio": { - "name": "AEP Ohio", + "aep_texas": { + "name": "AEP Texas", "integration_type": "virtual", "supported_by": "opower" }, From f93b0a48313900e0637290404f950f675f299cd9 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 28 Dec 2023 16:26:19 -0500 Subject: [PATCH 0039/1544] Fix Netgear LTE halting startup (#106598) --- homeassistant/components/netgear_lte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 00a43282210..9faa2f361b9 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -170,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_HASS_CONFIG] = config if lte_config := config.get(DOMAIN): - await hass.async_create_task(import_yaml(hass, lte_config)) + hass.async_create_task(import_yaml(hass, lte_config)) return True From a46fe9421669f55016531d917444d851e7634a18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 12:24:36 -1000 Subject: [PATCH 0040/1544] Add helper to report deprecated entity supported features magic numbers (#106602) --- homeassistant/helpers/entity.py | 30 +++++++++++++++++++++++++++++- tests/helpers/test_entity.py | 26 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8f344aff484..fc627f51acf 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping import dataclasses from datetime import timedelta -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -460,6 +460,9 @@ class Entity( # If we reported if this entity was slow _slow_reported = False + # If we reported deprecated supported features constants + _deprecated_supported_features_reported = False + # If we reported this entity is updated while disabled _disabled_reported = False @@ -1496,6 +1499,31 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) + class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2bf90660f31..96bbf95a986 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any @@ -2025,3 +2026,28 @@ async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> No for ent in entities: assert getattr(ent[0], property) == values[1] assert getattr(ent[1], property) == values[0] + + +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) From 97ee7e2c98de7b0606009c8647c2ad3e0db02171 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:15:48 -1000 Subject: [PATCH 0041/1544] Remote platform back-compat for custom components without RemoteEntityFeature (#106609) --- homeassistant/components/remote/__init__.py | 15 ++++++++++++++- tests/components/remote/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 8c3d094710e..7e9ebfe12b9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -200,6 +200,19 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> RemoteEntityFeature: + """Return the supported features as RemoteEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = RemoteEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def current_activity(self) -> str | None: """Active activity.""" @@ -214,7 +227,7 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if RemoteEntityFeature.ACTIVITY not in self.supported_features: + if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: return None return { diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index b185b229cd2..a75ff858483 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -150,3 +150,23 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockRemote(remote.RemoteEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockRemote() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "MockRemote" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text + caplog.clear() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From d0e9f2ce0df294458c3bdf440a9afac295a2cfcd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:16:02 -1000 Subject: [PATCH 0042/1544] Water heater platform back-compat for custom components without WaterHeaterEntityFeature (#106608) --- .../components/water_heater/__init__.py | 13 ++++++++++++ tests/components/water_heater/test_init.py | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index ddef4e7366c..f2744416900 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -401,6 +401,19 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> WaterHeaterEntityFeature: + """Return the supported features as WaterHeaterEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = WaterHeaterEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 8a7d76bd891..0d33f3a9e93 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -115,3 +115,23 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, water_heater, enum, "SUPPORT_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockWaterHeaterEntity(WaterHeaterEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockWaterHeaterEntity() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "MockWaterHeaterEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "WaterHeaterEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 3e7c44c61208cb7bf7bf3ba7b936e558ea9c73d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:36:15 -1000 Subject: [PATCH 0043/1544] Fan platform back-compat for custom components without FanEntityFeature (#106607) --- homeassistant/components/fan/__init__.py | 17 +++++++++++++++-- tests/components/fan/test_init.py | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 1bacc6d8dac..dedaedfe600 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -400,7 +400,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( FanEntityFeature.SET_SPEED in supported_features @@ -415,7 +415,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if FanEntityFeature.DIRECTION in supported_features: data[ATTR_DIRECTION] = self.current_direction @@ -439,6 +439,19 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> FanEntityFeature: + """Return the supported features as FanEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = FanEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite. diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index e6a3ab546cc..828c13b6f16 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.fan import ( DOMAIN, SERVICE_SET_PRESET_MODE, FanEntity, + FanEntityFeature, NotValidPresetModeError, ) from homeassistant.core import HomeAssistant @@ -156,3 +157,23 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockFan(FanEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockFan() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "MockFan" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "FanEntityFeature.SET_SPEED" in caplog.text + caplog.clear() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 552d4e49f0de6f6f1fe10c23cfecac791d533f68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:36:28 -1000 Subject: [PATCH 0044/1544] Climate platform back-compat for custom components without ClimateEntityFeature (#106605) --- homeassistant/components/climate/__init__.py | 17 +++++++++++++++-- tests/components/climate/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 4815b7a1cbb..19e26265f70 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -316,7 +316,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -349,7 +349,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -665,6 +665,19 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> ClimateEntityFeature: + """Return the supported features as ClimateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = ClimateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index f46e0902c66..8fc82365c23 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -333,3 +333,23 @@ async def test_preset_mode_validation( ) assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" assert exc.value.translation_key == "not_valid_fan_mode" + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockClimateEntity(ClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockClimateEntity() + assert entity.supported_features_compat is ClimateEntityFeature(1) + 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_compat is ClimateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 6506a8d511a2281cdf2af2a45b0b71b8a015b19a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 13:45:35 -1000 Subject: [PATCH 0045/1544] Camera platform back-compat for custom components without CameraEntityFeature (#106529) --- homeassistant/components/camera/__init__.py | 17 +++++++++++++++-- tests/components/camera/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 9f5ec0a6740..7a56292f7bb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -530,6 +530,19 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -570,7 +583,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ if hasattr(self, "_attr_frontend_stream_type"): return self._attr_frontend_stream_type - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if self._rtsp_to_webrtc: return StreamType.WEB_RTC @@ -758,7 +771,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def _async_use_rtsp_to_webrtc(self) -> bool: """Determine if a WebRTC provider can be used for the camera.""" - if CameraEntityFeature.STREAM not in self.supported_features: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return False if DATA_RTSP_TO_WEB_RTC not in self.hass.data: return False diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index cb9b09a85ab..0e761f2f437 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -993,3 +993,23 @@ def test_deprecated_support_constants( import_and_test_deprecated_constant_enum( caplog, camera, entity_feature, "SUPPORT_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 81726808e82ba6d1e415dbc5a0468eb9b9afe12b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:06:25 -1000 Subject: [PATCH 0046/1544] Vacuum platform back-compat for custom components without VacuumEntityFeature (#106614) --- homeassistant/components/vacuum/__init__.py | 19 ++++++++++++--- tests/components/vacuum/test_init.py | 26 ++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 3ff29ec4e47..9a10da23824 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -258,6 +258,19 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -281,7 +294,7 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> Mapping[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -289,7 +302,7 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -471,7 +484,7 @@ class VacuumEntity( """Return the state attributes of the vacuum cleaner.""" data = super().state_attributes - if VacuumEntityFeature.STATUS in self.supported_features: + if VacuumEntityFeature.STATUS in self.supported_features_compat: data[ATTR_STATUS] = self.status return data diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 3cf77d4f420..0b44476989b 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,11 @@ from collections.abc import Generator import pytest -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntity +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -121,3 +125,23 @@ async def test_deprecated_base_class( issue.translation_placeholders == {"platform": "test"} | translation_placeholders_extra ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockVacuumEntity(VacuumEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockVacuumEntity() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "MockVacuumEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "VacuumEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 4b6aaf6254236348dbc253cd0ffb9587ab9a9f4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:10:26 -1000 Subject: [PATCH 0047/1544] Update platform back-compat for custom components without UpdateEntityFeature (#106528) --- homeassistant/components/update/__init__.py | 21 +++++++++++++++++---- tests/components/update/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 43a2a3e785f..40431332aaf 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -263,7 +263,7 @@ class UpdateEntity( return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if UpdateEntityFeature.INSTALL in self.supported_features: + if UpdateEntityFeature.INSTALL in self.supported_features_compat: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -322,6 +322,19 @@ class UpdateEntity( """ return self._attr_title + @property + def supported_features_compat(self) -> UpdateEntityFeature: + """Return the supported features as UpdateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = UpdateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @final async def async_skip(self) -> None: """Skip the current offered version to update.""" @@ -408,7 +421,7 @@ class UpdateEntity( # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if UpdateEntityFeature.PROGRESS in self.supported_features: + if UpdateEntityFeature.PROGRESS in self.supported_features_compat: in_progress = self.in_progress else: in_progress = self.__in_progress @@ -444,7 +457,7 @@ class UpdateEntity( Handles setting the in_progress state in case the entity doesn't support it natively. """ - if UpdateEntityFeature.PROGRESS not in self.supported_features: + if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: self.__in_progress = True self.async_write_ha_state() @@ -490,7 +503,7 @@ async def websocket_release_notes( ) return - if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_SUPPORTED, diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 629c6838654..92e63af4b6f 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -865,3 +865,23 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state assert expected.items() <= state.attributes.items() + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockUpdateEntity(UpdateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockUpdateEntity() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "MockUpdateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "UpdateEntityFeature.INSTALL" in caplog.text + caplog.clear() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 931e90ab208b9737031c86222fac34108fbc8668 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:10:46 -1000 Subject: [PATCH 0048/1544] Humidifier platform back-compat for custom components without HumidifierEntityFeature (#106613) --- .../components/humidifier/__init__.py | 15 ++++++++++- tests/components/humidifier/test_init.py | 25 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 821cc8c4f37..75d4f0fd225 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -185,7 +185,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT ATTR_MAX_HUMIDITY: self.max_humidity, } - if HumidifierEntityFeature.MODES in self.supported_features: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_AVAILABLE_MODES] = self.available_modes return data @@ -280,3 +280,16 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> HumidifierEntityFeature: + """Return the supported features as HumidifierEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = HumidifierEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index da45e1f1661..45da5ba750f 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock import pytest from homeassistant.components import humidifier -from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier import ( + HumidifierEntity, + HumidifierEntityFeature, +) from homeassistant.core import HomeAssistant from tests.common import import_and_test_deprecated_constant_enum @@ -66,3 +69,23 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, module, enum, constant_prefix, "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockHumidifierEntity(HumidifierEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockHumidifierEntity() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "MockHumidifierEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "HumidifierEntityFeature.MODES" in caplog.text + caplog.clear() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From e0b6d4e216b7e258e0bf9d110abd401377b4d9e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 14:32:44 -1000 Subject: [PATCH 0049/1544] Media player platform back-compat for custom components without MediaPlayerEntityFeature (#106616) --- .../components/media_player/__init__.py | 49 ++++++++++++------- tests/components/media_player/test_init.py | 22 +++++++++ 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a4439c9c68e..113048421e1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -766,6 +766,19 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError() @@ -905,85 +918,87 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat + ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1012,7 +1027,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1030,7 +1045,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1073,7 +1088,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat if ( source_list := self.source_list diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 377cdd32748..b4228d1ee69 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,6 +10,8 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerEnqueue, + MediaPlayerEntity, + MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF @@ -327,3 +329,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 2b972f6dba6344405673dd1431037ce8e15eba37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:03:14 -1000 Subject: [PATCH 0050/1544] Add deprecation warning for lock supported features when using magic numbers (#106620) --- homeassistant/components/lock/__init__.py | 7 ++++++- tests/components/lock/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9a2466e22dd..a9370f8d092 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -278,7 +278,12 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = LockEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index c4337c367a9..854b89fd1d8 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -378,3 +378,20 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLockEntity(lock.LockEntity): + _attr_supported_features = 1 + + entity = MockLockEntity() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "MockLockEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LockEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From e6c632746325d2cbc27e1ba5ad59af2d9fffbb70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:04:06 -1000 Subject: [PATCH 0051/1544] Add deprecation warning for alarm_control_panel supported features when using magic numbers (#106619) --- .../alarm_control_panel/__init__.py | 7 +++++- .../alarm_control_panel/test_init.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index dd42c6c7072..9c53f2b7fd0 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -233,7 +233,12 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = AlarmControlPanelEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features @final @property diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index c447119c119..1e6fce6def6 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -45,3 +45,26 @@ def test_deprecated_support_alarm_constants( import_and_test_deprecated_constant_enum( caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): + _attr_supported_features = 1 + + entity = MockAlarmControlPanelEntity() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "MockAlarmControlPanelEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text + caplog.clear() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "is using deprecated supported features values" not in caplog.text From e4a25825d3b0a2b44f1bb57b87a95e25587a9e6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:45:06 -1000 Subject: [PATCH 0052/1544] Migrate light entity to use contains for LightEntityFeature with deprecation warnings (#106622) --- homeassistant/components/light/__init__.py | 67 ++++++++++++++++------ tests/components/light/test_init.py | 21 +++++++ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c66562a53af..ebd3696d61f 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -345,11 +345,11 @@ def filter_turn_off_params( light: LightEntity, params: dict[str, Any] ) -> dict[str, Any]: """Filter out params not used in turn off or not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} @@ -357,13 +357,13 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) supported_color_modes = ( @@ -989,7 +989,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -1007,7 +1007,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( self.min_color_temp_kelvin ) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list data[ATTR_SUPPORTED_COLOR_MODES] = sorted(supported_color_modes) @@ -1061,8 +1061,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes + supported_features_value = supported_features.value color_mode = self._light_internal_color_mode if self.is_on else None if color_mode and color_mode not in supported_color_modes: @@ -1081,7 +1082,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features & SUPPORT_BRIGHTNESS: + elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1103,7 +1104,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - elif supported_features & SUPPORT_COLOR_TEMP: + elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1133,7 +1134,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if color_mode: data.update(self._light_internal_convert_color(color_mode)) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT] = self.effect if self.is_on else None return data @@ -1146,14 +1147,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Backwards compatibility for supported_color_modes added in 2021.4 # Add warning in 2021.6, remove in 2021.10 - supported_features = self.supported_features + supported_features = self.supported_features_compat + supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() - if supported_features & SUPPORT_COLOR_TEMP: + if supported_features_value & SUPPORT_COLOR_TEMP: supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features & SUPPORT_COLOR: + if supported_features_value & SUPPORT_COLOR: supported_color_modes.add(ColorMode.HS) - if supported_features & SUPPORT_BRIGHTNESS and not supported_color_modes: + if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: supported_color_modes = {ColorMode.BRIGHTNESS} if not supported_color_modes: @@ -1170,3 +1172,34 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: # noqa: E721 + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 962c5500f06..903002063e8 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2589,3 +2589,24 @@ def test_filter_supported_color_modes() -> None: # ColorMode.BRIGHTNESS has priority over ColorMode.ONOFF supported = {light.ColorMode.ONOFF, light.ColorMode.BRIGHTNESS} assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From a47587e3cde5f18b7314113a57e99fd60120f20a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 15:45:27 -1000 Subject: [PATCH 0053/1544] Add deprecation warning for siren supported features when using magic numbers (#106621) --- homeassistant/components/siren/__init__.py | 7 ++++++- tests/components/siren/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 263c6697df6..29ad238ac00 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -212,4 +212,9 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = SirenEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index ee007f6f1f5..abc5b0fac38 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -119,3 +119,20 @@ def test_deprecated_constants( ) -> None: """Test deprecated constants.""" import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockSirenEntity(siren.SirenEntity): + _attr_supported_features = 1 + + entity = MockSirenEntity() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "MockSirenEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "SirenEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From ee2689de3c799eafa1e183215f7220632a119541 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 28 Dec 2023 17:45:34 -0800 Subject: [PATCH 0054/1544] Refactor screenlogic numbers to use subclasses (#106574) --- .../components/screenlogic/number.py | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index cc5efa6c7ad..1ff611b2c9f 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,6 +1,4 @@ """Support for a ScreenLogic number entity.""" -import asyncio -from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging @@ -29,31 +27,21 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -@dataclass(frozen=True) -class ScreenLogicNumberRequiredMixin: - """Describes a required mixin for a ScreenLogic number entity.""" - - set_value_name: str - - @dataclass(frozen=True) class ScreenLogicNumberDescription( NumberEntityDescription, ScreenLogicEntityDescription, - ScreenLogicNumberRequiredMixin, ): """Describes a ScreenLogic number entity.""" SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( - set_value_name="async_set_scg_config", data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -82,13 +70,13 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) continue if gateway.get_data(*scg_number_data_path): - entities.append(ScreenLogicNumber(coordinator, scg_number_description)) + entities.append(ScreenLogicSCGNumber(coordinator, scg_number_description)) async_add_entities(entities) class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number entity.""" + """Base class to represent a ScreenLogic Number entity.""" entity_description: ScreenLogicNumberDescription @@ -99,13 +87,7 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): ) -> None: """Initialize a ScreenLogic number entity.""" super().__init__(coordinator, entity_description) - if not asyncio.iscoroutinefunction( - func := getattr(self.gateway, entity_description.set_value_name) - ): - raise TypeError( - f"set_value_name '{entity_description.set_value_name}' is not a coroutine" - ) - self._set_value_func: Callable[..., Awaitable[bool]] = func + self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -127,6 +109,14 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): """Return the current value.""" return self.entity_data[ATTR.VALUE] + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + raise NotImplementedError() + + +class ScreenLogicSCGNumber(ScreenLogicNumber): + """Class to represent a ScreenLoigic SCG Number entity.""" + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" @@ -134,7 +124,7 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): value = int(value) try: - await self._set_value_func(**{self._data_key: value}) + await self.gateway.async_set_scg_config(**{self._data_key: value}) except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( f"Failed to set '{self._data_key}' to {value}: {sle.msg}" From 7051f28547eb78be4c09f6dc8a5803630f0ad098 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 21:34:08 -1000 Subject: [PATCH 0055/1544] Add deprecation warning for cover supported features when using magic numbers (#106618) --- homeassistant/components/cover/__init__.py | 8 ++++++-- tests/components/cover/test_init.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1a21908860a..3e438fb4ca1 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -340,8 +340,12 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - if self._attr_supported_features is not None: - return self._attr_supported_features + if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 062440e6b39..1b08658d983 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -141,3 +141,20 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum( caplog, cover, enum, constant_prefix, "2025.1" ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text From 03fcb81a593a647cb1a8cf607d7675344c9e8150 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 21:36:20 -1000 Subject: [PATCH 0056/1544] Small speed up to compressed state diff (#106624) --- .../components/websocket_api/messages.py | 2 +- .../components/websocket_api/test_messages.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 34ca6886b5e..3aaeff6a797 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -204,7 +204,7 @@ def _state_diff( for key, value in new_attributes.items(): if old_attributes.get(key) != value: additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value - if removed := set(old_attributes).difference(new_attributes): + if removed := old_attributes.keys() - new_attributes: # sets are not JSON serializable by default so we convert to list # here if there are any values to avoid jumping into the json_encoder_default # for every state diff with a removed attribute diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 35ed55183d4..24387a89a29 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -237,6 +237,50 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: } } + hass.states.async_set( + "light.window", + "green", + {"list_attr": ["a", "b", "c", "d"], "list_attr_2": ["a", "b"]}, + context=new_context, + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "a": {"list_attr": ["a", "b", "c", "d"], "list_attr_2": ["a", "b"]}, + "lu": new_state.last_updated.timestamp(), + } + } + } + } + + hass.states.async_set( + "light.window", + "green", + {"list_attr": ["a", "b", "c", "e"]}, + context=new_context, + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + assert message == { + "c": { + "light.window": { + "+": { + "a": {"list_attr": ["a", "b", "c", "e"]}, + "lu": new_state.last_updated.timestamp(), + }, + "-": {"a": ["list_attr_2"]}, + } + } + } + async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" From 7702f971fbb6e89d6510c716352fd08d2e85d1f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Dec 2023 21:37:44 -1000 Subject: [PATCH 0057/1544] Use built-in set methods for light supported checks (#106625) --- homeassistant/components/light/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index ebd3696d61f..77510b02035 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -160,14 +160,14 @@ def brightness_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: """Test if brightness is supported.""" if not color_modes: return False - return any(mode in COLOR_MODES_BRIGHTNESS for mode in color_modes) + return not COLOR_MODES_BRIGHTNESS.isdisjoint(color_modes) def color_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: """Test if color is supported.""" if not color_modes: return False - return any(mode in COLOR_MODES_COLOR for mode in color_modes) + return not COLOR_MODES_COLOR.isdisjoint(color_modes) def color_temp_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: From a4e9a053c7b3a79f8222d0798d0cd9247cfdb7eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Dec 2023 10:04:16 +0100 Subject: [PATCH 0058/1544] Fix missing await when running shutdown jobs (#106632) --- homeassistant/core.py | 2 +- tests/test_core.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 72287fb81ce..51cb3d4e496 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -889,7 +889,7 @@ class HomeAssistant: continue tasks.append(task_or_none) if tasks: - asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather(*tasks, return_exceptions=True) except asyncio.TimeoutError: _LOGGER.warning( "Timed out waiting for shutdown jobs to complete, the shutdown will" diff --git a/tests/test_core.py b/tests/test_core.py index 5f5be1b05db..90b87068a5d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2605,6 +2605,9 @@ async def test_shutdown_job(hass: HomeAssistant) -> None: evt = asyncio.Event() async def shutdown_func() -> None: + # Sleep to ensure core is waiting for the task to finish + await asyncio.sleep(0.01) + # Set the event evt.set() job = HassJob(shutdown_func, "shutdown_job") From c2cfc8ab46d88efe61fa374d302110ab74f12587 Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Fri, 29 Dec 2023 12:29:06 +0300 Subject: [PATCH 0059/1544] Add GPS satellites count to Starline sensor (#105740) * GPS Satellites count Starline sensor * Update homeassistant/components/starline/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/starline/strings.json Co-authored-by: Joost Lekkerkerker * Capitalise units * Revert capitalisation --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> --- homeassistant/components/starline/sensor.py | 8 ++++++++ homeassistant/components/starline/strings.json | 3 +++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 603cceec222..1a43601940e 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -70,6 +70,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.DISTANCE, icon="mdi:counter", ), + SensorEntityDescription( + key="gps_count", + translation_key="gps_count", + icon="mdi:satellite-variant", + native_unit_of_measurement="satellites", + ), ) @@ -132,6 +138,8 @@ class StarlineSensor(StarlineEntity, SensorEntity): return self._device.errors.get("val") if self._key == "mileage" and self._device.mileage: return self._device.mileage.get("val") + if self._key == "gps_count" and self._device.position: + return self._device.position["sat_qty"] return None @property diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 9631dbf7479..6f0c42f0882 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -99,6 +99,9 @@ }, "mileage": { "name": "Mileage" + }, + "gps_count": { + "name": "GPS satellites" } }, "switch": { From 149fdfb802952d8210c2e49e3a7d8c1fb2e83e42 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 29 Dec 2023 19:32:13 +1000 Subject: [PATCH 0060/1544] Minor improvements to Tessie device entries (#106623) Add more fields to device info --- homeassistant/components/tessie/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index be80caf50cb..bfedd7eb43d 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -38,8 +38,9 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): configuration_url="https://my.tessie.com/", name=coordinator.data["display_name"], model=MODELS.get(car_type, car_type), - sw_version=coordinator.data["vehicle_state_car_version"], + sw_version=coordinator.data["vehicle_state_car_version"].split(" ")[0], hw_version=coordinator.data["vehicle_config_driver_assist"], + serial_number=self.vin, ) @property From 27bdbc66000d39a691fdcc17cf229c5e0a86e38d Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 29 Dec 2023 05:07:56 -0500 Subject: [PATCH 0061/1544] Add entity name and translations to Netgear LTE (#106599) --- .../components/netgear_lte/binary_sensor.py | 3 ++ .../components/netgear_lte/entity.py | 2 +- .../components/netgear_lte/sensor.py | 28 +++++++--- .../components/netgear_lte/strings.json | 54 +++++++++++++++++++ .../netgear_lte/test_binary_sensor.py | 6 +-- tests/components/netgear_lte/test_sensor.py | 26 ++++----- 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index ce179c9e980..ccabcc3b3ea 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -16,13 +16,16 @@ from .entity import LTEEntity BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="roaming", + translation_key="roaming", ), BinarySensorEntityDescription( key="wire_connected", + translation_key="wire_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), BinarySensorEntityDescription( key="mobile_connected", + translation_key="mobile_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 02827e7df3f..0ec16ceff9d 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -13,6 +13,7 @@ from .const import DISPATCHER_NETGEAR_LTE, DOMAIN, MANUFACTURER class LTEEntity(Entity): """Base LTE entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( @@ -24,7 +25,6 @@ class LTEEntity(Entity): """Initialize a Netgear LTE entity.""" self.entity_description = description self.modem_data = modem_data - self._attr_name = f"Netgear LTE {description.key}" self._attr_unique_id = f"{description.key}_{modem_data.data.serial_number}" self._attr_device_info = DeviceInfo( configuration_url=f"http://{config_entry.data[CONF_HOST]}", diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index d281c93d795..49702c1ce41 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -34,39 +34,53 @@ class NetgearLTESensorEntityDescription(SensorEntityDescription): SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( NetgearLTESensorEntityDescription( key="sms", + translation_key="sms", native_unit_of_measurement="unread", value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), ), NetgearLTESensorEntityDescription( key="sms_total", + translation_key="sms_total", native_unit_of_measurement="messages", value_fn=lambda modem_data: len(modem_data.data.sms), ), NetgearLTESensorEntityDescription( key="usage", + translation_key="usage", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.MEBIBYTES, value_fn=lambda modem_data: round(modem_data.data.usage / 1024**2, 1), ), NetgearLTESensorEntityDescription( key="radio_quality", + translation_key="radio_quality", native_unit_of_measurement=PERCENTAGE, ), NetgearLTESensorEntityDescription( key="rx_level", + translation_key="rx_level", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), NetgearLTESensorEntityDescription( key="tx_level", + translation_key="tx_level", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), - NetgearLTESensorEntityDescription(key="upstream"), - NetgearLTESensorEntityDescription(key="connection_text"), - NetgearLTESensorEntityDescription(key="connection_type"), - NetgearLTESensorEntityDescription(key="current_ps_service_type"), - NetgearLTESensorEntityDescription(key="register_network_display"), - NetgearLTESensorEntityDescription(key="current_band"), - NetgearLTESensorEntityDescription(key="cell_id"), + NetgearLTESensorEntityDescription(key="upstream", translation_key="upstream"), + NetgearLTESensorEntityDescription( + key="connection_text", translation_key="connection_text" + ), + NetgearLTESensorEntityDescription( + key="connection_type", translation_key="connection_type" + ), + NetgearLTESensorEntityDescription( + key="current_ps_service_type", translation_key="service_type" + ), + NetgearLTESensorEntityDescription( + key="register_network_display", translation_key="register_network_display" + ), + NetgearLTESensorEntityDescription(key="current_band", translation_key="band"), + NetgearLTESensorEntityDescription(key="cell_id", translation_key="cell_id"), ) diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 8992fb50670..5719d693d15 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -80,5 +80,59 @@ } } } + }, + "entity": { + "binary_sensor": { + "mobile_connected": { + "name": "Mobile connected" + }, + "roaming": { + "name": "Roaming" + }, + "wire_connected": { + "name": "Wire connected" + } + }, + "sensor": { + "band": { + "name": "Current band" + }, + "cell_id": { + "name": "Cell ID" + }, + "connection_text": { + "name": "Connection text" + }, + "connection_type": { + "name": "Connection type" + }, + "radio_quality": { + "name": "Radio quality" + }, + "register_network_display": { + "name": "Register network display" + }, + "rx_level": { + "name": "Rx level" + }, + "service_type": { + "name": "Service type" + }, + "sms": { + "name": "SMS" + }, + "sms_total": { + "name": "SMS total" + }, + "tx_level": { + "name": "Tx level" + }, + "upstream": { + "name": "Upstream" + }, + "usage": { + "name": "Usage" + } + } } } diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py index 8ed43c8c887..9d45194aa69 100644 --- a/tests/components/netgear_lte/test_binary_sensor.py +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -9,11 +9,11 @@ from homeassistant.core import HomeAssistant @pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") async def test_binary_sensors(hass: HomeAssistant) -> None: """Test for successfully setting up the Netgear LTE binary sensor platform.""" - state = hass.states.get("binary_sensor.netgear_lte_mobile_connected") + state = hass.states.get("binary_sensor.netgear_lm1200_mobile_connected") assert state.state == STATE_ON assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lte_wire_connected") + state = hass.states.get("binary_sensor.netgear_lm1200_wire_connected") assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lte_roaming") + state = hass.states.get("binary_sensor.netgear_lm1200_roaming") assert state.state == STATE_OFF diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py index 8682af9a5c3..cdd7fbbd38e 100644 --- a/tests/components/netgear_lte/test_sensor.py +++ b/tests/components/netgear_lte/test_sensor.py @@ -15,42 +15,42 @@ from homeassistant.core import HomeAssistant @pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") async def test_sensors(hass: HomeAssistant) -> None: """Test for successfully setting up the Netgear LTE sensor platform.""" - state = hass.states.get("sensor.netgear_lte_cell_id") + state = hass.states.get("sensor.netgear_lm1200_cell_id") assert state.state == "12345678" - state = hass.states.get("sensor.netgear_lte_connection_text") + state = hass.states.get("sensor.netgear_lm1200_connection_text") assert state.state == "4G" - state = hass.states.get("sensor.netgear_lte_connection_type") + state = hass.states.get("sensor.netgear_lm1200_connection_type") assert state.state == "IPv4AndIPv6" - state = hass.states.get("sensor.netgear_lte_current_band") + state = hass.states.get("sensor.netgear_lm1200_current_band") assert state.state == "LTE B4" - state = hass.states.get("sensor.netgear_lte_current_ps_service_type") + state = hass.states.get("sensor.netgear_lm1200_service_type") assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lte_radio_quality") + state = hass.states.get("sensor.netgear_lm1200_radio_quality") assert state.state == "52" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - state = hass.states.get("sensor.netgear_lte_register_network_display") + state = hass.states.get("sensor.netgear_lm1200_register_network_display") assert state.state == "T-Mobile" - state = hass.states.get("sensor.netgear_lte_rx_level") + state = hass.states.get("sensor.netgear_lm1200_rx_level") assert state.state == "-113" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) - state = hass.states.get("sensor.netgear_lte_sms") + state = hass.states.get("sensor.netgear_lm1200_sms") assert state.state == "1" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "unread" - state = hass.states.get("sensor.netgear_lte_sms_total") + state = hass.states.get("sensor.netgear_lm1200_sms_total") assert state.state == "1" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "messages" - state = hass.states.get("sensor.netgear_lte_tx_level") + state = hass.states.get("sensor.netgear_lm1200_tx_level") assert state.state == "4" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) - state = hass.states.get("sensor.netgear_lte_upstream") + state = hass.states.get("sensor.netgear_lm1200_upstream") assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lte_usage") + state = hass.states.get("sensor.netgear_lm1200_usage") assert state.state == "40.5" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.MEBIBYTES assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE From e17e372c9497f1ac8916cdf0d584029e892b4f59 Mon Sep 17 00:00:00 2001 From: Joe Neuman Date: Fri, 29 Dec 2023 02:08:40 -0800 Subject: [PATCH 0062/1544] Fix count bug in qBittorrent (#106603) --- homeassistant/components/qbittorrent/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index a51ff58405c..9373aec8544 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -165,6 +165,9 @@ def count_torrents_in_states( coordinator: QBittorrentDataCoordinator, states: list[str] ) -> int: """Count the number of torrents in specified states.""" + if not states: + return len(coordinator.data["torrents"]) + return len( [ torrent From 19e0f55fc8ea171f5f7a1562a36f518490797b90 Mon Sep 17 00:00:00 2001 From: Jirka Date: Fri, 29 Dec 2023 12:01:23 +0100 Subject: [PATCH 0063/1544] Fix typo in Blink strings (#106641) Update strings.json Fixed typo. --- homeassistant/components/blink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 87e2fc68c20..a875fb3e343 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -106,7 +106,7 @@ }, "exceptions": { "integration_not_found": { - "message": "Integraion '{target}' not found in registry" + "message": "Integration '{target}' not found in registry" }, "no_path": { "message": "Can't write to directory {target}, no access to path!" From 4cd19657861986e78283e6e503f819435a9b7363 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 29 Dec 2023 13:21:08 +0100 Subject: [PATCH 0064/1544] Use set instead of list in Systemmonitor (#106650) --- .../components/systemmonitor/config_flow.py | 2 +- .../components/systemmonitor/sensor.py | 14 +++++++------- homeassistant/components/systemmonitor/util.py | 18 +++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 3dc45480aee..6d9787a39f5 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -86,7 +86,7 @@ async def validate_import_sensor_setup( async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Return process sensor setup schema.""" hass = handler.parent_handler.hass - processes = await hass.async_add_executor_job(get_all_running_processes) + processes = list(await hass.async_add_executor_job(get_all_running_processes)) return vol.Schema( { vol.Required(CONF_PROCESS): SelectSelector( diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 2bc1406308c..28929d07a7c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -267,7 +267,7 @@ def check_required_arg(value: Any) -> Any: return value -def check_legacy_resource(resource: str, resources: list[str]) -> bool: +def check_legacy_resource(resource: str, resources: set[str]) -> bool: """Return True if legacy resource was configured.""" # This function to check legacy resources can be removed # once we are removing the import from YAML @@ -388,8 +388,8 @@ async def async_setup_entry( """Set up System Montor sensors based on a config entry.""" entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} - legacy_resources: list[str] = entry.options.get("resources", []) - loaded_resources: list[str] = [] + legacy_resources: set[str] = set(entry.options.get("resources", [])) + loaded_resources: set[str] = set() disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) @@ -405,7 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -425,7 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -449,7 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) - loaded_resources.append(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( sensor_registry, @@ -463,7 +463,7 @@ async def async_setup_entry( sensor_registry[(_type, "")] = SensorData("", None, None, None, None) is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) - loaded_resources.append(f"{_type}_") + loaded_resources.add(f"{_type}_") entities.append( SystemMonitorSensor( sensor_registry, diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 27c4c449634..2baacb9d16f 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -8,9 +8,9 @@ import psutil _LOGGER = logging.getLogger(__name__) -def get_all_disk_mounts() -> list[str]: +def get_all_disk_mounts() -> set[str]: """Return all disk mount points on system.""" - disks: list[str] = [] + disks: set[str] = set() for part in psutil.disk_partitions(all=True): if os.name == "nt": if "cdrom" in part.opts or part.fstype == "": @@ -20,25 +20,25 @@ def get_all_disk_mounts() -> list[str]: continue usage = psutil.disk_usage(part.mountpoint) if usage.total > 0 and part.device != "": - disks.append(part.mountpoint) + disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) return disks -def get_all_network_interfaces() -> list[str]: +def get_all_network_interfaces() -> set[str]: """Return all network interfaces on system.""" - interfaces: list[str] = [] + interfaces: set[str] = set() for interface, _ in psutil.net_if_addrs().items(): - interfaces.append(interface) + interfaces.add(interface) _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) return interfaces -def get_all_running_processes() -> list[str]: +def get_all_running_processes() -> set[str]: """Return all running processes on system.""" - processes: list[str] = [] + processes: set[str] = set() for proc in psutil.process_iter(["name"]): if proc.name() not in processes: - processes.append(proc.name()) + processes.add(proc.name()) _LOGGER.debug("Running processes: %s", ", ".join(processes)) return processes From 853e4d87fab1fecb9b7646736045384271d598af Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 29 Dec 2023 13:21:36 +0100 Subject: [PATCH 0065/1544] Handle no permission for disks in Systemmonitor (#106653) --- homeassistant/components/systemmonitor/util.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 2baacb9d16f..25b8aa2eb1d 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -18,7 +18,13 @@ def get_all_disk_mounts() -> set[str]: # ENOENT, pop-up a Windows GUI error for a non-ready # partition or just hang. continue - usage = psutil.disk_usage(part.mountpoint) + try: + usage = psutil.disk_usage(part.mountpoint) + except PermissionError: + _LOGGER.debug( + "No permission for running user to access %s", part.mountpoint + ) + continue if usage.total > 0 and part.device != "": disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) From 95d7a66c16a1e3526bd3f575380b03e7e385116f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Dec 2023 13:22:06 +0100 Subject: [PATCH 0066/1544] Fix yolink entity descriptions (#106649) --- homeassistant/components/yolink/sensor.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4ac9379d763..ace13353341 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -48,21 +48,13 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass(frozen=True) -class YoLinkSensorEntityDescriptionMixin: - """Mixin for device type.""" - - exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - - -@dataclass(frozen=True) -class YoLinkSensorEntityDescription( - YoLinkSensorEntityDescriptionMixin, SensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class YoLinkSensorEntityDescription(SensorEntityDescription): """YoLink SensorEntityDescription.""" - value: Callable = lambda state: state + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True should_update_entity: Callable = lambda state: True + value: Callable = lambda state: state SENSOR_DEVICE_TYPE = [ From 56a58f928570928b6e9b8a1ca04d7da2bcedb2a8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Dec 2023 13:22:52 +0100 Subject: [PATCH 0067/1544] Improve tests of inheriting entity descriptions (#106647) --- tests/helpers/snapshots/test_entity.ambr | 206 ++++++++++++++++++++++- tests/helpers/test_entity.py | 184 +++++++++++++++++++- 2 files changed, 380 insertions(+), 10 deletions(-) diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index 1031134d2ad..cec9d05c8e1 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -42,17 +42,99 @@ 'entity_category': None, 'entity_registry_enabled_default': True, 'entity_registry_visible_default': True, + 'extra': 'foo', 'force_update': False, 'has_entity_name': False, 'icon': None, 'key': 'blah', + 'mixin': 'mixin', 'name': 'name', 'translation_key': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.11 - "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" + "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.12 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.13 + "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.14 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.15 + "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.16 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.17 + "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.18 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.19 + "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.2 dict({ @@ -70,9 +152,127 @@ 'unit_of_measurement': None, }) # --- +# name: test_extending_entity_description.20 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.21 + "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.22 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.23 + "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.24 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.25 + "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.26 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.27 + "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.28 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.29 + "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- # name: test_extending_entity_description.3 "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- +# name: test_extending_entity_description.30 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.31 + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" +# --- # name: test_extending_entity_description.4 dict({ 'device_class': None, @@ -111,7 +311,7 @@ }) # --- # name: test_extending_entity_description.7 - "test_extending_entity_description..ComplexEntityDescription1(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.8 dict({ @@ -131,5 +331,5 @@ }) # --- # name: test_extending_entity_description.9 - "test_extending_entity_description..ComplexEntityDescription2(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" # --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 96bbf95a986..b85c8794fed 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1716,31 +1716,201 @@ def test_extending_entity_description(snapshot: SnapshotAssertion): # Try multiple direct parents @dataclasses.dataclass(frozen=True) - class MyMixin: + class MyMixin1: + mixin: str + + @dataclasses.dataclass + class MyMixin2: + mixin: str + + @dataclasses.dataclass(frozen=True) + class MyMixin3: + mixin: str = None + + @dataclasses.dataclass + class MyMixin4: mixin: str = None @dataclasses.dataclass(frozen=True, kw_only=True) - class ComplexEntityDescription1(MyMixin, entity.EntityDescription): + class ComplexEntityDescription1A(MyMixin1, entity.EntityDescription): extra: str = None - obj = ComplexEntityDescription1(key="blah", extra="foo", mixin="mixin", name="name") + obj = ComplexEntityDescription1A( + key="blah", extra="foo", mixin="mixin", name="name" + ) assert obj == snapshot - assert obj == ComplexEntityDescription1( + assert obj == ComplexEntityDescription1A( key="blah", extra="foo", mixin="mixin", name="name" ) assert repr(obj) == snapshot @dataclasses.dataclass(frozen=True, kw_only=True) - class ComplexEntityDescription2(entity.EntityDescription, MyMixin): + class ComplexEntityDescription1B(entity.EntityDescription, MyMixin1): extra: str = None - obj = ComplexEntityDescription2(key="blah", extra="foo", mixin="mixin", name="name") + obj = ComplexEntityDescription1B( + key="blah", extra="foo", mixin="mixin", name="name" + ) assert obj == snapshot - assert obj == ComplexEntityDescription2( + assert obj == ComplexEntityDescription1B( key="blah", extra="foo", mixin="mixin", name="name" ) assert repr(obj) == snapshot + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription1C(MyMixin1, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription1C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription1D(entity.EntityDescription, MyMixin1): + extra: str = None + + obj = ComplexEntityDescription1D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription1D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription2A(MyMixin2, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription2A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription2B(entity.EntityDescription, MyMixin2): + extra: str = None + + obj = ComplexEntityDescription2B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass + class ComplexEntityDescription2C(MyMixin2, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription2C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2C( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass + class ComplexEntityDescription2D(entity.EntityDescription, MyMixin2): + extra: str = None + + obj = ComplexEntityDescription2D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription2D( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription3A(MyMixin3, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription3A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription3A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription3B(entity.EntityDescription, MyMixin3): + extra: str = None + + obj = ComplexEntityDescription3B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription3B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + with pytest.raises(TypeError): + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription3C(MyMixin3, entity.EntityDescription): + extra: str = None + + with pytest.raises(TypeError): + + @dataclasses.dataclass(frozen=True) + class ComplexEntityDescription3D(entity.EntityDescription, MyMixin3): + extra: str = None + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription4A(MyMixin4, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription4A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription4A( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(kw_only=True) + class ComplexEntityDescription4B(entity.EntityDescription, MyMixin4): + extra: str = None + + obj = ComplexEntityDescription4B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert obj == snapshot + assert obj == ComplexEntityDescription4B( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + with pytest.raises(TypeError): + + @dataclasses.dataclass + class ComplexEntityDescription4C(MyMixin4, entity.EntityDescription): + extra: str = None + + with pytest.raises(TypeError): + + @dataclasses.dataclass + class ComplexEntityDescription4D(entity.EntityDescription, MyMixin4): + extra: str = None + # Try inheriting with custom init @dataclasses.dataclass class CustomInitEntityDescription(entity.EntityDescription): From 02b863e968f310922e29f6f586788c8bce67f3e2 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 29 Dec 2023 14:55:41 +0100 Subject: [PATCH 0068/1544] Add tedee integration (#102846) * init tedee * init tests * add config flow tests * liniting * test * undo * linting * pylint * add tests * more tests * more tests * update snapshot * more tests * typing * strict typing * cleanups * cleanups, fix tests * remove extra platforms * remove codeowner * improvements * catch tedeeclientexception * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * allow bridge selection in CF * abort earlier * auto-select bridge * remove cloud token, optionsflow to remove size * remove options flow leftovers * improve coverage * defer coordinator setting to after first update * define coordinator * some improvements * remove diagnostics, webhook * remove reauth flow, freeze data classes * fix lock test * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/entity.py Co-authored-by: Joost Lekkerkerker * requested changes * requested changes * Update lock.py Co-authored-by: Joost Lekkerkerker * Update entity.py Co-authored-by: Joost Lekkerkerker * Update lock.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * Update config_flow.py Co-authored-by: Joost Lekkerkerker * requested changes * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/conftest.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tedee/lock.py Co-authored-by: Joost Lekkerkerker * requested changes * requested changes * requested changes * revert load fixture * change tests * Update test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update strings.json Co-authored-by: Joost Lekkerkerker * Update coordinator.py Co-authored-by: Joost Lekkerkerker * remove warning * move stuff out of try * add docstring * tedee lowercase, time.time * back to some uppercase, time.time * awaitable --------- Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/tedee/__init__.py | 40 ++++ homeassistant/components/tedee/config_flow.py | 53 +++++ homeassistant/components/tedee/const.py | 9 + homeassistant/components/tedee/coordinator.py | 85 +++++++ homeassistant/components/tedee/entity.py | 40 ++++ homeassistant/components/tedee/lock.py | 119 ++++++++++ homeassistant/components/tedee/manifest.json | 10 + homeassistant/components/tedee/strings.json | 25 +++ 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/tedee/__init__.py | 1 + tests/components/tedee/conftest.py | 81 +++++++ tests/components/tedee/fixtures/locks.json | 26 +++ .../components/tedee/snapshots/test_lock.ambr | 145 ++++++++++++ tests/components/tedee/test_config_flow.py | 102 +++++++++ tests/components/tedee/test_init.py | 48 ++++ tests/components/tedee/test_lock.py | 209 ++++++++++++++++++ 22 files changed, 1019 insertions(+) create mode 100644 homeassistant/components/tedee/__init__.py create mode 100644 homeassistant/components/tedee/config_flow.py create mode 100644 homeassistant/components/tedee/const.py create mode 100644 homeassistant/components/tedee/coordinator.py create mode 100644 homeassistant/components/tedee/entity.py create mode 100644 homeassistant/components/tedee/lock.py create mode 100644 homeassistant/components/tedee/manifest.json create mode 100644 homeassistant/components/tedee/strings.json create mode 100644 tests/components/tedee/__init__.py create mode 100644 tests/components/tedee/conftest.py create mode 100644 tests/components/tedee/fixtures/locks.json create mode 100644 tests/components/tedee/snapshots/test_lock.ambr create mode 100644 tests/components/tedee/test_config_flow.py create mode 100644 tests/components/tedee/test_init.py create mode 100644 tests/components/tedee/test_lock.py diff --git a/.strict-typing b/.strict-typing index aa9c801fbf6..b85a9df857a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -344,6 +344,7 @@ homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.tedee.* homeassistant.components.text.* homeassistant.components.threshold.* homeassistant.components.tibber.* diff --git a/CODEOWNERS b/CODEOWNERS index 12477a683a3..724f08bfd5e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1316,6 +1316,8 @@ build.json @home-assistant/supervisor /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/tedee/ @patrickhilker @zweckj +/tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py new file mode 100644 index 00000000000..2ba6131d00c --- /dev/null +++ b/homeassistant/components/tedee/__init__.py @@ -0,0 +1,40 @@ +"""Init the tedee component.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + +PLATFORMS = [ + Platform.LOCK, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Integration setup.""" + + coordinator = TedeeApiCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +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].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py new file mode 100644 index 00000000000..e31bcd91693 --- /dev/null +++ b/homeassistant/components/tedee/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow for Tedee integration.""" +from typing import Any + +from pytedee_async import ( + TedeeAuthException, + TedeeClient, + TedeeClientException, + TedeeLocalAuthException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME + + +class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tedee.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN] + tedee_client = TedeeClient(local_token=local_access_token, local_ip=host) + try: + local_bridge = await tedee_client.get_local_bridge() + except (TedeeAuthException, TedeeLocalAuthException): + errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key" + except TedeeClientException: + errors[CONF_HOST] = "invalid_host" + + else: + await self.async_set_unique_id(local_bridge.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=NAME, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_LOCAL_ACCESS_TOKEN): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/tedee/const.py b/homeassistant/components/tedee/const.py new file mode 100644 index 00000000000..bac5bfaec44 --- /dev/null +++ b/homeassistant/components/tedee/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tedee integration.""" +from datetime import timedelta + +DOMAIN = "tedee" +NAME = "Tedee" + +SCAN_INTERVAL = timedelta(seconds=10) + +CONF_LOCAL_ACCESS_TOKEN = "local_access_token" diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py new file mode 100644 index 00000000000..13e26541557 --- /dev/null +++ b/homeassistant/components/tedee/coordinator.py @@ -0,0 +1,85 @@ +"""Coordinator for Tedee locks.""" +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging +import time + +from pytedee_async import ( + TedeeClient, + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, + TedeeLock, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=20) +GET_LOCKS_INTERVAL_SECONDS = 3600 + +_LOGGER = logging.getLogger(__name__) + + +class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): + """Class to handle fetching data from the tedee API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.tedee_client = TedeeClient( + local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN], + local_ip=self.config_entry.data[CONF_HOST], + ) + + self._next_get_locks = time.time() + + async def _async_update_data(self) -> dict[int, TedeeLock]: + """Fetch data from API endpoint.""" + + _LOGGER.debug("Update coordinator: Getting locks from API") + # once every hours get all lock details, otherwise use the sync endpoint + if self._next_get_locks <= time.time(): + _LOGGER.debug("Updating through /my/lock endpoint") + await self._async_update_locks(self.tedee_client.get_locks) + self._next_get_locks = time.time() + GET_LOCKS_INTERVAL_SECONDS + else: + _LOGGER.debug("Updating through /sync endpoint") + await self._async_update_locks(self.tedee_client.sync) + + _LOGGER.debug( + "available_locks: %s", + ", ".join(map(str, self.tedee_client.locks_dict.keys())), + ) + + return self.tedee_client.locks_dict + + async def _async_update_locks( + self, update_fn: Callable[[], Awaitable[None]] + ) -> None: + """Update locks based on update function.""" + try: + await update_fn() + except TedeeLocalAuthException as ex: + raise ConfigEntryError( + "Authentication failed. Local access token is invalid" + ) from ex + + except TedeeDataUpdateException as ex: + _LOGGER.debug("Error while updating data: %s", str(ex)) + raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex + except (TedeeClientException, TimeoutError) as ex: + raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py new file mode 100644 index 00000000000..3e0ac5468e9 --- /dev/null +++ b/homeassistant/components/tedee/entity.py @@ -0,0 +1,40 @@ +"""Bases for Tedee entities.""" + +from pytedee_async.lock import TedeeLock + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + + +class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): + """Base class for Tedee entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + key: str, + ) -> None: + """Initialize Tedee entity.""" + super().__init__(coordinator) + self._lock = lock + self._attr_unique_id = f"{lock.lock_id}-{key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(lock.lock_id))}, + name=lock.lock_name, + manufacturer="tedee", + model=lock.lock_type, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._lock = self.coordinator.data[self._lock.lock_id] + super()._handle_coordinator_update() diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py new file mode 100644 index 00000000000..751dfb446b7 --- /dev/null +++ b/homeassistant/components/tedee/lock.py @@ -0,0 +1,119 @@ +"""Tedee lock entities.""" +from typing import Any + +from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator +from .entity import TedeeEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee lock entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[TedeeLockEntity] = [] + for lock in coordinator.data.values(): + if lock.is_enabled_pullspring: + entities.append(TedeeLockWithLatchEntity(lock, coordinator)) + else: + entities.append(TedeeLockEntity(lock, coordinator)) + + async_add_entities(entities) + + +class TedeeLockEntity(TedeeEntity, LockEntity): + """A tedee lock that doesn't have pullspring enabled.""" + + _attr_name = None + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + ) -> None: + """Initialize the lock.""" + super().__init__(lock, coordinator, "lock") + + @property + def is_locked(self) -> bool: + """Return true if lock is locked.""" + return self._lock.state == TedeeLockState.LOCKED + + @property + def is_unlocking(self) -> bool: + """Return true if lock is unlocking.""" + return self._lock.state == TedeeLockState.UNLOCKING + + @property + def is_locking(self) -> bool: + """Return true if lock is locking.""" + return self._lock.state == TedeeLockState.LOCKING + + @property + def is_jammed(self) -> bool: + """Return true if lock is jammed.""" + return self._lock.is_state_jammed + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the door.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.unlock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlock the door. Lock %s" % self._lock.lock_id + ) from ex + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the door.""" + try: + self._lock.state = TedeeLockState.LOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.lock(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to lock the door. Lock %s" % self._lock.lock_id + ) from ex + + +class TedeeLockWithLatchEntity(TedeeLockEntity): + """A tedee lock but has pullspring enabled, so it additional features.""" + + @property + def supported_features(self) -> LockEntityFeature: + """Flag supported features.""" + return LockEntityFeature.OPEN + + async def async_open(self, **kwargs: Any) -> None: + """Open the door with pullspring.""" + try: + self._lock.state = TedeeLockState.UNLOCKING + self.async_write_ha_state() + + await self.coordinator.tedee_client.open(self._lock.lock_id) + await self.coordinator.async_request_refresh() + except (TedeeClientException, Exception) as ex: + raise HomeAssistantError( + "Failed to unlatch the door. Lock %s" % self._lock.lock_id + ) from ex diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json new file mode 100644 index 00000000000..4055130e5e7 --- /dev/null +++ b/homeassistant/components/tedee/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tedee", + "name": "Tedee", + "codeowners": ["@patrickhilker", "@zweckj"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/tedee", + "iot_class": "local_push", + "requirements": ["pytedee-async==0.2.1"] +} diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json new file mode 100644 index 00000000000..e9286d894aa --- /dev/null +++ b/homeassistant/components/tedee/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your tedee locks", + "data": { + "local_access_token": "Local access token", + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The IP address of the bridge you want to connect to.", + "local_access_token": "You can find it in the tedee app under \"Bridge Settings\" -> \"Local API\"." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cba1a88d25b..254a3ad0df3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -500,6 +500,7 @@ FLOWS = { "tankerkoenig", "tasmota", "tautulli", + "tedee", "tellduslive", "tesla_wall_connector", "tessie", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 45bcc1788cd..c55b6aecce9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5804,6 +5804,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "tedee": { + "name": "Tedee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "telegram": { "name": "Telegram", "integrations": { diff --git a/mypy.ini b/mypy.ini index e19c6c6fa92..1f810bdb1ae 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3202,6 +3202,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tedee.*] +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.text.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 3eb0bd1e913..f9b312e391f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2134,6 +2134,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.1 + # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2d7072dc3..289732c846f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1631,6 +1631,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.tedee +pytedee-async==0.2.1 + # homeassistant.components.motionmount python-MotionMount==0.3.1 diff --git a/tests/components/tedee/__init__.py b/tests/components/tedee/__init__.py new file mode 100644 index 00000000000..a72b1fbdd6a --- /dev/null +++ b/tests/components/tedee/__init__.py @@ -0,0 +1 @@ +"""Add tests for Tedee components.""" diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py new file mode 100644 index 00000000000..21fb4047ab3 --- /dev/null +++ b/tests/components/tedee/conftest.py @@ -0,0 +1,81 @@ +"""Fixtures for Tedee integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pytedee_async.bridge import TedeeBridge +from pytedee_async.lock import TedeeLock +import pytest + +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Tedee", + domain=DOMAIN, + data={ + CONF_LOCAL_ACCESS_TOKEN: "api_token", + CONF_HOST: "192.168.1.42", + }, + unique_id="0000-0000", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tedee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_tedee(request) -> Generator[MagicMock, None, None]: + """Return a mocked Tedee client.""" + with patch( + "homeassistant.components.tedee.coordinator.TedeeClient", autospec=True + ) as tedee_mock, patch( + "homeassistant.components.tedee.config_flow.TedeeClient", + new=tedee_mock, + ): + tedee = tedee_mock.return_value + + tedee.get_locks.return_value = None + tedee.sync.return_value = None + tedee.get_bridges.return_value = [ + TedeeBridge(1234, "0000-0000", "Bridge-AB1C"), + TedeeBridge(5678, "9999-9999", "Bridge-CD2E"), + ] + tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") + + tedee.parse_webhook_message.return_value = None + + locks_json = json.loads(load_fixture("locks.json", DOMAIN)) + + lock_list = [TedeeLock(**lock) for lock in locks_json] + tedee.locks_dict = {lock.lock_id: lock for lock in lock_list} + + yield tedee + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> MockConfigEntry: + """Set up the Tedee integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/tedee/fixtures/locks.json b/tests/components/tedee/fixtures/locks.json new file mode 100644 index 00000000000..6a8eb77d7ee --- /dev/null +++ b/tests/components/tedee/fixtures/locks.json @@ -0,0 +1,26 @@ +[ + { + "lock_name": "Lock-1A2B", + "lock_id": 12345, + "lock_type": 2, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 1, + "duration_pullspring": 2 + }, + { + "lock_name": "Lock-2C3D", + "lock_id": 98765, + "lock_type": 4, + "state": 2, + "battery_level": 70, + "is_connected": true, + "is_charging": false, + "state_change_result": 0, + "is_enabled_pullspring": 0, + "duration_pullspring": 0 + } +] diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..ad89e9c842d --- /dev/null +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_lock + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_1a2b', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_1a2b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'tedee', + 'model': 'Tedee PRO', + 'name': 'Lock-1A2B', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_lock_without_pullspring + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-2C3D', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.lock_2c3d', + 'last_changed': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_without_pullspring.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.lock_2c3d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_without_pullspring.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '98765', + ), + }), + 'is_new': False, + 'manufacturer': 'tedee', + 'model': 'Tedee GO', + 'name': 'Lock-2C3D', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py new file mode 100644 index 00000000000..73132d3bd78 --- /dev/null +++ b/tests/components/tedee/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the Tedee config flow.""" +from unittest.mock import MagicMock + +from pytedee_async import TedeeClientException, TedeeLocalAuthException +import pytest + +from homeassistant import config_entries +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +FLOW_UNIQUE_ID = "112233445566778899" +LOCAL_ACCESS_TOKEN = "api_token" + + +async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: + """Test config flow with one bridge.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + } + + +async def test_flow_already_configured( + hass: HomeAssistant, + mock_tedee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), + ( + TedeeLocalAuthException("boom."), + {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, + ), + ], +) +async def test_config_flow_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, + side_effect: Exception, + error: dict[str, str], +) -> None: + """Test the config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + + mock_tedee.get_local_bridge.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.42", + CONF_LOCAL_ACCESS_TOKEN: "wrong_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + assert len(mock_tedee.get_local_bridge.mock_calls) == 1 diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py new file mode 100644 index 00000000000..874a827e458 --- /dev/null +++ b/tests/components/tedee/test_init.py @@ -0,0 +1,48 @@ +"""Test initialization of tedee.""" +from unittest.mock import MagicMock + +from pytedee_async.exception import TedeeAuthException, TedeeClientException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", [TedeeClientException(""), TedeeAuthException("")] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + side_effect: Exception, +) -> None: + """Test the LaMetric configuration entry not ready.""" + mock_tedee.get_locks.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_tedee.get_locks.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py new file mode 100644 index 00000000000..995d036fba7 --- /dev/null +++ b/tests/components/tedee/test_lock.py @@ -0,0 +1,209 @@ +"""Tests for tedee lock.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pytedee_async.exception import ( + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKING, + STATE_UNLOCKING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_lock( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device == snapshot + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.lock.mock_calls) == 1 + mock_tedee.lock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.unlock.mock_calls) == 1 + mock_tedee.unlock.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 1 + mock_tedee.open.assert_called_once_with(12345) + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_without_pullspring( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the tedee lock without pullspring.""" + mock_tedee.lock.return_value = None + mock_tedee.unlock.return_value = None + mock_tedee.open.return_value = None + + state = hass.states.get("lock.lock_2c3d") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + assert entry.device_id + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot + + with pytest.raises( + HomeAssistantError, + match="Entity lock.lock_2c3d does not support this service.", + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_2c3d", + }, + blocking=True, + ) + + assert len(mock_tedee.open.mock_calls) == 0 + + +async def test_lock_errors( + hass: HomeAssistant, + mock_tedee: MagicMock, +) -> None: + """Test event errors.""" + mock_tedee.lock.side_effect = TedeeClientException("Boom") + with pytest.raises(HomeAssistantError, match="Failed to lock the door. Lock 12345"): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.unlock.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlock the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + mock_tedee.open.side_effect = TedeeClientException("Boom") + with pytest.raises( + HomeAssistantError, match="Failed to unlatch the door. Lock 12345" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + { + ATTR_ENTITY_ID: "lock.lock_1a2b", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "side_effect", + [ + TedeeClientException("Boom"), + TedeeLocalAuthException("Boom"), + TimeoutError, + TedeeDataUpdateException("Boom"), + ], +) +async def test_update_failed( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """Test update failed.""" + mock_tedee.sync.side_effect = side_effect + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("lock.lock_1a2b") + assert state is not None + assert state.state == STATE_UNAVAILABLE From 2add7707b42c32429eb3d41e94e276e86be298a7 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Fri, 29 Dec 2023 15:52:38 +0100 Subject: [PATCH 0069/1544] Add roomba total cleaned area sensor (#106640) * Add roomba total cleaned area sensor * Use parentheses for multi-line lambda * Update homeassistant/components/roomba/sensor.py Co-authored-by: Jan-Philipp Benecke * Update homeassistant/components/roomba/sensor.py Co-authored-by: Jan-Philipp Benecke * Revert "Update homeassistant/components/roomba/sensor.py" This reverts commit 819be6179f140c155687d8916bbe59f3dba9913f. --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/roomba/sensor.py | 21 +++++++++++++++++++- homeassistant/components/roomba/strings.json | 3 +++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 09d4d643be9..c02de0229c0 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,7 +11,12 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.const import ( + AREA_SQUARE_METERS, + PERCENTAGE, + EntityCategory, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -114,6 +119,20 @@ SENSORS: list[RoombaSensorEntityDescription] = [ value_fn=lambda self: self.run_stats.get("nScrubs"), entity_registry_enabled_default=False, ), + RoombaSensorEntityDescription( + key="total_cleaned_area", + translation_key="total_cleaned_area", + icon="mdi:texture-box", + native_unit_of_measurement=AREA_SQUARE_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: ( + self.run_stats.get("sqft") * 9.29 + if self.run_stats.get("sqft") is not None + else None + ), + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), ] diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 654c1b7fdfc..088918824d2 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -84,6 +84,9 @@ }, "scrubs_count": { "name": "Scrubs" + }, + "total_cleaned_area": { + "name": "Total cleaned area" } } } From e83aa864266a08081180edabf24d93c6e8c665d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hansl=C3=ADk?= Date: Fri, 29 Dec 2023 18:37:46 +0100 Subject: [PATCH 0070/1544] Fixed native apparent temperature in WeatherEntity (#106645) --- homeassistant/components/weather/__init__.py | 2 +- tests/components/smhi/snapshots/test_weather.ambr | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index fa832ca8c32..bdc8ae4d514 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -430,7 +430,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" - return self._attr_native_temperature + return self._attr_native_apparent_temperature @cached_property def native_temperature(self) -> float | None: diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index fa9d76c68ba..eb7378b5cba 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -669,7 +669,6 @@ # --- # name: test_setup_hass ReadOnlyDict({ - 'apparent_temperature': 18.0, 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'forecast': list([ From 6e98f72f8b4b9156e731ed13515f0a7fc1571133 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 10:19:06 -1000 Subject: [PATCH 0071/1544] Bump SQLAlchemy to 2.0.24 (#106672) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/recorder/statistics.py | 4 ++-- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index b630a71daff..6f3371681e5 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.23", + "SQLAlchemy==2.0.24", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ad6cdd31e2c..8f932ecf499 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -101,7 +101,7 @@ QUERY_STATISTICS_SHORT_TERM = ( StatisticsShortTerm.sum, ) -QUERY_STATISTICS_SUMMARY_MEAN = ( +QUERY_STATISTICS_SUMMARY_MEAN = ( # type: ignore[var-annotated] StatisticsShortTerm.metadata_id, func.avg(StatisticsShortTerm.mean), func.min(StatisticsShortTerm.min), @@ -115,7 +115,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( StatisticsShortTerm.state, StatisticsShortTerm.sum, func.row_number() - .over( # type: ignore[no-untyped-call] + .over( partition_by=StatisticsShortTerm.metadata_id, order_by=StatisticsShortTerm.start_ts.desc(), ) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 5ebd79b09a5..0409f2cdf6f 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.23", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.24", "sqlparse==0.4.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a6c59c98dc0..ec821523c17 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.24 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index f9b312e391f..44e75b0cf74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -128,7 +128,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.24 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 289732c846f..5d6bc299ceb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -113,7 +113,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.23 +SQLAlchemy==2.0.24 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 From 8abfde2d1538d81f1ef08303ae649fbe60012d87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 13:45:27 -1000 Subject: [PATCH 0072/1544] Bump roombapy to 1.6.10 (#106678) changelog: https://github.com/pschmitt/roombapy/compare/1.6.8...1.6.10 fixes #105323 --- homeassistant/components/roomba/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 8e6b92732eb..fbe6c925438 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"], + "requirements": ["roombapy==1.6.10"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 44e75b0cf74..c498f92c89d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2403,7 +2403,7 @@ rocketchat-API==0.6.1 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon roonapi==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d6bc299ceb..355d93ac35f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1810,7 +1810,7 @@ ring-doorbell[listen]==0.8.5 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon roonapi==0.1.6 From f3ecec9c44ccd537664e41b56d0938136dcb537e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 14:12:19 -1000 Subject: [PATCH 0073/1544] Fix missed cached_property for hvac_mode in climate (#106692) --- homeassistant/components/climate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 19e26265f70..78cb92944cb 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -227,6 +227,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "temperature_unit", "current_humidity", "target_humidity", + "hvac_mode", "hvac_modes", "hvac_action", "current_temperature", @@ -414,7 +415,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode From 2177113c6e5edcf3b14df1ec583595069540a715 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 29 Dec 2023 19:45:04 -0500 Subject: [PATCH 0074/1544] Bump asyncsleepiq to v1.4.1 (#106682) Update asyncsleepiq to v1.4.1 --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index d58c20b14b8..62bd3930c77 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.4.0"] + "requirements": ["asyncsleepiq==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c498f92c89d..fe9c53c7e5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -478,7 +478,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.4.0 +asyncsleepiq==1.4.1 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 355d93ac35f..8ec668e0049 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -424,7 +424,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.38.0 # homeassistant.components.sleepiq -asyncsleepiq==1.4.0 +asyncsleepiq==1.4.1 # homeassistant.components.aurora auroranoaa==0.0.3 From 9e3869ae1c08f5293a2b130999843118395411d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 17:10:40 -1000 Subject: [PATCH 0075/1544] Avoid recreating ReadOnly dicts when attributes do not change (#106687) --- homeassistant/core.py | 13 ++++++++++++- tests/test_core.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 51cb3d4e496..cb17bd55805 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1409,7 +1409,13 @@ class State: self.entity_id = entity_id self.state = state - self.attributes = ReadOnlyDict(attributes or {}) + # State only creates and expects a ReadOnlyDict so + # there is no need to check for subclassing with + # isinstance here so we can use the faster type check. + if type(attributes) is not ReadOnlyDict: # noqa: E721 + self.attributes = ReadOnlyDict(attributes or {}) + else: + self.attributes = attributes self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated self.context = context or Context() @@ -1828,6 +1834,11 @@ class StateMachine: else: now = dt_util.utcnow() + if same_attr: + if TYPE_CHECKING: + assert old_state is not None + attributes = old_state.attributes + state = State( entity_id, new_state, diff --git a/tests/test_core.py b/tests/test_core.py index 90b87068a5d..da76961c5be 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1157,6 +1157,26 @@ async def test_statemachine_force_update(hass: HomeAssistant) -> None: assert len(events) == 1 +async def test_statemachine_avoids_updating_attributes(hass: HomeAssistant) -> None: + """Test async_set avoids recreating ReadOnly dicts when possible.""" + attrs = {"some_attr": "attr_value"} + + hass.states.async_set("light.bowl", "off", attrs) + await hass.async_block_till_done() + + state = hass.states.get("light.bowl") + assert state.attributes == attrs + + hass.states.async_set("light.bowl", "on", attrs) + await hass.async_block_till_done() + + new_state = hass.states.get("light.bowl") + assert new_state.attributes == attrs + + assert new_state.attributes is state.attributes + assert isinstance(new_state.attributes, ReadOnlyDict) + + def test_service_call_repr() -> None: """Test ServiceCall repr.""" call = ha.ServiceCall("homeassistant", "start") From 461dad30399c7cd6598e66fba4969980b79316be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 30 Dec 2023 08:34:21 +0100 Subject: [PATCH 0076/1544] Fix changed_variables in automation traces (#106665) --- homeassistant/helpers/script.py | 19 +++++++++---- homeassistant/helpers/trace.py | 26 ++++++++++------- tests/helpers/test_script.py | 49 ++++++++++++++------------------- 3 files changed, 50 insertions(+), 44 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a1d045eb542..07f10e13dbf 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy @@ -157,7 +157,12 @@ def action_trace_append(variables, path): @asynccontextmanager -async def trace_action(hass, script_run, stop, variables): +async def trace_action( + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Event, + variables: dict[str, Any], +) -> AsyncGenerator[TraceElement, None]: """Trace action execution.""" path = trace_path_get() trace_element = action_trace_append(variables, path) @@ -362,6 +367,8 @@ class _StopScript(_HaltScript): class _ScriptRun: """Manage Script sequence run.""" + _action: dict[str, Any] + def __init__( self, hass: HomeAssistant, @@ -376,7 +383,6 @@ class _ScriptRun: self._context = context self._log_exceptions = log_exceptions self._step = -1 - self._action: dict[str, Any] | None = None self._stop = asyncio.Event() self._stopped = asyncio.Event() @@ -446,11 +452,13 @@ class _ScriptRun: return ScriptRunResult(response, self._variables) - async def _async_step(self, log_exceptions): + async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): - async with trace_action(self._hass, self, self._stop, self._variables): + async with trace_action( + self._hass, self, self._stop, self._variables + ) as trace_element: if self._stop.is_set(): return @@ -466,6 +474,7 @@ class _ScriptRun: try: handler = f"_async_{action}_step" await getattr(self, handler)() + trace_element.update_variables(self._variables) except Exception as ex: # pylint: disable=broad-except self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 41be606488a..53e66e1c651 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -24,6 +24,7 @@ class TraceElement: "_child_key", "_child_run_id", "_error", + "_last_variables", "path", "_result", "reuse_by_child", @@ -41,16 +42,8 @@ class TraceElement: self.reuse_by_child = False self._timestamp = dt_util.utcnow() - if variables is None: - variables = {} - last_variables = variables_cv.get() or {} - variables_cv.set(dict(variables)) - changed_variables = { - key: value - for key, value in variables.items() - if key not in last_variables or last_variables[key] != value - } - self._variables = changed_variables + self._last_variables = variables_cv.get() or {} + self.update_variables(variables) def __repr__(self) -> str: """Container for trace data.""" @@ -74,6 +67,19 @@ class TraceElement: old_result = self._result or {} self._result = {**old_result, **kwargs} + def update_variables(self, variables: TemplateVarsType) -> None: + """Update variables.""" + if variables is None: + variables = {} + last_variables = self._last_variables + variables_cv.set(dict(variables)) + changed_variables = { + key: value + for key, value in variables.items() + if key not in last_variables or last_variables[key] != value + } + self._variables = changed_variables + def as_dict(self) -> dict[str, Any]: """Return dictionary version of this TraceElement.""" result: dict[str, Any] = {"path": self.path, "timestamp": self._timestamp} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c2bad6287ab..1ea602f7cda 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -386,7 +386,10 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - } + }, + "variables": { + "my_response": {"data": "value-12345"}, + }, } ], "1": [ @@ -399,10 +402,7 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - }, - "variables": { - "my_response": {"data": "value-12345"}, - }, + } } ], } @@ -1163,13 +1163,13 @@ async def test_wait_template_not_schedule(hass: HomeAssistant) -> None: assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {"wait": {"completed": True, "remaining": None}}}], - "2": [ + "1": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": {"wait": {"completed": True, "remaining": None}}, "variables": {"wait": {"completed": True, "remaining": None}}, } ], + "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) @@ -1230,13 +1230,13 @@ async def test_wait_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], - "1": [ + "0": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": variable_wait, "variables": variable_wait, } ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], } assert_action_trace(expected_trace) @@ -1291,19 +1291,14 @@ async def test_wait_continue_on_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], + "0": [{"result": variable_wait, "variables": variable_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error_type"] = asyncio.TimeoutError expected_script_execution = "aborted" else: - expected_trace["1"] = [ - { - "result": {"event": "test_event", "event_data": {}}, - "variables": variable_wait, - } - ] + expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) @@ -3269,12 +3264,12 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - "description": "state of switch.trigger", }, } - } + }, + "variables": {"wait": {"remaining": None}}, } ], "0/parallel/1/sequence/0": [ { - "variables": {}, "result": { "event": "test_event", "event_data": {"hello": "from action 2", "what": "world"}, @@ -3283,7 +3278,6 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - ], "0/parallel/0/sequence/1": [ { - "variables": {"wait": {"remaining": None}}, "result": { "event": "test_event", "event_data": {"hello": "from action 1", "what": "world"}, @@ -4462,7 +4456,7 @@ async def test_set_variable( assert f"Executing step {alias}" in caplog.text expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "value"}}], "1": [ { "result": { @@ -4474,7 +4468,6 @@ async def test_set_variable( }, "running_script": False, }, - "variables": {"variable": "value"}, } ], } @@ -4504,7 +4497,7 @@ async def test_set_redefines_variable( assert mock_calls[1].data["value"] == 2 expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "1"}}], "1": [ { "result": { @@ -4515,11 +4508,10 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": "1"}, + } } ], - "2": [{}], + "2": [{"variables": {"variable": 2}}], "3": [ { "result": { @@ -4530,8 +4522,7 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": 2}, + } } ], } From 197525c697e8f99e226717360fada8f052c3bd72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 29 Dec 2023 23:33:59 -1000 Subject: [PATCH 0077/1544] Bump thermobeacon-ble to 0.6.2 (#106676) changelog: https://github.com/Bluetooth-Devices/thermobeacon-ble/compare/v0.6.0...v0.6.2 --- homeassistant/components/thermobeacon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 772c565e9d2..29443acaa3d 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -42,5 +42,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.6.0"] + "requirements": ["thermobeacon-ble==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe9c53c7e5a..36159ca467a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2638,7 +2638,7 @@ tessie-api==0.0.9 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ec668e0049..86a73f56969 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1985,7 +1985,7 @@ tesla-wall-connector==1.0.2 tessie-api==0.0.9 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 From f22d6a427991465e312605c75f1799d233237cec Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 30 Dec 2023 10:34:57 +0100 Subject: [PATCH 0078/1544] Use volume up/down from enigma2 API (#106674) enigma2: use volume up/down from enigma2 API --- homeassistant/components/enigma2/media_player.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 432823d781b..598ab1afffe 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations from openwebif.api import OpenWebIfDevice -from openwebif.enums import RemoteControlCodes +from openwebif.enums import RemoteControlCodes, SetVolumeOption import voluptuous as vol from homeassistant.components.media_player import ( @@ -142,15 +142,11 @@ class Enigma2Device(MediaPlayerEntity): async def async_volume_up(self) -> None: """Volume up the media player.""" - if self._attr_volume_level is None: - return - await self._device.set_volume(int(self._attr_volume_level * 100) + 5) + await self._device.set_volume(SetVolumeOption.UP) async def async_volume_down(self) -> None: """Volume down media player.""" - if self._attr_volume_level is None: - return - await self._device.set_volume(int(self._attr_volume_level * 100) - 5) + await self._device.set_volume(SetVolumeOption.DOWN) async def async_media_stop(self) -> None: """Send stop command.""" From bcf75795c28a7c608abf0c2238f027158ccfb9fa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 30 Dec 2023 10:51:34 +0100 Subject: [PATCH 0079/1544] DNS IP implement retry (#105675) * DNS IP implement retry * Review comments --- homeassistant/components/dnsip/sensor.py | 6 ++++++ tests/components/dnsip/test_sensor.py | 23 +++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index ebe5216ab69..a4b0d34b339 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -23,6 +23,8 @@ from .const import ( DOMAIN, ) +DEFAULT_RETRIES = 2 + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=120) @@ -67,6 +69,7 @@ class WanIpSensor(SensorEntity): self.resolver = aiodns.DNSResolver() self.resolver.nameservers = [resolver] self.querytype = "AAAA" if ipv6 else "A" + self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { "Resolver": resolver, "Querytype": self.querytype, @@ -90,5 +93,8 @@ class WanIpSensor(SensorEntity): if response: self._attr_native_value = response[0].host self._attr_available = True + self._retries = DEFAULT_RETRIES + elif self._retries > 0: + self._retries -= 1 else: self._attr_available = False diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 1282cddc5e6..6fd24ad9b13 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from unittest.mock import patch from aiodns.error import DNSError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, @@ -14,10 +15,10 @@ from homeassistant.components.dnsip.const import ( CONF_RESOLVER_IPV6, DOMAIN, ) +from homeassistant.components.dnsip.sensor import SCAN_INTERVAL from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import RetrieveDNS @@ -58,7 +59,9 @@ async def test_sensor(hass: HomeAssistant) -> None: assert state2.state == "1.2.3.4" -async def test_sensor_no_response(hass: HomeAssistant) -> None: +async def test_sensor_no_response( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the DNS IP sensor with DNS error.""" entry = MockConfigEntry( domain=DOMAIN, @@ -95,10 +98,18 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", return_value=dns_mock, ): - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(minutes=10), - ) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Allows 2 retries before going unavailable + state = hass.states.get("sensor.home_assistant_io") + assert state.state == "1.2.3.4" + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.home_assistant_io") From a49999e9849d352d39eecb11d5fcaa166742beeb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Dec 2023 00:29:19 -1000 Subject: [PATCH 0080/1544] Pin lxml to 4.9.4 (#106694) --- homeassistant/components/scrape/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 4 ++++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 26603603198..708ecc14d16 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec821523c17..ec064f84126 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -185,3 +185,7 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index 36159ca467a..ceff0806681 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1225,7 +1225,7 @@ lupupy==0.3.1 lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86a73f56969..96fdb709c4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -958,7 +958,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bcd19b97e08..101c9294706 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,10 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 """ GENERATED_MESSAGE = ( From cc14d80d3d3e471b2fd2b9904656497f7a13223e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 30 Dec 2023 13:14:49 +0100 Subject: [PATCH 0081/1544] Add ffmpeg to dev-container (#106710) --- Dockerfile.dev | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile.dev b/Dockerfile.dev index a1143adde89..453b922cd0b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -16,6 +16,7 @@ RUN \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery bluez \ + ffmpeg \ libudev-dev \ libavformat-dev \ libavcodec-dev \ From f17470cb29bc83cb0171bbdc67f500db95745f0c Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 30 Dec 2023 13:15:22 +0100 Subject: [PATCH 0082/1544] Upper case tedee device name (#106685) --- homeassistant/components/tedee/entity.py | 2 +- tests/components/tedee/snapshots/test_lock.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 3e0ac5468e9..fa80ffcb24c 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -29,7 +29,7 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(lock.lock_id))}, name=lock.lock_name, - manufacturer="tedee", + manufacturer="Tedee", model=lock.lock_type, ) diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index ad89e9c842d..b7c20f39750 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -61,7 +61,7 @@ ), }), 'is_new': False, - 'manufacturer': 'tedee', + 'manufacturer': 'Tedee', 'model': 'Tedee PRO', 'name': 'Lock-1A2B', 'name_by_user': None, @@ -133,7 +133,7 @@ ), }), 'is_new': False, - 'manufacturer': 'tedee', + 'manufacturer': 'Tedee', 'model': 'Tedee GO', 'name': 'Lock-2C3D', 'name_by_user': None, From ee1b0b46ce53b049a293889c4eb0fa15d6fbd11e Mon Sep 17 00:00:00 2001 From: Floris272 <60342568+Floris272@users.noreply.github.com> Date: Sat, 30 Dec 2023 13:53:35 +0100 Subject: [PATCH 0083/1544] Add reauth to Blue Current integration (#106658) * Add reauth to Blue Current integration. * Apply feedback * Fix failing codecov check * Fix patches * Add wrong_account to strings.json --- .../components/blue_current/__init__.py | 7 +- .../components/blue_current/config_flow.py | 28 ++++++- .../components/blue_current/manifest.json | 1 - .../components/blue_current/strings.json | 4 +- .../blue_current/test_config_flow.py | 72 ++++++++++++++++-- tests/components/blue_current/test_init.py | 74 ++++++++++++++----- 6 files changed, 152 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 0dfa67f097d..604f251bfeb 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -16,7 +16,7 @@ from bluecurrent_api.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -42,9 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await connector.connect(api_token) - except InvalidApiToken: - LOGGER.error("Invalid Api token") - return False + except InvalidApiToken as err: + raise ConfigEntryAuthFailed("Invalid API token.") from err except BlueCurrentException as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py index 32a6c177b49..68a30fcdf7f 100644 --- a/homeassistant/components/blue_current/config_flow.py +++ b/homeassistant/components/blue_current/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Blue Current integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bluecurrent_api import Client @@ -25,6 +26,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the config flow for Blue Current.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -51,11 +53,31 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: - await self.async_set_unique_id(customer_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(customer_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=email, data=user_input) - return self.async_create_entry(title=email, data=user_input) + if self._reauth_entry.unique_id == customer_id: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + return self.async_abort( + reason="wrong_account", + description_placeholders={"email": email}, + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index bff8a057f08..cadaac30d68 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -5,6 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", - "issue_tracker": "https://github.com/bluecurrent/ha-bluecurrent/issues", "requirements": ["bluecurrent-api==1.0.6"] } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 10c114e5f1c..293d0cd6ab7 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -18,7 +18,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: Please authenticate with the api key for {email}." } }, "entity": { diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py index c510aeada4f..057701235ad 100644 --- a/tests/components/blue_current/test_config_flow.py +++ b/tests/components/blue_current/test_config_flow.py @@ -12,6 +12,9 @@ from homeassistant.components.blue_current.config_flow import ( WebsocketError, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry async def test_form(hass: HomeAssistant) -> None: @@ -30,8 +33,12 @@ async def test_user(hass: HomeAssistant) -> None: ) assert result["errors"] == {} - with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( - "bluecurrent_api.Client.get_email", return_value="test@email.com" + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value="1234", + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", ), patch( "homeassistant.components.blue_current.async_setup_entry", return_value=True, @@ -59,9 +66,9 @@ async def test_user(hass: HomeAssistant) -> None: ], ) async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: - """Test user initialized flow with invalid username.""" + """Test bluecurrent api errors during configuration flow.""" with patch( - "bluecurrent_api.Client.validate_api_token", + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", side_effect=error, ): result = await hass.config_entries.flow.async_init( @@ -71,8 +78,12 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - ) assert result["errors"]["base"] == message - with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( - "bluecurrent_api.Client.get_email", return_value="test@email.com" + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value="1234", + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", ), patch( "homeassistant.components.blue_current.async_setup_entry", return_value=True, @@ -87,3 +98,52 @@ async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) - assert result2["title"] == "test@email.com" assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("customer_id", "reason", "expected_api_token"), + [ + ("1234", "reauth_successful", "1234567890"), + ("6666", "wrong_account", "123"), + ], +) +async def test_reauth( + hass: HomeAssistant, customer_id: str, reason: str, expected_api_token: str +) -> None: + """Test reauth flow.""" + with patch( + "homeassistant.components.blue_current.config_flow.Client.validate_api_token", + return_value=customer_id, + ), patch( + "homeassistant.components.blue_current.config_flow.Client.get_email", + return_value="test@email.com", + ): + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="1234", + data={"api_token": "123"}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data={"api_token": "123"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"api_token": "1234567890"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert entry.data == {"api_token": expected_api_token} + + await hass.async_block_till_done() diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index fe40f58077f..14bd055cd45 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -4,13 +4,22 @@ from datetime import timedelta from unittest.mock import patch from bluecurrent_api.client import Client -from bluecurrent_api.exceptions import RequestLimitReached, WebsocketError +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) import pytest from homeassistant.components.blue_current import DOMAIN, Connector, async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + IntegrationError, +) from . import init_integration @@ -29,12 +38,21 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: assert hass.data[DOMAIN] == {} -async def test_config_not_ready(hass: HomeAssistant) -> None: - """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketError.""" +@pytest.mark.parametrize( + ("api_error", "config_error"), + [ + (InvalidApiToken, ConfigEntryAuthFailed), + (BlueCurrentException, ConfigEntryNotReady), + ], +) +async def test_config_exceptions( + hass: HomeAssistant, api_error: BlueCurrentException, config_error: IntegrationError +) -> None: + """Tests if the correct config error is raised when connecting to the api fails.""" with patch( - "bluecurrent_api.Client.connect", - side_effect=WebsocketError, - ), pytest.raises(ConfigEntryNotReady): + "homeassistant.components.blue_current.Client.connect", + side_effect=api_error, + ), pytest.raises(config_error): config_entry = MockConfigEntry( domain=DOMAIN, entry_id="uuid", @@ -143,14 +161,15 @@ async def test_start_loop(hass: HomeAssistant) -> None: connector = Connector(hass, config_entry, Client) with patch( - "bluecurrent_api.Client.start_loop", + "homeassistant.components.blue_current.Client.start_loop", side_effect=WebsocketError("unknown command"), ): await connector.start_loop() test_async_call_later.assert_called_with(hass, 1, connector.reconnect) with patch( - "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + "homeassistant.components.blue_current.Client.start_loop", + side_effect=RequestLimitReached, ): await connector.start_loop() test_async_call_later.assert_called_with(hass, 1, connector.reconnect) @@ -159,11 +178,7 @@ async def test_start_loop(hass: HomeAssistant) -> None: async def test_reconnect(hass: HomeAssistant) -> None: """Tests reconnect.""" - with patch("bluecurrent_api.Client.connect"), patch( - "bluecurrent_api.Client.connect", side_effect=WebsocketError - ), patch( - "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) - ), patch( + with patch( "homeassistant.components.blue_current.async_call_later" ) as test_async_call_later: config_entry = MockConfigEntry( @@ -174,12 +189,33 @@ async def test_reconnect(hass: HomeAssistant) -> None: ) connector = Connector(hass, config_entry, Client) - await connector.reconnect() + + with patch( + "homeassistant.components.blue_current.Client.connect", + side_effect=WebsocketError, + ): + await connector.reconnect() test_async_call_later.assert_called_with(hass, 20, connector.reconnect) - with patch("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + with patch( + "homeassistant.components.blue_current.Client.connect", + side_effect=RequestLimitReached, + ), patch( + "homeassistant.components.blue_current.Client.get_next_reset_delta", + return_value=timedelta(hours=1), + ): await connector.reconnect() - test_async_call_later.assert_called_with( - hass, timedelta(hours=1), connector.reconnect - ) + + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) + + with patch("homeassistant.components.blue_current.Client.connect"), patch( + "homeassistant.components.blue_current.Connector.start_loop" + ) as test_start_loop, patch( + "homeassistant.components.blue_current.Client.get_charge_points" + ) as test_get_charge_points: + await connector.reconnect() + test_start_loop.assert_called_once() + test_get_charge_points.assert_called_once() From 9d36b716e7d75e5c0140d065f1e231b88a92cc8c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 30 Dec 2023 13:55:53 +0100 Subject: [PATCH 0084/1544] Use call_soon_threadsafe in render_will_timeout of template helper (#106514) --- homeassistant/helpers/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f96b2c53b50..ad8c4ba771a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -651,7 +651,7 @@ class Template: except Exception: # pylint: disable=broad-except self._exc_info = sys.exc_info() finally: - run_callback_threadsafe(self.hass.loop, finish_event.set) + self.hass.loop.call_soon_threadsafe(finish_event.set) try: template_render_thread = ThreadWithException(target=_render_template) From 4764af96a8707f6a659c4c8acf2890a3be1fb28e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 14:23:43 +0100 Subject: [PATCH 0085/1544] Mark date entity component as strictly typed (#106716) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index b85a9df857a..75a08cb9e09 100644 --- a/.strict-typing +++ b/.strict-typing @@ -102,6 +102,7 @@ homeassistant.components.configurator.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* +homeassistant.components.date.* homeassistant.components.deconz.* homeassistant.components.demo.* homeassistant.components.derivative.* diff --git a/mypy.ini b/mypy.ini index 1f810bdb1ae..99c7749c80d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -780,6 +780,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.date.*] +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.deconz.*] check_untyped_defs = true disallow_incomplete_defs = true From faa2129e96e5c19e984450bfbbf4f19132189b14 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 14:32:44 +0100 Subject: [PATCH 0086/1544] Mark todo entity component as strictly typed (#106718) --- .strict-typing | 1 + homeassistant/components/todo/intent.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 75a08cb9e09..a2adc5b0de4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -351,6 +351,7 @@ homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* +homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* homeassistant.components.tplink_omada.* diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 4cf62c6391d..2cce9da9c0f 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -22,7 +22,7 @@ class ListAddItemIntent(intent.IntentHandler): intent_type = INTENT_LIST_ADD_ITEM slot_schema = {"item": cv.string, "name": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" hass = intent_obj.hass diff --git a/mypy.ini b/mypy.ini index 99c7749c80d..4c6b9a4ac6a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3272,6 +3272,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.todo.*] +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.tolo.*] check_untyped_defs = true disallow_incomplete_defs = true From 8140036d2e72290ba0bcc9b1c9517520622255a1 Mon Sep 17 00:00:00 2001 From: DiamondDrake Date: Sat, 30 Dec 2023 08:34:50 -0500 Subject: [PATCH 0087/1544] Add support for cookie file to media_extractor (#104973) * media_extractor comp -> add support for cookie file * Update __init__.py * Update __init__.py * fixed pr request, added test * update test with valid cookie file * move cookies to subdirectory * use pathlib instead of os.path --- .../components/media_extractor/__init__.py | 16 ++++- tests/components/media_extractor/test_init.py | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 39ce1f7a3bd..b657caceaff 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -1,6 +1,7 @@ """Decorator service for the media_player.play_media service.""" from collections.abc import Callable import logging +from pathlib import Path from typing import Any, cast import voluptuous as vol @@ -106,7 +107,20 @@ class MediaExtractor: def get_stream_selector(self) -> Callable[[str], str]: """Return format selector for the media URL.""" - ydl = YoutubeDL({"quiet": True, "logger": _LOGGER}) + cookies_file = Path( + self.hass.config.config_dir, "media_extractor", "cookies.txt" + ) + ydl_params = {"quiet": True, "logger": _LOGGER} + if cookies_file.exists(): + ydl_params["cookiefile"] = str(cookies_file) + _LOGGER.debug( + "Media extractor loaded cookies file from: %s", str(cookies_file) + ) + else: + _LOGGER.debug( + "Media extractor didn't find cookies file at: %s", str(cookies_file) + ) + ydl = YoutubeDL(ydl_params) try: all_media = ydl.extract_info(self.get_media_url(), process=False) diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index c60f67031cf..d32ad90d87c 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -1,4 +1,6 @@ """The tests for Media Extractor integration.""" +import os +import os.path from typing import Any from unittest.mock import patch @@ -209,3 +211,60 @@ async def test_query_error( await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_cookiefile_detection( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test cookie file detection.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + cookies_dir = os.path.join(hass.config.config_dir, "media_extractor") + cookies_file = os.path.join(cookies_dir, "cookies.txt") + + if not os.path.exists(cookies_dir): + os.makedirs(cookies_dir) + + f = open(cookies_file, "w+", encoding="utf-8") + f.write( + """# Netscape HTTP Cookie File + + .youtube.com TRUE / TRUE 1701708706 GPS 1 + """ + ) + f.close() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert "Media extractor loaded cookies file" in caplog.text + + os.remove(cookies_file) + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert "Media extractor didn't find cookies file" in caplog.text From 2a6a347cd054c838dbb30bf925760483a92e6ed1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 15:47:08 +0100 Subject: [PATCH 0088/1544] Mark datetime entity component as strictly typed (#106717) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index a2adc5b0de4..2d7a22cc30a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.cover.* homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* homeassistant.components.date.* +homeassistant.components.datetime.* homeassistant.components.deconz.* homeassistant.components.demo.* homeassistant.components.derivative.* diff --git a/mypy.ini b/mypy.ini index 4c6b9a4ac6a..a2b7fdbb025 100644 --- a/mypy.ini +++ b/mypy.ini @@ -790,6 +790,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.datetime.*] +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.deconz.*] check_untyped_defs = true disallow_incomplete_defs = true From ff25211bf955f7e2d312e7e6306af689dd5bb55f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 20:20:33 +0100 Subject: [PATCH 0089/1544] Mark wake_word entity component as strictly typed (#106724) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 2d7a22cc30a..32ded35430d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -379,6 +379,7 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* +homeassistant.components.wake_word.* homeassistant.components.wallbox.* homeassistant.components.water_heater.* homeassistant.components.watttime.* diff --git a/mypy.ini b/mypy.ini index a2b7fdbb025..d02b70a81bf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3553,6 +3553,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wake_word.*] +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.wallbox.*] check_untyped_defs = true disallow_incomplete_defs = true From 1849d68e786006d16dba35d3bddc4137b6afda18 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 20:21:33 +0100 Subject: [PATCH 0090/1544] Mark siren entity component as strictly typed (#106719) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 32ded35430d..b0f44a7ec60 100644 --- a/.strict-typing +++ b/.strict-typing @@ -316,6 +316,7 @@ homeassistant.components.sfr_box.* homeassistant.components.shelly.* homeassistant.components.simplepush.* homeassistant.components.simplisafe.* +homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* homeassistant.components.sleepiq.* diff --git a/mypy.ini b/mypy.ini index d02b70a81bf..1d28fa7bdb2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2921,6 +2921,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.siren.*] +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.skybell.*] check_untyped_defs = true disallow_incomplete_defs = true From 969f9e2e3f674eb852a7ac713110781585470c38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Dec 2023 09:49:06 -1000 Subject: [PATCH 0091/1544] Use more shorthand attrs in bond fan (#106740) --- homeassistant/components/bond/fan.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 465c4b8966b..403e0ae01e6 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -72,6 +72,14 @@ class BondFan(BondEntity, FanEntity): super().__init__(hub, device, bpup_subs) if self._device.has_action(Action.BREEZE_ON): self._attr_preset_modes = [PRESET_MODE_BREEZE] + features = FanEntityFeature(0) + if self._device.supports_speed(): + features |= FanEntityFeature.SET_SPEED + if self._device.supports_direction(): + features |= FanEntityFeature.DIRECTION + if self._device.has_action(Action.BREEZE_ON): + features |= FanEntityFeature.PRESET_MODE + self._attr_supported_features = features def _apply_state(self) -> None: state = self._device.state @@ -81,18 +89,6 @@ class BondFan(BondEntity, FanEntity): breeze = state.get("breeze", [0, 0, 0]) self._attr_preset_mode = PRESET_MODE_BREEZE if breeze[0] else None - @property - def supported_features(self) -> FanEntityFeature: - """Flag supported features.""" - features = FanEntityFeature(0) - if self._device.supports_speed(): - features |= FanEntityFeature.SET_SPEED - if self._device.supports_direction(): - features |= FanEntityFeature.DIRECTION - if self._device.has_action(Action.BREEZE_ON): - features |= FanEntityFeature.PRESET_MODE - return features - @property def _speed_range(self) -> tuple[int, int]: """Return the range of speeds.""" From 82b15d9e38882a59f1a7772ef667de5f96260a79 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Dec 2023 23:16:06 +0100 Subject: [PATCH 0092/1544] Add missing vacuum toggle service description (#106729) --- homeassistant/components/vacuum/services.yaml | 8 ++++++++ homeassistant/components/vacuum/strings.json | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index aab35b42077..25f3822bd35 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -14,6 +14,14 @@ turn_off: supported_features: - vacuum.VacuumEntityFeature.TURN_OFF +toggle: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_OFF + - vacuum.VacuumEntityFeature.TURN_ON + stop: target: entity: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 3c018fc1a89..15ba2076060 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -48,6 +48,10 @@ "name": "[%key:common::action::turn_off%]", "description": "Stops the current cleaning task and returns to its dock." }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the vacuum cleaner on/off." + }, "stop": { "name": "[%key:common::action::stop%]", "description": "Stops the current cleaning task." From 95f6336ecd1e9aed6ce7d40471d5f34b320535c1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 31 Dec 2023 00:45:05 +0100 Subject: [PATCH 0093/1544] Bump reolink_aio to 0.8.5 (#106747) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/media_source.py | 11 ++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index e687fc5d9b1..d5116af0071 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.4"] + "requirements": ["reolink-aio==0.8.5"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 6a350e13836..2a1eee9e97d 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -5,6 +5,8 @@ from __future__ import annotations import datetime as dt import logging +from reolink_aio.enums import VodRequestType + from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source.error import Unresolvable @@ -56,7 +58,14 @@ class ReolinkVODMediaSource(MediaSource): channel = int(channel_str) host = self.data[config_entry_id].host - mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + + vod_type = VodRequestType.RTMP + if host.api.is_nvr: + vod_type = VodRequestType.FLV + + mime_type, url = await host.api.get_vod_source( + channel, filename, stream_res, vod_type + ) if _LOGGER.isEnabledFor(logging.DEBUG): url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index ceff0806681..61ca9267f36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2379,7 +2379,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96fdb709c4a..9ad67341c01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1798,7 +1798,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.rflink rflink==0.0.65 From b1dd064f2d57b1e96d08d988d1dd8db89f014e27 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 31 Dec 2023 02:52:59 +0100 Subject: [PATCH 0094/1544] Mark time entity component as strictly typed (#106720) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index b0f44a7ec60..076b093adbb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -353,6 +353,7 @@ homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* +homeassistant.components.time.* homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* diff --git a/mypy.ini b/mypy.ini index 1d28fa7bdb2..c6a880bc6d7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3292,6 +3292,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.time.*] +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.todo.*] check_untyped_defs = true disallow_incomplete_defs = true From 92713c3f376dc6e071b5023232be956a46a3466e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 Dec 2023 22:44:12 -1000 Subject: [PATCH 0095/1544] Bump pyunifiprotect to 4.22.4 (#106749) changelog: https://github.com/AngellusMortis/pyunifiprotect/compare/v4.22.3...v4.22.4 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index cd38f50bf6d..c74097c3c17 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.4", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 61ca9267f36..4721c2b9ae0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2283,7 +2283,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ad67341c01..ed39612ec8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1726,7 +1726,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 6a582b29f16cf3800be5eef1614567c5d3e3ad84 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 31 Dec 2023 10:04:42 +0100 Subject: [PATCH 0096/1544] Bump pyatmo to v8.0.2 (#106758) --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index f5f2d67947f..aee63e60016 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.1"] + "requirements": ["pyatmo==8.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4721c2b9ae0..2e93f5a0a32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1648,7 +1648,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed39612ec8c..971e20484a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1265,7 +1265,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 From 98c41f7398c8de7489a6beb5451731f2b13e3832 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 31 Dec 2023 04:54:09 -0500 Subject: [PATCH 0097/1544] Bump ZHA dependencies (#106756) * Bump ZHA dependencies * Revert "Remove bellows thread, as it has been removed upstream" This reverts commit c28053f4bf2539eb6150d35af19687610aaeac5e. --- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 10 +++++ homeassistant/components/zha/manifest.json | 6 +-- homeassistant/components/zha/radio_manager.py | 2 + requirements_all.txt | 6 +-- requirements_test_all.txt | 6 +-- tests/components/zha/test_gateway.py | 45 ++++++++++++++++++- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 7e591a596e5..ecbd347a621 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,6 +139,7 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" +CONF_USE_THREAD = "use_thread" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1308abb3d37..12e439f1059 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,6 +46,7 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_RADIO_TYPE, + CONF_USE_THREAD, CONF_ZIGPY, DATA_ZHA, DEBUG_COMP_BELLOWS, @@ -158,6 +159,15 @@ class ZHAGateway: if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True + # The bellows UART thread sometimes propagates a cancellation into the main Core + # event loop, when a connection to a TCP coordinator fails in a specific way + if ( + CONF_USE_THREAD not in app_config + and radio_type is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6a14a3064a6..db5939123e4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,12 +21,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.4", + "bellows==0.37.6", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.109", - "zigpy-deconz==0.22.3", - "zigpy==0.60.2", + "zigpy-deconz==0.22.4", + "zigpy==0.60.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 92a90e0e13a..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -10,6 +10,7 @@ import logging import os from typing import Any, Self +from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -174,6 +175,7 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False + app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( diff --git a/requirements_all.txt b/requirements_all.txt index 2e93f5a0a32..39ddc20c838 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,7 +526,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2878,7 +2878,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2890,7 +2890,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.3 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 971e20484a3..6b9c70bc73b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2174,7 +2174,7 @@ zeversolar==0.3.1 zha-quirks==0.0.109 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2186,7 +2186,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.2 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 1d9042daa4a..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,8 +1,9 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting @@ -222,6 +223,48 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", + MagicMock(), +) +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", + MagicMock(), +) +@pytest.mark.parametrize( + ("device_path", "thread_state", "config_override"), + [ + ("/dev/ttyUSB0", True, {}), + ("socket://192.168.1.123:9999", False, {}), + ("socket://192.168.1.123:9999", True, {"use_thread": True}), + ], +) +async def test_gateway_initialize_bellows_thread( + device_path: str, + thread_state: bool, + config_override: dict, + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) + + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() + + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() + + @pytest.mark.parametrize( ("device_path", "config_override", "expected_channel"), [ From f7154cff9da8ce9ae66b59970b3b8fa2ae162cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 31 Dec 2023 11:50:53 +0100 Subject: [PATCH 0098/1544] Update aioairzone-cloud to v0.3.8 (#106736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e10669d6a93..f8b740dc04d 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.7"] + "requirements": ["aioairzone-cloud==0.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 39ddc20c838..ac0b2527466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ aio-georss-gdacs==0.8 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.7 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone aioairzone==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b9c70bc73b..2cf743d3731 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.7 +aioairzone-cloud==0.3.8 # homeassistant.components.airzone aioairzone==0.7.2 From 0549c9e113176cb97d9c84b6de9f35baca58f03f Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 31 Dec 2023 12:24:44 +0100 Subject: [PATCH 0099/1544] Add sensor platform for tedee integration (#106722) * add sensors * requested changes * remove translation key from battery * fix pullspring test * loop instead of parametrize * name snapshots * fix snapshots --- homeassistant/components/tedee/__init__.py | 1 + homeassistant/components/tedee/entity.py | 17 ++++ homeassistant/components/tedee/sensor.py | 74 ++++++++++++++ homeassistant/components/tedee/strings.json | 7 ++ .../tedee/snapshots/test_sensor.ambr | 98 +++++++++++++++++++ tests/components/tedee/test_sensor.py | 36 +++++++ 6 files changed, 233 insertions(+) create mode 100644 homeassistant/components/tedee/sensor.py create mode 100644 tests/components/tedee/snapshots/test_sensor.ambr create mode 100644 tests/components/tedee/test_sensor.py diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 2ba6131d00c..7940d594d71 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -10,6 +10,7 @@ from .coordinator import TedeeApiCoordinator PLATFORMS = [ Platform.LOCK, + Platform.SENSOR, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index fa80ffcb24c..86baa81b452 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -4,6 +4,7 @@ from pytedee_async.lock import TedeeLock from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -38,3 +39,19 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): """Handle updated data from the coordinator.""" self._lock = self.coordinator.data[self._lock.lock_id] super()._handle_coordinator_update() + + +class TedeeDescriptionEntity(TedeeEntity): + """Base class for Tedee device entities.""" + + entity_description: EntityDescription + + def __init__( + self, + lock: TedeeLock, + coordinator: TedeeApiCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize Tedee device entity.""" + super().__init__(lock, coordinator, entity_description.key) + self.entity_description = entity_description diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py new file mode 100644 index 00000000000..9eb61e624c7 --- /dev/null +++ b/homeassistant/components/tedee/sensor.py @@ -0,0 +1,74 @@ +"""Tedee sensor entities.""" +from collections.abc import Callable +from dataclasses import dataclass + +from pytedee_async import TedeeLock + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +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 .const import DOMAIN +from .entity import TedeeDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class TedeeSensorEntityDescription(SensorEntityDescription): + """Describes Tedee sensor entity.""" + + value_fn: Callable[[TedeeLock], float | None] + + +ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( + TedeeSensorEntityDescription( + key="battery_sensor", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda lock: lock.battery_level, + ), + TedeeSensorEntityDescription( + key="pullspring_duration", + translation_key="pullspring_duration", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL, + icon="mdi:timer-lock-open", + value_fn=lambda lock: lock.duration_pullspring, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee sensor entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + for entity_description in ENTITIES: + async_add_entities( + [ + TedeeSensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + ] + ) + + +class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): + """Tedee sensor entity.""" + + entity_description: TedeeSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._lock) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index e9286d894aa..7a1df7d3875 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -21,5 +21,12 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "pullspring_duration": { + "name": "Pullspring duration" + } + } } } diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a74ee38bff0 --- /dev/null +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_sensors[entry-battery] + 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.lock_1a2b_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-battery_sensor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[entry-pullspring_duration] + 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.lock_1a2b_pullspring_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer-lock-open', + 'original_name': 'Pullspring duration', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_duration', + 'unique_id': '12345-pullspring_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[state-battery] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Lock-1A2B Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_battery', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[state-pullspring_duration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Lock-1A2B Pullspring duration', + 'icon': 'mdi:timer-lock-open', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lock_1a2b_pullspring_duration', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py new file mode 100644 index 00000000000..95cde20a82f --- /dev/null +++ b/tests/components/tedee/test_sensor.py @@ -0,0 +1,36 @@ +"""Tests for the Tedee Sensors.""" + + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +SENSORS = ( + "battery", + "pullspring_duration", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test tedee sensors.""" + for key in SENSORS: + state = hass.states.get(f"sensor.lock_1a2b_{key}") + assert state + assert state == snapshot(name=f"state-{key}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"entry-{key}") From 83b09341387f47653745a53edb01ebb991ea652a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 31 Dec 2023 13:16:01 +0100 Subject: [PATCH 0100/1544] Add binary sensors for tedee (#106773) * fix tests * Update homeassistant/components/tedee/binary_sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tedee/__init__.py | 1 + .../components/tedee/binary_sensor.py | 78 +++++++++++ homeassistant/components/tedee/strings.json | 8 ++ .../tedee/snapshots/test_binary_sensor.ambr | 131 ++++++++++++++++++ tests/components/tedee/test_binary_sensor.py | 34 +++++ 5 files changed, 252 insertions(+) create mode 100644 homeassistant/components/tedee/binary_sensor.py create mode 100644 tests/components/tedee/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/tedee/test_binary_sensor.py diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 7940d594d71..1eb6b7a0333 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -9,6 +9,7 @@ from .const import DOMAIN from .coordinator import TedeeApiCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR, ] diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py new file mode 100644 index 00000000000..9bb2cd0410c --- /dev/null +++ b/homeassistant/components/tedee/binary_sensor.py @@ -0,0 +1,78 @@ +"""Tedee sensor entities.""" +from collections.abc import Callable +from dataclasses import dataclass + +from pytedee_async import TedeeLock +from pytedee_async.lock import TedeeLockState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import TedeeDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class TedeeBinarySensorEntityDescription( + BinarySensorEntityDescription, +): + """Describes Tedee binary sensor entity.""" + + is_on_fn: Callable[[TedeeLock], bool | None] + + +ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( + TedeeBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on_fn=lambda lock: lock.is_charging, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TedeeBinarySensorEntityDescription( + key="semi_locked", + translation_key="semi_locked", + is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TedeeBinarySensorEntityDescription( + key="pullspring_enabled", + translation_key="pullspring_enabled", + is_on_fn=lambda lock: lock.is_enabled_pullspring, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tedee sensor entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + for entity_description in ENTITIES: + async_add_entities( + [ + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + ] + ) + + +class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): + """Tedee sensor entity.""" + + entity_description: TedeeBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self._lock) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 7a1df7d3875..db6a450c1f3 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -23,6 +23,14 @@ } }, "entity": { + "binary_sensor": { + "pullspring_enabled": { + "name": "Pullspring enabled" + }, + "semi_locked": { + "name": "Semi locked" + } + }, "sensor": { "pullspring_duration": { "name": "Pullspring duration" diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..16be8aafd0e --- /dev/null +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -0,0 +1,131 @@ +# serializer version: 1 +# name: test_binary_sensors[entry-charging] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[entry-pullspring_enabled] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pullspring enabled', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pullspring_enabled', + 'unique_id': '12345-pullspring_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[entry-semi_locked] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.lock_1a2b_semi_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Semi locked', + 'platform': 'tedee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'semi_locked', + 'unique_id': '12345-semi_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[state-charging] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Lock-1A2B Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[state-pullspring_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Pullspring enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_pullspring_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[state-semi_locked] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lock-1A2B Semi locked', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_1a2b_semi_locked', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py new file mode 100644 index 00000000000..bdb66c9c0a9 --- /dev/null +++ b/tests/components/tedee/test_binary_sensor.py @@ -0,0 +1,34 @@ +"""Tests for the Tedee Binary Sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + +BINARY_SENSORS = ( + "charging", + "semi_locked", + "pullspring_enabled", +) + + +async def test_binary_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test tedee battery charging sensor.""" + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_1a2b_{key}") + assert state + assert state == snapshot(name=f"state-{key}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot(name=f"entry-{key}") From 70e2ff351da38a72266966a2557ce80814966815 Mon Sep 17 00:00:00 2001 From: Guy Shefer Date: Sun, 31 Dec 2023 16:39:00 +0200 Subject: [PATCH 0101/1544] Add Tami4 integration boil water button (#103400) * Implement boil water button * Sort platforms list * Get API directly * Cleanup * Rename boil button string Co-authored-by: Joost Lekkerkerker * Add button to .coveragerc * Change ButtonEntityDescription to EntityDescription * Update homeassistant/components/tami4/button.py * Update homeassistant/components/tami4/button.py --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/tami4/__init__.py | 2 +- homeassistant/components/tami4/button.py | 42 +++++++++++++++++++++ homeassistant/components/tami4/strings.json | 5 +++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tami4/button.py diff --git a/.coveragerc b/.coveragerc index 44e424260c1..d17676d79c9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1318,6 +1318,7 @@ omit = homeassistant/components/tado/device_tracker.py homeassistant/components/tado/sensor.py homeassistant/components/tado/water_heater.py + homeassistant/components/tami4/button.py homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/__init__.py homeassistant/components/tankerkoenig/binary_sensor.py diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 846f1194930..643363b1285 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN from .coordinator import Tami4EdgeWaterQualityCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py new file mode 100644 index 00000000000..30ff4824e18 --- /dev/null +++ b/homeassistant/components/tami4/button.py @@ -0,0 +1,42 @@ +"""Button entities for Tami4Edge.""" +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.components.button import ButtonEntity +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 API, DOMAIN +from .entity import Tami4EdgeBaseEntity + +_LOGGER = logging.getLogger(__name__) + +ENTITY_DESCRIPTION = EntityDescription( + key="boil_water", + translation_key="boil_water", + icon="mdi:kettle-steam", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Perform the setup for Tami4Edge.""" + api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] + + async_add_entities([Tami4EdgeBoilButton(api)]) + + +class Tami4EdgeBoilButton(Tami4EdgeBaseEntity, ButtonEntity): + """Boil button entity for Tami4Edge.""" + + def __init__(self, api: Tami4EdgeAPI) -> None: + """Initialize the button entity.""" + super().__init__(api, ENTITY_DESCRIPTION) + + def press(self) -> None: + """Handle the button press.""" + self._api.boil_water() diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 9036d92d6f1..79447d93e9e 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -22,6 +22,11 @@ "filter_litters_passed": { "name": "Filter water passed" } + }, + "button": { + "boil_water": { + "name": "Boil water" + } } }, "config": { From bfda3f1ba817a4b9a02e94045b36a89fb47265f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 31 Dec 2023 06:44:55 -1000 Subject: [PATCH 0102/1544] Bump habluetooth to 2.0.1 (#106750) fixes switching scanners to quickly since the manager failed to account for jitter in the auto discovered advertising interval replaces and closes #96531 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v2.0.0...v2.0.1 --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 86 ++++++++++++++++++- 5 files changed, 89 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 33404a762b9..19199e4b1c6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.0.0" + "habluetooth==2.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec064f84126..723ec15d046 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.0.0 +habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index ac0b2527466..2c967f974cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.0 +habluetooth==2.0.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cf743d3731..c66cc77502e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -800,7 +800,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.0 +habluetooth==2.0.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 212f45bb5f0..4726c12f681 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -7,11 +7,12 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory -from habluetooth.manager import FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, @@ -315,6 +316,89 @@ async def test_switching_adapters_based_on_stale( ) +async def test_switching_adapters_based_on_stale_with_discovered_interval( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test switching with discovered interval.""" + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + bluetooth.async_set_fallback_availability_interval(hass, address, 10) + + switchbot_device_poor_signal_hci1 = generate_ble_device( + address, "wohand_poor_signal_hci1" + ) + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic, + "hci1", + ) + + # Should not switch adapters until the advertisement is stale + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + 1, + "hci1", + ) + + # Should not switch yet since we are not within the + # wobble period + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, + "hci1", + ) + # Should switch to hci1 since the previous advertisement is stale + # even though the signal is poor because the device is now + # likely unreachable via hci0 + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci1 + ) + + async def test_restore_history_from_dbus( hass: HomeAssistant, one_adapter: None, disable_new_discovery_flows ) -> None: From c83388fd2da3fab062dec3fc3c3ebc8013ca478b Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 31 Dec 2023 12:12:06 -0500 Subject: [PATCH 0103/1544] Fix Zlinky energy polling in ZHA (#106738) --- .../components/zha/core/cluster_handlers/smartenergy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 8fd38425dff..2ceaeaf1013 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -195,9 +195,9 @@ class Metering(ClusterHandler): ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) - async def async_force_update(self) -> None: + async def async_update(self) -> None: """Retrieve latest state.""" - self.debug("async_force_update") + self.debug("async_update") attrs = [ a["attr"] From c1f1b5c50b1311e805a1d93d07d121a064011d3c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 31 Dec 2023 18:54:34 +0100 Subject: [PATCH 0104/1544] Ensure it's safe to call Entity.__repr__ on non added entity (#106032) --- homeassistant/components/sensor/__init__.py | 11 ----------- homeassistant/helpers/entity.py | 7 ++++++- tests/helpers/test_entity.py | 15 +++++++++++++-- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index c82254bdcb1..6077e4708d5 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -733,17 +733,6 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return value - def __repr__(self) -> str: - """Return the representation. - - Entity.__repr__ includes the state in the generated string, this fails if we're - called before self.hass is set. - """ - if not self.hass: - return f"" - - return super().__repr__() - def _suggested_precision_or_none(self) -> int | None: """Return suggested display precision, or None if not set.""" assert self.registry_entry diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index fc627f51acf..b7ed7e3c095 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1476,7 +1476,12 @@ class Entity( self.async_on_remove(self._async_unsubscribe_device_updates) def __repr__(self) -> str: - """Return the representation.""" + """Return the representation. + + If the entity is not added to a platform it's not safe to call _stringify_state. + """ + if self._platform_state != EntityPlatformState.ADDED: + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index b85c8794fed..a74ef166907 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1392,8 +1392,8 @@ async def test_translation_key(hass: HomeAssistant) -> None: assert mock_entity2.translation_key == "from_entity_description" -async def test_repr_using_stringify_state() -> None: - """Test that repr uses stringify state.""" +async def test_repr(hass) -> None: + """Test Entity.__repr__.""" class MyEntity(MockEntity): """Mock entity.""" @@ -1403,9 +1403,20 @@ async def test_repr_using_stringify_state() -> None: """Return the state.""" raise ValueError("Boom") + platform = MockEntityPlatform(hass, domain="hello") my_entity = MyEntity(entity_id="test.test", available=False) + + # Not yet added + assert str(my_entity) == "" + + # Added + await platform.async_add_entities([my_entity]) assert str(my_entity) == "" + # Removed + await platform.async_remove_entity(my_entity.entity_id) + assert str(my_entity) == "" + async def test_warn_using_async_update_ha_state( hass: HomeAssistant, caplog: pytest.LogCaptureFixture From 80f8102b8325d93a4cb840b24751c7546954b4ce Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 31 Dec 2023 14:17:51 -0500 Subject: [PATCH 0105/1544] Bump pyschlage to 2023.12.1 (#106782) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index e14a5bc706e..72d5ad54565 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.12.0"] + "requirements": ["pyschlage==2023.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2c967f974cf..6db71e7c6b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2058,7 +2058,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c66cc77502e..06f854dfc42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1564,7 +1564,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 From ce54a1259a2e00bebbfe21596bdba8c20cdbdde5 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sun, 31 Dec 2023 14:26:21 -0500 Subject: [PATCH 0106/1544] Bump pyunifiprotect to v4.22.5 (#106781) --- .../components/unifiprotect/binary_sensor.py | 11 +++++++++++ homeassistant/components/unifiprotect/button.py | 7 +++++++ homeassistant/components/unifiprotect/data.py | 2 ++ homeassistant/components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/media_player.py | 11 +++++++++++ homeassistant/components/unifiprotect/number.py | 12 ++++++++++++ homeassistant/components/unifiprotect/select.py | 11 +++++++++++ homeassistant/components/unifiprotect/sensor.py | 9 +++++++++ homeassistant/components/unifiprotect/switch.py | 11 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 11 files changed, 77 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index f32b53a5d7a..1104ecb98e1 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -643,4 +643,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): or self._attr_extra_state_attributes != previous_extra_state_attributes or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_is_on, + previous_available, + previous_extra_state_attributes, + self._attr_is_on, + self._attr_available, + self._attr_extra_state_attributes, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 01bde0d9248..b69fbb95970 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -206,4 +206,11 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): previous_available = self._attr_available self._async_update_device_from_protect(device) if self._attr_available != previous_available: + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device.name, + device.mac, + previous_available, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 73d05f1be1d..8b8ec80c5ba 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -228,6 +228,8 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): # type: ignore[unreachable] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("event WS msg: %s", obj.dict()) if obj.type in SMART_EVENTS: if obj.camera is not None: if obj.end is None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index c74097c3c17..2fbf8f31071 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.4", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index df5ea40d4a9..b2376277e6f 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -133,6 +133,17 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): or self._attr_volume_level != previous_volume_level or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_state, + previous_available, + previous_volume_level, + self._attr_state, + self._attr_available, + self._attr_volume_level, + ) self.async_write_ha_state() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 7fed79499d2..c02753a9401 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from pyunifiprotect.data import ( Camera, @@ -25,6 +26,8 @@ from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True) class NumberKeysMixin: @@ -285,4 +288,13 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 649c77bed5b..dfc3be2d4a1 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -420,4 +420,15 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): or self._attr_options != previous_options or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_option, + previous_available, + previous_options, + self._attr_current_option, + self._attr_available, + self._attr_options, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 6344b852b63..3e2bd6ee858 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -730,6 +730,15 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index c57546be8d0..d8a3fc1c5bc 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Any from pyunifiprotect.data import ( @@ -27,6 +28,7 @@ from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_enti from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" @@ -458,6 +460,15 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): self._attr_is_on != previous_is_on or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_is_on, + previous_available, + self._attr_is_on, + self._attr_available, + ) self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 6db71e7c6b3..40534c589c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2283,7 +2283,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.4 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06f854dfc42..53ae03081bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1726,7 +1726,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.4 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 74e02fe057b0bcb782b03df61b825a4545110ba0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 13:12:56 +0100 Subject: [PATCH 0107/1544] Update pytest to 7.4.4 (#106802) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3a552741812..c814a035d2d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.5.0 pytest-xdist==3.3.1 -pytest==7.4.3 +pytest==7.4.4 requests-mock==1.11.0 respx==0.20.2 syrupy==4.6.0 From 41f0eda7124619e27057e50c975fa898998088a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jan 2024 02:14:28 -1000 Subject: [PATCH 0108/1544] Use shorthand attrs for tplink color temp min/max (#106796) The valid_temperature_range property does a regex match over every possible model. Avoid calling it more than once since it will never change as its based on the model --- homeassistant/components/tplink/light.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 8e77c68a880..c4ec80347d5 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -185,6 +185,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): modes: set[ColorMode] = set() if device.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) + temp_range = device.valid_temperature_range + self._attr_min_color_temp_kelvin = temp_range.min + self._attr_max_color_temp_kelvin = temp_range.max if device.is_color: modes.add(ColorMode.HS) if device.is_dimmable: @@ -251,16 +254,6 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): transition = int(transition * 1_000) await self.device.turn_off(transition=transition) - @property - def min_color_temp_kelvin(self) -> int: - """Return minimum supported color temperature.""" - return cast(int, self.device.valid_temperature_range.min) - - @property - def max_color_temp_kelvin(self) -> int: - """Return maximum supported color temperature.""" - return cast(int, self.device.valid_temperature_range.max) - @property def color_temp_kelvin(self) -> int: """Return the color temperature of this light.""" From a64f9127336892a4c8df7b72ab0d10f55e862524 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Mon, 1 Jan 2024 17:24:13 +0100 Subject: [PATCH 0109/1544] Use walrus operator for roomba total cleaned area sensor value (#106772) --- homeassistant/components/roomba/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index c02de0229c0..ad2894ebb11 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -126,9 +126,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ native_unit_of_measurement=AREA_SQUARE_METERS, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda self: ( - self.run_stats.get("sqft") * 9.29 - if self.run_stats.get("sqft") is not None - else None + None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29 ), suggested_display_precision=0, entity_registry_enabled_default=False, From 3433e1d34919f1c717f52ac48d833a6ac2ba0f06 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 18:56:44 +0100 Subject: [PATCH 0110/1544] Enable strict typing for airthings_ble (#106815) --- .strict-typing | 1 + homeassistant/components/airthings_ble/__init__.py | 6 +++--- mypy.ini | 10 ++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 076b093adbb..9eb9d318457 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airnow.* +homeassistant.components.airthings_ble.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index d7e6bddbcd4..c642ebf9563 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from airthings_ble import AirthingsBluetoothDeviceData +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -37,13 +37,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Airthings device with address {address}" ) - async def _async_update_method(): + async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" ble_device = bluetooth.async_ble_device_from_address(hass, address) airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) try: - data = await airthings.update_device(ble_device) + data = await airthings.update_device(ble_device) # type: ignore[arg-type] except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/mypy.ini b/mypy.ini index c6a880bc6d7..06d9f21df42 100644 --- a/mypy.ini +++ b/mypy.ini @@ -250,6 +250,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airthings_ble.*] +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.airvisual.*] check_untyped_defs = true disallow_incomplete_defs = true From c37e2680306478d5bd485734017b6ca649a266bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:14:00 +0100 Subject: [PATCH 0111/1544] Enable strict typing for aprs (#106824) --- .strict-typing | 1 + homeassistant/components/aprs/device_tracker.py | 17 +++++++++-------- mypy.ini | 10 ++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9eb9d318457..81400b5688f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -69,6 +69,7 @@ homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* homeassistant.components.apprise.* +homeassistant.components.aprs.* homeassistant.components.aqualogic.* homeassistant.components.aranet.* homeassistant.components.aseko_pool_live.* diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index b1467a6d2e4..8b952f88c7c 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import threading +from typing import Any import aprslib from aprslib import ConnectionError as AprsConnectionError, LoginError @@ -23,7 +24,7 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -66,7 +67,7 @@ def make_filter(callsigns: list) -> str: return " ".join(f"b/{sign.upper()}" for sign in callsigns) -def gps_accuracy(gps, posambiguity: int) -> int: +def gps_accuracy(gps: tuple[float, float], posambiguity: int) -> int: """Calculate the GPS accuracy based on APRS posambiguity.""" pos_a_map = {0: 0, 1: 1 / 600, 2: 1 / 60, 3: 1 / 6, 4: 1} @@ -74,7 +75,7 @@ def gps_accuracy(gps, posambiguity: int) -> int: degrees = pos_a_map[posambiguity] gps2 = (gps[0], gps[1] + degrees) - dist_m = geopy.distance.distance(gps, gps2).m + dist_m: float = geopy.distance.distance(gps, gps2).m accuracy = round(dist_m) else: @@ -100,7 +101,7 @@ def setup_scanner( timeout = config[CONF_TIMEOUT] aprs_listener = AprsListenerThread(callsign, password, host, server_filter, see) - def aprs_disconnect(event): + def aprs_disconnect(event: Event) -> None: """Stop the APRS connection.""" aprs_listener.stop() @@ -145,13 +146,13 @@ class AprsListenerThread(threading.Thread): self.callsign, passwd=password, host=self.host, port=FILTER_PORT ) - def start_complete(self, success: bool, message: str): + def start_complete(self, success: bool, message: str) -> None: """Complete startup process.""" self.start_message = message self.start_success = success self.start_event.set() - def run(self): + def run(self) -> None: """Connect to APRS and listen for data.""" self.ais.set_filter(self.server_filter) @@ -171,11 +172,11 @@ class AprsListenerThread(threading.Thread): "Closing connection to %s with callsign %s", self.host, self.callsign ) - def stop(self): + def stop(self) -> None: """Close the connection to the APRS network.""" self.ais.close() - def rx_msg(self, msg: dict): + def rx_msg(self, msg: dict[str, Any]) -> None: """Receive message and process if position.""" _LOGGER.debug("APRS message received: %s", str(msg)) if msg[ATTR_FORMAT] in MSG_FORMATS: diff --git a/mypy.ini b/mypy.ini index 06d9f21df42..4b8fa484b06 100644 --- a/mypy.ini +++ b/mypy.ini @@ -450,6 +450,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aprs.*] +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.aqualogic.*] check_untyped_defs = true disallow_incomplete_defs = true From 06a5e258534fc05605c5a9cabeb4322658779005 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:14:34 +0100 Subject: [PATCH 0112/1544] Enable strict typing for anel_pwrctrl (#106821) --- .strict-typing | 1 + homeassistant/components/anel_pwrctrl/switch.py | 8 ++++---- mypy.ini | 10 ++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 81400b5688f..dea3fb25ffd 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,6 +65,7 @@ homeassistant.components.ampio.* homeassistant.components.analytics.* homeassistant.components.android_ip_webcam.* homeassistant.components.androidtv_remote.* +homeassistant.components.anel_pwrctrl.* homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 19d1a2deaff..827fc0037a7 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from typing import Any -from anel_pwrctrl import DeviceMaster +from anel_pwrctrl import Device, DeviceMaster, Switch import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -72,7 +72,7 @@ def setup_platform( class PwrCtrlSwitch(SwitchEntity): """Representation of a PwrCtrl switch.""" - def __init__(self, port, parent_device): + def __init__(self, port: Switch, parent_device: PwrCtrlDevice) -> None: """Initialize the PwrCtrl switch.""" self._port = port self._parent_device = parent_device @@ -96,11 +96,11 @@ class PwrCtrlSwitch(SwitchEntity): class PwrCtrlDevice: """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, device: Device) -> None: """Initialize the PwrCtrl device.""" self._device = device @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self) -> None: """Update the device and all its switches.""" self._device.update() diff --git a/mypy.ini b/mypy.ini index 4b8fa484b06..71a50796866 100644 --- a/mypy.ini +++ b/mypy.ini @@ -410,6 +410,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.anel_pwrctrl.*] +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.anova.*] check_untyped_defs = true disallow_incomplete_defs = true From 4e0c0cf2ca36aaedd8ddb6476e824947e03d63f5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:15:01 +0100 Subject: [PATCH 0113/1544] Enable strict typing for androidtv (#106820) --- .strict-typing | 1 + homeassistant/components/androidtv/__init__.py | 4 ++-- homeassistant/components/androidtv/config_flow.py | 2 +- homeassistant/components/androidtv/media_player.py | 4 ++-- mypy.ini | 10 ++++++++++ 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index dea3fb25ffd..cceaeb9ee52 100644 --- a/.strict-typing +++ b/.strict-typing @@ -64,6 +64,7 @@ homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* homeassistant.components.android_ip_webcam.* +homeassistant.components.androidtv.* homeassistant.components.androidtv_remote.* homeassistant.components.anel_pwrctrl.* homeassistant.components.anova.* diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 4a1ad55e0b1..cd9e42aeb4d 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -28,7 +28,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -166,7 +166,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not aftv: raise ConfigEntryNotReady(error_message) - async def async_close_connection(event): + async def async_close_connection(event: Event) -> None: """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 7e2b1e85f39..e688b0a92de 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -385,4 +385,4 @@ def _validate_state_det_rules(state_det_rules: Any) -> list[Any] | None: except ValueError as exc: _LOGGER.warning("Invalid state detection rules: %s", exc) return None - return json_rules + return json_rules # type: ignore[no-any-return] diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 496b4e51e4f..bd058ac769e 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -313,7 +313,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def _adb_screencap(self) -> bytes | None: """Take a screen capture from the device.""" - return await self.aftv.adb_screencap() + return await self.aftv.adb_screencap() # type: ignore[no-any-return] async def _async_get_screencap(self, prev_app_id: str | None = None) -> None: """Take a screen capture from the device when enabled.""" @@ -331,7 +331,7 @@ class ADBDevice(MediaPlayerEntity): await self._adb_get_screencap(no_throttle=force) @Throttle(MIN_TIME_BETWEEN_SCREENCAPS) - async def _adb_get_screencap(self, **kwargs) -> None: + async def _adb_get_screencap(self, **kwargs: Any) -> None: """Take a screen capture from the device every 60 seconds.""" if media_data := await self._adb_screencap(): self._media_image = media_data, "image/png" diff --git a/mypy.ini b/mypy.ini index 71a50796866..57020bc9f3a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -400,6 +400,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.androidtv.*] +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.androidtv_remote.*] check_untyped_defs = true disallow_incomplete_defs = true From 007798916977869a6d049184e1994f03a1383586 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:19:19 +0100 Subject: [PATCH 0114/1544] Enable strict typing for alpha_vantage (#106816) --- .strict-typing | 1 + homeassistant/components/alpha_vantage/sensor.py | 12 +++++++----- mypy.ini | 10 ++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index cceaeb9ee52..a304f771cd8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -58,6 +58,7 @@ homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* homeassistant.components.alexa.* +homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 02c6958e0da..52427065f68 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -74,9 +74,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Alpha Vantage sensor.""" - api_key = config[CONF_API_KEY] - symbols = config.get(CONF_SYMBOLS, []) - conversions = config.get(CONF_FOREIGN_EXCHANGE, []) + api_key: str = config[CONF_API_KEY] + symbols: list[dict[str, str]] = config.get(CONF_SYMBOLS, []) + conversions: list[dict[str, str]] = config.get(CONF_FOREIGN_EXCHANGE, []) if not symbols and not conversions: msg = "No symbols or currencies configured." @@ -120,7 +120,7 @@ class AlphaVantageSensor(SensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, timeseries, symbol): + def __init__(self, timeseries: TimeSeries, symbol: dict[str, str]) -> None: """Initialize the sensor.""" self._symbol = symbol[CONF_SYMBOL] self._attr_name = symbol.get(CONF_NAME, self._symbol) @@ -154,7 +154,9 @@ class AlphaVantageForeignExchange(SensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, foreign_exchange, config): + def __init__( + self, foreign_exchange: ForeignExchange, config: dict[str, str] + ) -> None: """Initialize the sensor.""" self._foreign_exchange = foreign_exchange self._from_currency = config[CONF_FROM] diff --git a/mypy.ini b/mypy.ini index 57020bc9f3a..bc7c33396ff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -340,6 +340,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.alpha_vantage.*] +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.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true From 800351287bc8ebd209453d299ddc742bebcfe959 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:32:29 +0100 Subject: [PATCH 0115/1544] Enable strict typing for aquostv (#106836) --- .strict-typing | 1 + homeassistant/components/aquostv/media_player.py | 13 ++++++++++--- mypy.ini | 10 ++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index a304f771cd8..2516beaf36a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -74,6 +74,7 @@ homeassistant.components.apcupsd.* homeassistant.components.apprise.* homeassistant.components.aprs.* homeassistant.components.aqualogic.* +homeassistant.components.aquostv.* homeassistant.components.aranet.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 34d5e4161fb..a87756334e2 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -1,7 +1,9 @@ """Support for interface with an Aquos TV.""" from __future__ import annotations +from collections.abc import Callable import logging +from typing import Any, Concatenate, ParamSpec, TypeVar import sharp_aquos_rc import voluptuous as vol @@ -25,6 +27,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +_SharpAquosTVDeviceT = TypeVar("_SharpAquosTVDeviceT", bound="SharpAquosTVDevice") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Sharp Aquos TV" @@ -79,10 +84,12 @@ def setup_platform( add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) -def _retry(func): +def _retry( + func: Callable[Concatenate[_SharpAquosTVDeviceT, _P], Any], +) -> Callable[Concatenate[_SharpAquosTVDeviceT, _P], None]: """Handle query retries.""" - def wrapper(obj, *args, **kwargs): + def wrapper(obj: _SharpAquosTVDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap all query functions.""" update_retries = 5 while update_retries > 0: @@ -125,7 +132,7 @@ class SharpAquosTVDevice(MediaPlayerEntity): # Assume that the TV is not muted self._remote = remote - def set_state(self, state): + def set_state(self, state: MediaPlayerState) -> None: """Set TV state.""" self._attr_state = state diff --git a/mypy.ini b/mypy.ini index bc7c33396ff..3e4385fddcb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -500,6 +500,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aquostv.*] +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.aranet.*] check_untyped_defs = true disallow_incomplete_defs = true From f67bae2cdeca7263131a5d1475cc912c929adddf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:33:15 +0100 Subject: [PATCH 0116/1544] Enable strict typing for aruba (#106839) --- .strict-typing | 1 + .../components/aruba/device_tracker.py | 37 ++++++++++--------- mypy.ini | 10 +++++ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2516beaf36a..64207dd98a1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -76,6 +76,7 @@ homeassistant.components.aprs.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* homeassistant.components.aranet.* +homeassistant.components.aruba.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* homeassistant.components.asuswrt.* diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 7b8c547fd53..1b449450cf8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import re +from typing import Any import pexpect import voluptuous as vol @@ -44,33 +45,33 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | class ArubaDeviceScanner(DeviceScanner): """Class which queries a Aruba Access Point for connected devices.""" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] + self.host: str = config[CONF_HOST] + self.username: str = config[CONF_USERNAME] + self.password: str = config[CONF_PASSWORD] - self.last_results = {} + self.last_results: dict[str, dict[str, str]] = {} # Test the router is accessible. data = self.get_aruba_data() self.success_init = data is not None - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client["mac"] for client in self.last_results] + return [client["mac"] for client in self.last_results.values()] - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" if not self.last_results: return None - for client in self.last_results: + for client in self.last_results.values(): if client["mac"] == device: return client["name"] return None - def _update_info(self): + def _update_info(self) -> bool: """Ensure the information from the Aruba Access Point is up to date. Return boolean if scanning successful. @@ -81,10 +82,10 @@ class ArubaDeviceScanner(DeviceScanner): if not (data := self.get_aruba_data()): return False - self.last_results = data.values() + self.last_results = data return True - def get_aruba_data(self): + def get_aruba_data(self) -> dict[str, dict[str, str]] | None: """Retrieve data from Aruba Access Point and return parsed result.""" connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" @@ -103,22 +104,22 @@ class ArubaDeviceScanner(DeviceScanner): ) if query == 1: _LOGGER.error("Timeout") - return + return None if query == 2: _LOGGER.error("Unexpected response from router") - return + return None if query == 3: ssh.sendline("yes") ssh.expect("password:") elif query == 4: _LOGGER.error("Host key changed") - return + return None elif query == 5: _LOGGER.error("Connection refused by server") - return + return None elif query == 6: _LOGGER.error("Connection timed out") - return + return None ssh.sendline(self.password) ssh.expect("#") ssh.sendline("show clients") @@ -126,7 +127,7 @@ class ArubaDeviceScanner(DeviceScanner): devices_result = ssh.before.split(b"\r\n") ssh.sendline("exit") - devices = {} + devices: dict[str, dict[str, str]] = {} for device in devices_result: if match := _DEVICES_REGEX.search(device.decode("utf-8")): devices[match.group("ip")] = { diff --git a/mypy.ini b/mypy.ini index 3e4385fddcb..57eebdbed1e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -520,6 +520,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aruba.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true From c5c132e1d49e4e32ba61c33f7655df4506ec0071 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:33:38 +0100 Subject: [PATCH 0117/1544] Enable strict typing for airq (#106813) --- .strict-typing | 1 + homeassistant/components/airq/coordinator.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 64207dd98a1..fc6be9aaa8f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airnow.* +homeassistant.components.airq.* homeassistant.components.airthings_ble.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 76459005c45..6f49303bc6c 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -56,4 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - return await self.airq.get_latest_data() + return await self.airq.get_latest_data() # type: ignore[no-any-return] diff --git a/mypy.ini b/mypy.ini index 57eebdbed1e..958396707fc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -250,6 +250,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airq.*] +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.airthings_ble.*] check_untyped_defs = true disallow_incomplete_defs = true From 3b0d877b5e68e850248943233e00597297f21b27 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:35:34 +0100 Subject: [PATCH 0118/1544] Enable strict typing for amberelectric (#106817) --- .strict-typing | 1 + .../components/amberelectric/binary_sensor.py | 15 +++++++-------- .../components/amberelectric/config_flow.py | 4 ++-- .../components/amberelectric/coordinator.py | 6 +++--- homeassistant/components/amberelectric/sensor.py | 11 +++++------ mypy.ini | 10 ++++++++++ 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/.strict-typing b/.strict-typing index fc6be9aaa8f..5c65e947b91 100644 --- a/.strict-typing +++ b/.strict-typing @@ -61,6 +61,7 @@ homeassistant.components.alert.* homeassistant.components.alexa.* homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* +homeassistant.components.amberelectric.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index 1931bcbd32c..25a6c2fe267 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from typing import Any from homeassistant.components.binary_sensor import ( @@ -45,14 +44,14 @@ class AmberPriceGridSensor( @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data["grid"][self.entity_description.key] + return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return] class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): """Sensor to show single grid binary values.""" @property - def icon(self): + def icon(self) -> str: """Return the sensor icon.""" status = self.coordinator.data["grid"]["price_spike"] return PRICE_SPIKE_ICONS[status] @@ -60,10 +59,10 @@ class AmberPriceSpikeBinarySensor(AmberPriceGridSensor): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data["grid"]["price_spike"] == "spike" + return self.coordinator.data["grid"]["price_spike"] == "spike" # type: ignore[no-any-return] @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return additional pieces of information about the price spike.""" spike_status = self.coordinator.data["grid"]["price_spike"] @@ -80,10 +79,10 @@ async def async_setup_entry( """Set up a config entry.""" coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities: list = [] price_spike_description = BinarySensorEntityDescription( key="price_spike", name=f"{entry.title} - Price Spike", ) - entities.append(AmberPriceSpikeBinarySensor(coordinator, price_spike_description)) - async_add_entities(entities) + async_add_entities( + [AmberPriceSpikeBinarySensor(coordinator, price_spike_description)] + ) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index 0258fdf4cb4..4011f442ee2 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -28,10 +28,10 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _fetch_sites(self, token: str) -> list[Site] | None: configuration = amberelectric.Configuration(access_token=token) - api = amber_api.AmberApi.create(configuration) + api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) try: - sites = api.get_sites() + sites: list[Site] = api.get_sites() if len(sites) == 0: self._errors[CONF_API_TOKEN] = "no_site" return None diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 75cf3fd4360..3e420be2f68 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -30,19 +30,19 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) - def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the general channel.""" - return interval.channel_type == ChannelType.GENERAL + return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return] def is_controlled_load( interval: ActualInterval | CurrentInterval | ForecastInterval, ) -> bool: """Return true if the supplied interval is on the controlled load channel.""" - return interval.channel_type == ChannelType.CONTROLLED_LOAD + return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return] def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool: """Return true if the supplied interval is on the feed in channel.""" - return interval.channel_type == ChannelType.FEED_IN + return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return] def normalize_descriptor(descriptor: Descriptor) -> str | None: diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 4a6d1a6ea18..97ecc103661 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -7,7 +7,6 @@ from __future__ import annotations -from collections.abc import Mapping from typing import Any from amberelectric.model.channel import ChannelType @@ -86,7 +85,7 @@ class AmberPriceSensor(AmberSensor): return format_cents_to_dollars(interval.per_kwh) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional pieces of information about the price.""" interval = self.coordinator.data[self.entity_description.key][self.channel_type] @@ -133,7 +132,7 @@ class AmberForecastSensor(AmberSensor): return format_cents_to_dollars(interval.per_kwh) @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional pieces of information about the price.""" intervals = self.coordinator.data[self.entity_description.key].get( self.channel_type @@ -177,7 +176,7 @@ class AmberPriceDescriptorSensor(AmberSensor): @property def native_value(self) -> str | None: """Return the current price descriptor.""" - return self.coordinator.data[self.entity_description.key][self.channel_type] + return self.coordinator.data[self.entity_description.key][self.channel_type] # type: ignore[no-any-return] class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): @@ -199,7 +198,7 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): @property def native_value(self) -> str | None: """Return the value of the sensor.""" - return self.coordinator.data["grid"][self.entity_description.key] + return self.coordinator.data["grid"][self.entity_description.key] # type: ignore[no-any-return] async def async_setup_entry( @@ -213,7 +212,7 @@ async def async_setup_entry( current: dict[str, CurrentInterval] = coordinator.data["current"] forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] - entities: list = [] + entities: list[SensorEntity] = [] for channel_type in current: description = SensorEntityDescription( key="current", diff --git a/mypy.ini b/mypy.ini index 958396707fc..c0e87ebb14b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -370,6 +370,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.amberelectric.*] +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.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true From 8501b2e71b245bfd9a2628170502c4af2ff503a2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:45:16 +0100 Subject: [PATCH 0119/1544] Enable strict typing for asterisk_cdr + asterisk_mbox (#106841) --- .strict-typing | 2 ++ .../components/asterisk_cdr/mailbox.py | 13 +++---- .../components/asterisk_mbox/__init__.py | 34 +++++++++++++------ .../components/asterisk_mbox/mailbox.py | 2 +- mypy.ini | 20 +++++++++++ 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index 5c65e947b91..8fc41dea4ed 100644 --- a/.strict-typing +++ b/.strict-typing @@ -81,6 +81,8 @@ homeassistant.components.aranet.* homeassistant.components.aruba.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* +homeassistant.components.asterisk_cdr.* +homeassistant.components.asterisk_mbox.* homeassistant.components.asuswrt.* homeassistant.components.auth.* homeassistant.components.automation.* diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index a6c246831af..971b893ef6b 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime import hashlib +from typing import Any from homeassistant.components.asterisk_mbox import ( DOMAIN as ASTERISK_DOMAIN, @@ -28,21 +29,21 @@ async def async_get_handler( class AsteriskCDR(Mailbox): """Asterisk VM Call Data Record mailbox.""" - def __init__(self, hass, name): + def __init__(self, hass: HomeAssistant, name: str) -> None: """Initialize Asterisk CDR.""" super().__init__(hass, name) - self.cdr = [] + self.cdr: list[dict[str, Any]] = [] async_dispatcher_connect(self.hass, SIGNAL_CDR_UPDATE, self._update_callback) @callback - def _update_callback(self, msg): + def _update_callback(self, msg: list[dict[str, Any]]) -> Any: """Update the message count in HA, if needed.""" self._build_message() self.async_update() - def _build_message(self): + def _build_message(self) -> None: """Build message structure.""" - cdr = [] + cdr: list[dict[str, Any]] = [] for entry in self.hass.data[ASTERISK_DOMAIN].cdr: timestamp = datetime.datetime.strptime( entry["time"], "%Y-%m-%d %H:%M:%S" @@ -61,7 +62,7 @@ class AsteriskCDR(Mailbox): cdr.append({"info": info, "sha": sha, "text": msg}) self.cdr = cdr - async def async_get_messages(self): + async def async_get_messages(self) -> list[dict[str, Any]]: """Return a list of the current messages.""" if not self.cdr: self._build_message() diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py index 607daad5b54..e4c80a5848d 100644 --- a/homeassistant/components/asterisk_mbox/__init__.py +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -1,5 +1,6 @@ """Support for Asterisk Voicemail interface.""" import logging +from typing import Any, cast from asterisk_mbox import Client as asteriskClient from asterisk_mbox.commands import ( @@ -42,11 +43,11 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up for the Asterisk Voicemail box.""" - conf = config[DOMAIN] + conf: dict[str, Any] = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - password = conf[CONF_PASSWORD] + host: str = conf[CONF_HOST] + port: int = conf[CONF_PORT] + password: str = conf[CONF_PASSWORD] hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) @@ -56,13 +57,20 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class AsteriskData: """Store Asterisk mailbox data.""" - def __init__(self, hass, host, port, password, config): + def __init__( + self, + hass: HomeAssistant, + host: str, + port: int, + password: str, + config: dict[str, Any], + ) -> None: """Init the Asterisk data object.""" self.hass = hass self.config = config - self.messages = None - self.cdr = None + self.messages: list[dict[str, Any]] | None = None + self.cdr: list[dict[str, Any]] | None = None dispatcher_connect(self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages) dispatcher_connect(self.hass, SIGNAL_CDR_REQUEST, self._request_cdr) @@ -71,7 +79,7 @@ class AsteriskData: self.client = asteriskClient(host, port, password, self.handle_data) @callback - def _discover_platform(self, component): + def _discover_platform(self, component: str) -> None: _LOGGER.debug("Adding mailbox %s", component) self.hass.async_create_task( discovery.async_load_platform( @@ -80,10 +88,13 @@ class AsteriskData: ) @callback - def handle_data(self, command, msg): + def handle_data( + self, command: int, msg: list[dict[str, Any]] | dict[str, Any] + ) -> None: """Handle changes to the mailbox.""" if command == CMD_MESSAGE_LIST: + msg = cast(list[dict[str, Any]], msg) _LOGGER.debug("AsteriskVM sent updated message list: Len %d", len(msg)) old_messages = self.messages self.messages = sorted( @@ -93,6 +104,7 @@ class AsteriskData: async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM, DOMAIN) async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, self.messages) elif command == CMD_MESSAGE_CDR: + msg = cast(dict[str, Any], msg) _LOGGER.debug( "AsteriskVM sent updated CDR list: Len %d", len(msg.get("entries", [])) ) @@ -112,13 +124,13 @@ class AsteriskData: ) @callback - def _request_messages(self): + def _request_messages(self) -> None: """Handle changes to the mailbox.""" _LOGGER.debug("Requesting message list") self.client.messages() @callback - def _request_cdr(self): + def _request_cdr(self) -> None: """Handle changes to the CDR.""" _LOGGER.debug("Requesting CDR list") self.client.get_cdr() diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py index edf95cb3787..95b3b7e3b15 100644 --- a/homeassistant/components/asterisk_mbox/mailbox.py +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -74,7 +74,7 @@ class AsteriskMailbox(Mailbox): async def async_get_messages(self) -> list[dict[str, Any]]: """Return a list of the current messages.""" data: AsteriskData = self.hass.data[ASTERISK_DOMAIN] - return data.messages + return data.messages or [] async def async_delete(self, msgid: str) -> bool: """Delete the specified messages.""" diff --git a/mypy.ini b/mypy.ini index c0e87ebb14b..70949e36ef6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -570,6 +570,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.asterisk_cdr.*] +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.asterisk_mbox.*] +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.asuswrt.*] check_untyped_defs = true disallow_incomplete_defs = true From 33f8a364aba4416fc529b192b57b3303c8237023 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 20:47:37 +0100 Subject: [PATCH 0120/1544] Enable strict typing for arris_tg2492lg (#106838) --- .strict-typing | 1 + .../components/arris_tg2492lg/device_tracker.py | 12 ++++++------ mypy.ini | 10 ++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8fc41dea4ed..765f429e5e3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -78,6 +78,7 @@ homeassistant.components.aprs.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* homeassistant.components.aranet.* +homeassistant.components.arris_tg2492lg.* homeassistant.components.aruba.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 48b8d9f13c4..bb917af5c39 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -40,13 +40,13 @@ class ArrisDeviceScanner(DeviceScanner): self.connect_box = connect_box self.last_results: list[Device] = [] - def scan_devices(self): + def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [device.mac for device in self.last_results] + return [device.mac for device in self.last_results if device.mac] - def get_device_name(self, device): + def get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" name = next( (result.hostname for result in self.last_results if result.mac == device), @@ -54,12 +54,12 @@ class ArrisDeviceScanner(DeviceScanner): ) return name - def _update_info(self): + def _update_info(self) -> None: """Ensure the information from the Arris TG2492LG router is up to date.""" result = self.connect_box.get_connected_devices() - last_results = [] - mac_addresses = set() + last_results: list[Device] = [] + mac_addresses: set[str | None] = set() for device in result: if device.online and device.mac not in mac_addresses: diff --git a/mypy.ini b/mypy.ini index 70949e36ef6..1c8158066ce 100644 --- a/mypy.ini +++ b/mypy.ini @@ -540,6 +540,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.arris_tg2492lg.*] +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.aruba.*] check_untyped_defs = true disallow_incomplete_defs = true From 73ccd0d310f4e33b9ac252b97c537ce3139e3358 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 21:12:29 +0100 Subject: [PATCH 0121/1544] Enable strict typing for arcam_fmj (#106837) --- .strict-typing | 1 + homeassistant/components/arcam_fmj/media_player.py | 12 +++++++++--- mypy.ini | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 765f429e5e3..8638cacbc22 100644 --- a/.strict-typing +++ b/.strict-typing @@ -78,6 +78,7 @@ homeassistant.components.aprs.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* homeassistant.components.aranet.* +homeassistant.components.arcam_fmj.* homeassistant.components.arris_tg2492lg.* homeassistant.components.aruba.* homeassistant.components.aseko_pool_live.* diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 12114ec04b8..7c4ec280101 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,9 +1,10 @@ """Arcam media player.""" from __future__ import annotations +from collections.abc import Callable, Coroutine import functools import logging -from typing import Any +from typing import Any, ParamSpec, TypeVar from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State @@ -34,6 +35,9 @@ from .const import ( SIGNAL_CLIENT_STOPPED, ) +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) @@ -59,11 +63,13 @@ async def async_setup_entry( ) -def convert_exception(func): +def convert_exception( + func: Callable[_P, Coroutine[Any, Any, _R]], +) -> Callable[_P, Coroutine[Any, Any, _R]]: """Return decorator to convert a connection error into a home assistant error.""" @functools.wraps(func) - async def _convert_exception(*args, **kwargs): + async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R: try: return await func(*args, **kwargs) except ConnectionFailed as exception: diff --git a/mypy.ini b/mypy.ini index 1c8158066ce..e79f005a6a8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -540,6 +540,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.arcam_fmj.*] +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.arris_tg2492lg.*] check_untyped_defs = true disallow_incomplete_defs = true From aec8dc13b2b5f6f936766b72df8a5642c9fe9e73 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 1 Jan 2024 22:16:22 +0100 Subject: [PATCH 0122/1544] Improve acmeda typing (#106812) --- .strict-typing | 1 + homeassistant/components/acmeda/base.py | 4 +-- homeassistant/components/acmeda/cover.py | 4 +-- homeassistant/components/acmeda/helpers.py | 8 ++++-- homeassistant/components/acmeda/hub.py | 31 +++++++++++----------- homeassistant/components/acmeda/sensor.py | 4 +-- mypy.ini | 10 +++++++ 7 files changed, 39 insertions(+), 23 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8638cacbc22..9f23dce88fa 100644 --- a/.strict-typing +++ b/.strict-typing @@ -42,6 +42,7 @@ homeassistant.components homeassistant.components.abode.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* +homeassistant.components.acmeda.* homeassistant.components.actiontec.* homeassistant.components.adax.* homeassistant.components.adguard.* diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 9ad01ba6f29..5d1f643418a 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -66,12 +66,12 @@ class AcmedaBase(entity.Entity): @property def unique_id(self) -> str: """Return the unique ID of this roller.""" - return self.roller.id + return self.roller.id # type: ignore[no-any-return] @property def device_id(self) -> str: """Return the ID of this roller.""" - return self.roller.id + return self.roller.id # type: ignore[no-any-return] @property def device_info(self) -> dr.DeviceInfo: diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 2af985033b6..32b6cf31ee5 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -30,7 +30,7 @@ async def async_setup_entry( current: set[int] = set() @callback - def async_add_acmeda_covers(): + def async_add_acmeda_covers() -> None: async_add_acmeda_entities( hass, AcmedaCover, config_entry, current, async_add_entities ) @@ -95,7 +95,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): @property def is_closed(self) -> bool: """Return if the cover is closed.""" - return self.roller.closed_percent == 100 + return self.roller.closed_percent == 100 # type: ignore[no-any-return] async def async_close_cover(self, **kwargs: Any) -> None: """Close the roller.""" diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index ff8f28ffbc3..a87cbcd1635 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -1,6 +1,8 @@ """Helper functions for Acmeda Pulse.""" from __future__ import annotations +from aiopulse import Roller + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -16,7 +18,7 @@ def async_add_acmeda_entities( config_entry: ConfigEntry, current: set[int], async_add_entities: AddEntitiesCallback, -): +) -> None: """Add any new entities.""" hub = hass.data[DOMAIN][config_entry.entry_id] LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) @@ -34,7 +36,9 @@ def async_add_acmeda_entities( async_add_entities(new_items) -async def update_devices(hass: HomeAssistant, config_entry: ConfigEntry, api): +async def update_devices( + hass: HomeAssistant, config_entry: ConfigEntry, api: dict[int, Roller] +) -> None: """Tell hass that device info has been updated.""" dev_registry = dr.async_get(hass) diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index e156ee5cb78..9c6ef6156f0 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -2,9 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import aiopulse +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER @@ -14,31 +17,29 @@ from .helpers import update_devices class PulseHub: """Manages a single Pulse Hub.""" - def __init__(self, hass, config_entry): + api: aiopulse.Hub + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" self.config_entry = config_entry self.hass = hass - self.api: aiopulse.Hub | None = None - self.tasks = [] - self.current_rollers = {} - self.cleanup_callbacks = [] + self.tasks: list[asyncio.Task[None]] = [] + self.current_rollers: dict[int, aiopulse.Roller] = {} + self.cleanup_callbacks: list[Callable[[], None]] = [] @property - def title(self): + def title(self) -> str: """Return the title of the hub shown in the integrations list.""" return f"{self.api.id} ({self.api.host})" @property - def host(self): + def host(self) -> str: """Return the host of this hub.""" - return self.config_entry.data["host"] + return self.config_entry.data["host"] # type: ignore[no-any-return] - async def async_setup(self, tries=0): + async def async_setup(self, tries: int = 0) -> bool: """Set up a hub based on host parameter.""" - host = self.host - - hub = aiopulse.Hub(host) - self.api = hub + self.api = hub = aiopulse.Hub(self.host) hub.callback_subscribe(self.async_notify_update) self.tasks.append(asyncio.create_task(hub.run())) @@ -46,7 +47,7 @@ class PulseHub: LOGGER.debug("Hub setup complete") return True - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this hub to default state.""" for cleanup_callback in self.cleanup_callbacks: @@ -66,7 +67,7 @@ class PulseHub: return True - async def async_notify_update(self, update_type): + async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None: """Evaluate entities when hub reports that update has occurred.""" LOGGER.debug("Hub {update_type.name} updated") diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index e8ccb30ada4..20d0929f341 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( current: set[int] = set() @callback - def async_add_acmeda_sensors(): + def async_add_acmeda_sensors() -> None: async_add_acmeda_entities( hass, AcmedaBattery, config_entry, current, async_add_entities ) @@ -48,4 +48,4 @@ class AcmedaBattery(AcmedaBase, SensorEntity): @property def native_value(self) -> float | int | None: """Return the state of the device.""" - return self.roller.battery + return self.roller.battery # type: ignore[no-any-return] diff --git a/mypy.ini b/mypy.ini index e79f005a6a8..01fcef8ac07 100644 --- a/mypy.ini +++ b/mypy.ini @@ -180,6 +180,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.acmeda.*] +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.actiontec.*] check_untyped_defs = true disallow_incomplete_defs = true From 25f09134b2005ae31d9cfafe48accb9f83181a2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jan 2024 12:16:17 -1000 Subject: [PATCH 0123/1544] Bump bleak-retry-connector to 3.4.0 (#106831) --- 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 19199e4b1c6..c5dec12fe40 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.3.0", + "bleak-retry-connector==3.4.0", "bluetooth-adapters==0.16.2", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 723ec15d046..4e521e6b95d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 bcrypt==4.0.1 -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 bleak==0.21.1 bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 40534c589c1..b61bbc1cb5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,7 +538,7 @@ bizkaibus==0.1.1 bleak-esphome==0.4.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53ae03081bd..761d917df4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -457,7 +457,7 @@ bimmer-connected[china]==0.14.6 bleak-esphome==0.4.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 From b074b239798b6bd3a63c4f3dc8d6f26b8ae69cab Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 1 Jan 2024 23:45:31 +0100 Subject: [PATCH 0124/1544] Bump pyduotecno to 2024.1.1 (#106801) * Bump pyduotecno to 2024.0.1 * Bump pyduotecno to 2024.1.0 * small update --- homeassistant/components/duotecno/climate.py | 2 +- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 8e23e742c04..dc10e0a61d9 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -52,7 +52,7 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_translation_key = "duotecno" @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Get the current temperature.""" return self._unit.get_cur_temp() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 2f221929178..9f6d082cae8 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.11.1"] + "requirements": ["pyDuotecno==2024.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b61bbc1cb5d..ff5a37190c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1599,7 +1599,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 761d917df4e..3b0aa91cbd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1231,7 +1231,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 From 370345ce2bfc598b5bd7968c5095391e89e9e98d Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Mon, 1 Jan 2024 19:58:12 -0800 Subject: [PATCH 0125/1544] Map missing preset mapping for heat mode "ready" in smarttub (#106856) --- homeassistant/components/smarttub/climate.py | 2 ++ tests/components/smarttub/test_climate.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index b2d4fbf17c4..9f1802e7327 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -23,11 +23,13 @@ from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLL from .entity import SmartTubEntity PRESET_DAY = "day" +PRESET_READY = "ready" PRESET_MODES = { Spa.HeatMode.AUTO: PRESET_NONE, Spa.HeatMode.ECONOMY: PRESET_ECO, Spa.HeatMode.DAY: PRESET_DAY, + Spa.HeatMode.READY: PRESET_READY, } HEAT_MODES = {v: k for k, v in PRESET_MODES.items()} diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index 601015ca681..40e3c05b509 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -58,7 +58,7 @@ async def test_thermostat_update( assert state.attributes[ATTR_TEMPERATURE] == 39 assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day"] + assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day", "ready"] await hass.services.async_call( CLIMATE_DOMAIN, From cc18b9a2d8b7075d4003e95614f6994d4f60c7e6 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 1 Jan 2024 23:00:17 -0500 Subject: [PATCH 0126/1544] Constrain dacite to at least 1.7.0 (#105709) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4e521e6b95d..7ba253f8dc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -189,3 +189,7 @@ charset-normalizer==3.2.0 # lxml 5.0.0 currently does not build on alpine 3.18 # https://bugs.launchpad.net/lxml/+bug/2047718 lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101c9294706..3cecff68fb0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -183,6 +183,10 @@ charset-normalizer==3.2.0 # lxml 5.0.0 currently does not build on alpine 3.18 # https://bugs.launchpad.net/lxml/+bug/2047718 lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 """ GENERATED_MESSAGE = ( From d93d25a7d1abd295f5fdf92e6ff89e96bb79c185 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 05:09:22 +0100 Subject: [PATCH 0127/1544] Enable strict typing for ambiclimate (#106819) --- .strict-typing | 1 + homeassistant/components/ambiclimate/climate.py | 5 +++-- homeassistant/components/ambiclimate/config_flow.py | 8 ++++---- mypy.ini | 10 ++++++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9f23dce88fa..6bc8942486b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -63,6 +63,7 @@ homeassistant.components.alexa.* homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* +homeassistant.components.ambiclimate.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 2762c3948a7..fc192d8658f 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -6,6 +6,7 @@ import logging from typing import Any import ambiclimate +from ambiclimate import AmbiclimateDevice import voluptuous as vol from homeassistant.components.climate import ( @@ -157,13 +158,13 @@ class AmbiclimateEntity(ClimateEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, heater, store): + def __init__(self, heater: AmbiclimateDevice, store: Store[dict[str, Any]]) -> None: """Initialize the thermostat.""" self._heater = heater self._store = store self._attr_unique_id = heater.device_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + identifiers={(DOMAIN, self.unique_id)}, # type: ignore[arg-type] manufacturer="Ambiclimate", name=heater.name, ) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 3d05ab2bb07..383a11055e4 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -114,7 +114,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY) await store.async_save(token_info) - return token_info + return token_info # type: ignore[no-any-return] def _generate_view(self) -> None: self.hass.http.register_view(AmbiclimateAuthCallbackView()) @@ -132,12 +132,12 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): clientsession, ) - def _cb_url(self): + def _cb_url(self) -> str: return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}" - async def _get_authorize_url(self): + async def _get_authorize_url(self) -> str: oauth = self._generate_oauth() - return oauth.get_authorize_url() + return oauth.get_authorize_url() # type: ignore[no-any-return] class AmbiclimateAuthCallbackView(HomeAssistantView): diff --git a/mypy.ini b/mypy.ini index 01fcef8ac07..173b2dbd838 100644 --- a/mypy.ini +++ b/mypy.ini @@ -390,6 +390,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambiclimate.*] +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.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true From 391123beb06feef3a6ec8bd8fac5671efcfd5f2e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Jan 2024 05:15:34 +0100 Subject: [PATCH 0128/1544] Update frontend to 20240101.0 (#106808) --- 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 227fa96edf7..02a311a42ce 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==20231228.0"] + "requirements": ["home-assistant-frontend==20240101.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7ba253f8dc9..3b687b79aa4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ff5a37190c0..465b34f9d49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b0aa91cbd6..5e078ac888a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -828,7 +828,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231228.0 +home-assistant-frontend==20240101.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From a4f0c844573c2837f5dbd030abc6df75e2e04a33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 1 Jan 2024 20:25:23 -1000 Subject: [PATCH 0129/1544] Reduce duplicate code in json_loads (#106859) --- homeassistant/util/json.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 1af35c604eb..83ddd373992 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -39,9 +39,10 @@ def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: This adds a workaround for orjson not handling subclasses of str, https://github.com/ijl/orjson/issues/445. """ - if type(__obj) in (bytes, bytearray, memoryview, str): - return orjson.loads(__obj) # type:ignore[no-any-return] - if isinstance(__obj, str): + # Avoid isinstance overhead for the common case + if type(__obj) not in (bytes, bytearray, memoryview, str) and isinstance( + __obj, str + ): return orjson.loads(str(__obj)) # type:ignore[no-any-return] return orjson.loads(__obj) # type:ignore[no-any-return] From 7396bc61d7f69bb5da50bc2a0d68bae89bfd766a Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 2 Jan 2024 03:55:06 -0500 Subject: [PATCH 0130/1544] Set entity category and device class for Netgear LTE entities (#106661) * Set entity category and device class for Netgear * add suggested unit of measure and precision --- .../components/netgear_lte/binary_sensor.py | 5 + .../components/netgear_lte/sensor.py | 66 ++++++- .../snapshots/test_binary_sensor.ambr | 39 ++++ .../netgear_lte/snapshots/test_sensor.ambr | 175 ++++++++++++++++++ .../netgear_lte/test_binary_sensor.py | 34 ++-- tests/components/netgear_lte/test_sensor.py | 71 +++---- 6 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 tests/components/netgear_lte/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/netgear_lte/snapshots/test_sensor.ambr diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index ccabcc3b3ea..2830c551b80 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,15 +18,19 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="roaming", translation_key="roaming", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="wire_connected", translation_key="wire_connected", + entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), BinarySensorEntityDescription( key="mobile_connected", translation_key="mobile_connected", + entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 49702c1ce41..4e978a2f964 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, UnitOfInformation, ) from homeassistant.core import HomeAssistant @@ -35,12 +36,14 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( NetgearLTESensorEntityDescription( key="sms", translation_key="sms", + icon="mdi:message-processing", native_unit_of_measurement="unread", value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), ), NetgearLTESensorEntityDescription( key="sms_total", translation_key="sms_total", + icon="mdi:message-processing", native_unit_of_measurement="messages", value_fn=lambda modem_data: len(modem_data.data.sms), ), @@ -48,39 +51,84 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( key="usage", translation_key="usage", device_class=SensorDeviceClass.DATA_SIZE, - native_unit_of_measurement=UnitOfInformation.MEBIBYTES, - value_fn=lambda modem_data: round(modem_data.data.usage / 1024**2, 1), + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=1, + value_fn=lambda modem_data: modem_data.data.usage, ), NetgearLTESensorEntityDescription( key="radio_quality", translation_key="radio_quality", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, ), NetgearLTESensorEntityDescription( key="rx_level", translation_key="rx_level", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), NetgearLTESensorEntityDescription( key="tx_level", translation_key="tx_level", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), - NetgearLTESensorEntityDescription(key="upstream", translation_key="upstream"), NetgearLTESensorEntityDescription( - key="connection_text", translation_key="connection_text" + key="upstream", + translation_key="upstream", + entity_registry_enabled_default=False, + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, ), NetgearLTESensorEntityDescription( - key="connection_type", translation_key="connection_type" + key="connection_text", + translation_key="connection_text", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, ), NetgearLTESensorEntityDescription( - key="current_ps_service_type", translation_key="service_type" + key="connection_type", + translation_key="connection_type", + entity_registry_enabled_default=False, + icon="mdi:ip", + entity_category=EntityCategory.DIAGNOSTIC, ), NetgearLTESensorEntityDescription( - key="register_network_display", translation_key="register_network_display" + key="current_ps_service_type", + translation_key="service_type", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="register_network_display", + translation_key="register_network_display", + entity_registry_enabled_default=False, + icon="mdi:web", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="current_band", + translation_key="band", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NetgearLTESensorEntityDescription( + key="cell_id", + translation_key="cell_id", + entity_registry_enabled_default=False, + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, ), - NetgearLTESensorEntityDescription(key="current_band", translation_key="band"), - NetgearLTESensorEntityDescription(key="cell_id", translation_key="cell_id"), ) diff --git a/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr b/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..6f3950aaabe --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_binary_sensor.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.netgear_lm1200_mobile_connected] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Netgear LM1200 Mobile connected', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_mobile_connected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.netgear_lm1200_roaming] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Roaming', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_roaming', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.netgear_lm1200_wire_connected] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Netgear LM1200 Wire connected', + }), + 'context': , + 'entity_id': 'binary_sensor.netgear_lm1200_wire_connected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/netgear_lte/snapshots/test_sensor.ambr b/tests/components/netgear_lte/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8d16ff29dfa --- /dev/null +++ b/tests/components/netgear_lte/snapshots/test_sensor.ambr @@ -0,0 +1,175 @@ +# serializer version: 1 +# name: test_sensors[sensor.netgear_lm1200_cell_id] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Cell ID', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_cell_id', + 'last_changed': , + 'last_updated': , + 'state': '12345678', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_connection_text] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Connection text', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_connection_text', + 'last_changed': , + 'last_updated': , + 'state': '4G', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_connection_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Connection type', + 'icon': 'mdi:ip', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_connection_type', + 'last_changed': , + 'last_updated': , + 'state': 'IPv4AndIPv6', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_current_band] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Current band', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_current_band', + 'last_changed': , + 'last_updated': , + 'state': 'LTE B4', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_radio_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Radio quality', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_radio_quality', + 'last_changed': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_register_network_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Register network display', + 'icon': 'mdi:web', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_register_network_display', + 'last_changed': , + 'last_updated': , + 'state': 'T-Mobile', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_rx_level] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Netgear LM1200 Rx level', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_rx_level', + 'last_changed': , + 'last_updated': , + 'state': '-113', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_service_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Service type', + 'icon': 'mdi:radio-tower', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_service_type', + 'last_changed': , + 'last_updated': , + 'state': 'LTE', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_sms] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 SMS', + 'icon': 'mdi:message-processing', + 'unit_of_measurement': 'unread', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_sms', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_sms_total] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 SMS total', + 'icon': 'mdi:message-processing', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_sms_total', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_tx_level] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Netgear LM1200 Tx level', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_tx_level', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_upstream] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Netgear LM1200 Upstream', + 'icon': 'mdi:ip-network', + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_upstream', + 'last_changed': , + 'last_updated': , + 'state': 'LTE', + }) +# --- +# name: test_sensors[sensor.netgear_lm1200_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Netgear LM1200 Usage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.netgear_lm1200_usage', + 'last_changed': , + 'last_updated': , + 'state': '40.5162000656128', + }) +# --- diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py index 9d45194aa69..660b7dd4fdf 100644 --- a/tests/components/netgear_lte/test_binary_sensor.py +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -1,19 +1,27 @@ """The tests for Netgear LTE binary sensor platform.""" -import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.netgear_lte.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") -async def test_binary_sensors(hass: HomeAssistant) -> None: +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + setup_integration: None, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test for successfully setting up the Netgear LTE binary sensor platform.""" - state = hass.states.get("binary_sensor.netgear_lm1200_mobile_connected") - assert state.state == STATE_ON - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lm1200_wire_connected") - assert state.state == STATE_OFF - assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY - state = hass.states.get("binary_sensor.netgear_lm1200_roaming") - assert state.state == STATE_OFF + entry = hass.config_entries.async_entries(DOMAIN)[0] + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + assert entity_entries + for entity_entry in entity_entries: + if entity_entry.domain != BINARY_SENSOR_DOMAIN: + continue + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py index cdd7fbbd38e..37f6538fe6a 100644 --- a/tests/components/netgear_lte/test_sensor.py +++ b/tests/components/netgear_lte/test_sensor.py @@ -1,56 +1,27 @@ """The tests for Netgear LTE sensor platform.""" -import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfInformation, -) +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + setup_integration: None, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test for successfully setting up the Netgear LTE sensor platform.""" - state = hass.states.get("sensor.netgear_lm1200_cell_id") - assert state.state == "12345678" - state = hass.states.get("sensor.netgear_lm1200_connection_text") - assert state.state == "4G" - state = hass.states.get("sensor.netgear_lm1200_connection_type") - assert state.state == "IPv4AndIPv6" - state = hass.states.get("sensor.netgear_lm1200_current_band") - assert state.state == "LTE B4" - state = hass.states.get("sensor.netgear_lm1200_service_type") - assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lm1200_radio_quality") - assert state.state == "52" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - state = hass.states.get("sensor.netgear_lm1200_register_network_display") - assert state.state == "T-Mobile" - state = hass.states.get("sensor.netgear_lm1200_rx_level") - assert state.state == "-113" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - ) - state = hass.states.get("sensor.netgear_lm1200_sms") - assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "unread" - state = hass.states.get("sensor.netgear_lm1200_sms_total") - assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "messages" - state = hass.states.get("sensor.netgear_lm1200_tx_level") - assert state.state == "4" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == SIGNAL_STRENGTH_DECIBELS_MILLIWATT - ) - state = hass.states.get("sensor.netgear_lm1200_upstream") - assert state.state == "LTE" - state = hass.states.get("sensor.netgear_lm1200_usage") - assert state.state == "40.5" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.MEBIBYTES - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + entry = hass.config_entries.async_entries(DOMAIN)[0] + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + assert entity_entries + for entity_entry in entity_entries: + if entity_entry.domain != SENSOR_DOMAIN: + continue + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) From b27e830997379c7f41043f82a5808bee8f98781f Mon Sep 17 00:00:00 2001 From: Benjamin Richter Date: Tue, 2 Jan 2024 09:59:13 +0100 Subject: [PATCH 0131/1544] Fix fints account type check (#106082) --- homeassistant/components/fints/sensor.py | 8 +- requirements_test_all.txt | 3 + tests/components/fints/__init__.py | 1 + tests/components/fints/test_client.py | 95 ++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/components/fints/__init__.py create mode 100644 tests/components/fints/test_client.py diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index fafe1fcf2bf..c969adfe637 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,8 +168,8 @@ class FinTsClient: if not account_information: return False - if 1 <= account_information["type"] <= 9: - return True + if account_type := account_information.get("type"): + return 1 <= account_type <= 9 if ( account_information["iban"] in self.account_config @@ -188,8 +188,8 @@ class FinTsClient: if not account_information: return False - if 30 <= account_information["type"] <= 39: - return True + if account_type := account_information.get("type"): + return 30 <= account_type <= 39 if ( account_information["iban"] in self.holdings_config diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e078ac888a..5ea49bdacf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -659,6 +659,9 @@ feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fints +fints==3.1.0 + # homeassistant.components.fitbit fitbit==0.3.1 diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py new file mode 100644 index 00000000000..6a2b1d96d20 --- /dev/null +++ b/tests/components/fints/__init__.py @@ -0,0 +1 @@ +"""Tests for FinTS component.""" diff --git a/tests/components/fints/test_client.py b/tests/components/fints/test_client.py new file mode 100644 index 00000000000..429d391b07e --- /dev/null +++ b/tests/components/fints/test_client.py @@ -0,0 +1,95 @@ +"""Tests for the FinTS client.""" + +from typing import Optional + +from fints.client import BankIdentifier, FinTSOperations +import pytest + +from homeassistant.components.fints.sensor import ( + BankCredentials, + FinTsClient, + SEPAAccount, +) + +BANK_INFORMATION = { + "bank_identifier": BankIdentifier(country_identifier="280", bank_code="50010517"), + "currency": "EUR", + "customer_id": "0815", + "owner_name": ["SURNAME, FIRSTNAME"], + "subaccount_number": None, + "supported_operations": { + FinTSOperations.GET_BALANCE: True, + FinTSOperations.GET_CREDIT_CARD_TRANSACTIONS: False, + FinTSOperations.GET_HOLDINGS: False, + FinTSOperations.GET_SCHEDULED_DEBITS_MULTIPLE: False, + FinTSOperations.GET_SCHEDULED_DEBITS_SINGLE: False, + FinTSOperations.GET_SEPA_ACCOUNTS: True, + FinTSOperations.GET_STATEMENT: False, + FinTSOperations.GET_STATEMENT_PDF: False, + FinTSOperations.GET_TRANSACTIONS: True, + FinTSOperations.GET_TRANSACTIONS_XML: False, + }, +} + + +@pytest.mark.parametrize( + ( + "account_number", + "iban", + "product_name", + "account_type", + "expected_balance_result", + "expected_holdings_result", + ), + [ + ("GIRO1", "GIRO1", "Valid balance account", 5, True, False), + (None, None, "Invalid account", None, False, False), + ("GIRO2", "GIRO2", "Account without type", None, False, False), + ("GIRO3", "GIRO3", "Balance account from fallback", None, True, False), + ("DEPOT1", "DEPOT1", "Valid holdings account", 33, False, True), + ("DEPOT2", "DEPOT2", "Holdings account from fallback", None, False, True), + ], +) +async def test_account_type( + account_number: Optional[str], + iban: Optional[str], + product_name: str, + account_type: Optional[int], + expected_balance_result: bool, + expected_holdings_result: bool, +) -> None: + """Check client methods is_balance_account and is_holdings_account.""" + credentials = BankCredentials( + blz=1234, login="test", pin="0000", url="https://example.com" + ) + account_config = {"GIRO3": True} + holdings_config = {"DEPOT2": True} + + client = FinTsClient( + credentials=credentials, + name="test", + account_config=account_config, + holdings_config=holdings_config, + ) + + client._account_information_fetched = True + client._account_information = { + iban: BANK_INFORMATION + | { + "account_number": account_number, + "iban": iban, + "product_name": product_name, + "type": account_type, + } + } + + sepa_account = SEPAAccount( + iban=iban, + bic="BANCODELTEST", + accountnumber=account_number, + subaccount=None, + blz="12345", + ) + + assert client.is_balance_account(sepa_account) == expected_balance_result + assert client.is_holdings_account(sepa_account) == expected_holdings_result From 97a5f0b2afeb29ac2b08afee19c3ef863e11d9ee Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 2 Jan 2024 10:34:02 +0100 Subject: [PATCH 0132/1544] Add diagnostics for tedee (#106662) * add diagnostics * don't redact lock name * Update test_diagnostics.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tedee/diagnostics.py | 28 ++++++++++++++++++ .../tedee/snapshots/test_diagnostics.ambr | 29 +++++++++++++++++++ tests/components/tedee/test_diagnostics.py | 21 ++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 homeassistant/components/tedee/diagnostics.py create mode 100644 tests/components/tedee/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tedee/test_diagnostics.py diff --git a/homeassistant/components/tedee/diagnostics.py b/homeassistant/components/tedee/diagnostics.py new file mode 100644 index 00000000000..d17c4c335bc --- /dev/null +++ b/homeassistant/components/tedee/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for tedee.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TedeeApiCoordinator + +TO_REDACT = { + "lock_id", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TedeeApiCoordinator = hass.data[DOMAIN][entry.entry_id] + # dict has sensitive info as key, redact manually + data = { + index: lock.to_dict() + for index, (_, lock) in enumerate(coordinator.tedee_client.locks_dict.items()) + } + return async_redact_data(data, TO_REDACT) diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..401c519c215 --- /dev/null +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '0': dict({ + 'battery_level': 70, + 'duration_pullspring': 2, + 'is_charging': False, + 'is_connected': True, + 'is_enabled_pullspring': 1, + 'lock_id': '**REDACTED**', + 'lock_name': 'Lock-1A2B', + 'lock_type': 2, + 'state': 2, + 'state_change_result': 0, + }), + '1': dict({ + 'battery_level': 70, + 'duration_pullspring': 0, + 'is_charging': False, + 'is_connected': True, + 'is_enabled_pullspring': 0, + 'lock_id': '**REDACTED**', + 'lock_name': 'Lock-2C3D', + 'lock_type': 4, + 'state': 2, + 'state_change_result': 0, + }), + }) +# --- diff --git a/tests/components/tedee/test_diagnostics.py b/tests/components/tedee/test_diagnostics.py new file mode 100644 index 00000000000..9a31e153b6c --- /dev/null +++ b/tests/components/tedee/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the diagnostics data provided by the Tedee integration.""" +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 599271fdc04e4381b5180ab6fbcc5fa70a8e93f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 11:35:16 +0100 Subject: [PATCH 0133/1544] Don't use entity_id in __repr__ of not added entity (#106861) --- homeassistant/helpers/entity.py | 2 +- tests/helpers/test_entity.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b7ed7e3c095..3c3c8474e67 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1481,7 +1481,7 @@ class Entity( If the entity is not added to a platform it's not safe to call _stringify_state. """ if self._platform_state != EntityPlatformState.ADDED: - return f"" + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a74ef166907..ef23687a166 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1407,7 +1407,7 @@ async def test_repr(hass) -> None: my_entity = MyEntity(entity_id="test.test", available=False) # Not yet added - assert str(my_entity) == "" + assert str(my_entity) == "" # Added await platform.async_add_entities([my_entity]) @@ -1415,7 +1415,7 @@ async def test_repr(hass) -> None: # Removed await platform.async_remove_entity(my_entity.entity_id) - assert str(my_entity) == "" + assert str(my_entity) == "" async def test_warn_using_async_update_ha_state( From e40faf957e18ce15d81485b591d9f7803316e648 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:37:18 -1000 Subject: [PATCH 0134/1544] Bump bleak-esphome to 0.4.1 (#106832) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4a1301ccf29..e3437e5aa73 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "aioesphomeapi==21.0.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==0.4.0" + "bleak-esphome==0.4.1" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 465b34f9d49..ebcae6c970e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -535,7 +535,7 @@ bimmer-connected[china]==0.14.6 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==0.4.0 +bleak-esphome==0.4.1 # homeassistant.components.bluetooth bleak-retry-connector==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ea49bdacf4..ba4d19c444d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ bellows==0.37.6 bimmer-connected[china]==0.14.6 # homeassistant.components.esphome -bleak-esphome==0.4.0 +bleak-esphome==0.4.1 # homeassistant.components.bluetooth bleak-retry-connector==3.4.0 From d89683f980bd9312cff976704db28b723becbdec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:37:51 -1000 Subject: [PATCH 0135/1544] Fix incorrect state in Yale Access Bluetooth when lock status is unknown (#106851) --- homeassistant/components/yalexs_ble/lock.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index d457784a038..f6fa1917d7e 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -40,17 +40,19 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): self._attr_is_unlocking = False self._attr_is_jammed = False lock_state = new_state.lock - if lock_state == LockStatus.LOCKED: + if lock_state is LockStatus.LOCKED: self._attr_is_locked = True - elif lock_state == LockStatus.LOCKING: + elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True - elif lock_state == LockStatus.UNLOCKING: + elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, ): self._attr_is_jammed = True + elif lock_state is LockStatus.UNKNOWN: + self._attr_is_locked = None super()._async_update_state(new_state, lock_info, connection_info) async def async_unlock(self, **kwargs: Any) -> None: From 8903aecb77e9afe218eba539d5f42ad381aae013 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 11:38:20 +0100 Subject: [PATCH 0136/1544] Enable strict typing for airthings (#106814) --- .strict-typing | 1 + homeassistant/components/airthings/__init__.py | 8 +++++--- homeassistant/components/airthings/sensor.py | 16 ++++++++-------- mypy.ini | 10 ++++++++++ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.strict-typing b/.strict-typing index 6bc8942486b..b00fd677f3c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -51,6 +51,7 @@ homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airnow.* homeassistant.components.airq.* +homeassistant.components.airthings.* homeassistant.components.airthings_ble.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index d596c1db757..a5b962d1bf7 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from airthings import Airthings, AirthingsError +from airthings import Airthings, AirthingsDevice, AirthingsError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform @@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) +AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airthings from a config entry.""" @@ -30,10 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), ) - async def _update_method(): + async def _update_method() -> dict[str, AirthingsDevice]: """Get the latest data from Airthings.""" try: - return await airthings.update_devices() + return await airthings.update_devices() # type: ignore[no-any-return] except AirthingsError as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index cd4e9d52f6b..3802a735a99 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -24,11 +24,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirthingsDataCoordinatorType from .const import DOMAIN SENSORS: dict[str, SensorEntityDescription] = { @@ -108,7 +106,7 @@ async def async_setup_entry( ) -> None: """Set up the Airthings sensor.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AirthingsDataCoordinatorType = hass.data[DOMAIN][entry.entry_id] entities = [ AirthingsHeaterEnergySensor( coordinator, @@ -122,7 +120,9 @@ async def async_setup_entry( async_add_entities(entities) -class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): +class AirthingsHeaterEnergySensor( + CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity +): """Representation of a Airthings Sensor device.""" _attr_state_class = SensorStateClass.MEASUREMENT @@ -130,7 +130,7 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: AirthingsDataCoordinatorType, airthings_device: AirthingsDevice, entity_description: SensorEntityDescription, ) -> None: @@ -155,4 +155,4 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.coordinator.data[self._id].sensors[self.entity_description.key] + return self.coordinator.data[self._id].sensors[self.entity_description.key] # type: ignore[no-any-return] diff --git a/mypy.ini b/mypy.ini index 173b2dbd838..9ffe5e48c1f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -270,6 +270,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airthings.*] +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.airthings_ble.*] check_untyped_defs = true disallow_incomplete_defs = true From 21fc3203a696d6d68f883095054e46bfce8764db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:39:04 -1000 Subject: [PATCH 0137/1544] Bump pySwitchbot to 0.43.0 (#106833) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e835a2f4aca..d3d84d2cd48 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.1"] + "requirements": ["PySwitchbot==0.43.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ebcae6c970e..4c588a9027e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba4d19c444d..3a303a4cc47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 93a29ebf2f2ceb3b0c347b7d4f9c7d0faa4cc059 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 00:41:56 -1000 Subject: [PATCH 0138/1544] Bump yalexs-ble to 2.4.0 (#106834) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index aacebb4bb5c..d0f2a27522d 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==1.10.0", "yalexs-ble==2.3.2"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index be388ec563c..dcd7e57ce1f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.2"] + "requirements": ["yalexs-ble==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c588a9027e..6ba98b21171 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2833,7 +2833,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a303a4cc47..bba7d7906de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2144,7 +2144,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 From ef261842ac818ae1700070fbeabf70ee4ea4b079 Mon Sep 17 00:00:00 2001 From: vexofp Date: Tue, 2 Jan 2024 05:59:40 -0500 Subject: [PATCH 0139/1544] Pass default SSLContext instances to Octoprint custom HTTP sessions (#105351) --- homeassistant/components/octoprint/__init__.py | 5 ++++- homeassistant/components/octoprint/config_flow.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 5fd2182ca00..50ba6c964f3 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -26,6 +26,7 @@ from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN from .coordinator import OctoprintDataUpdateCoordinator @@ -159,7 +160,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connector = aiohttp.TCPConnector( force_close=True, - ssl=False if not entry.data[CONF_VERIFY_SSL] else None, + ssl=get_default_no_verify_context() + if not entry.data[CONF_VERIFY_SSL] + else get_default_context(), ) session = aiohttp.ClientSession(connector=connector) diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 09ac53ecf5b..696898400bf 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import DOMAIN @@ -264,7 +265,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): connector = aiohttp.TCPConnector( force_close=True, - ssl=False if not verify_ssl else None, + ssl=get_default_no_verify_context() + if not verify_ssl + else get_default_context(), ) session = aiohttp.ClientSession(connector=connector) self._sessions.append(session) From 1cbacd13aa0d7c2a40d0d4b7e34d46cfe30efb3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:33:02 -1000 Subject: [PATCH 0140/1544] Use identity checks for HassJobType (#106860) --- homeassistant/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index cb17bd55805..c8d01309767 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -604,13 +604,13 @@ class HomeAssistant: # if TYPE_CHECKING to avoid the overhead of constructing # the type used for the cast. For history see: # https://github.com/home-assistant/core/pull/71960 - if hassjob.job_type == HassJobType.Coroutinefunction: + if hassjob.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) - elif hassjob.job_type == HassJobType.Callback: + elif hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) self.loop.call_soon(hassjob.target, *args) @@ -709,7 +709,7 @@ class HomeAssistant: # if TYPE_CHECKING to avoid the overhead of constructing # the type used for the cast. For history see: # https://github.com/home-assistant/core/pull/71960 - if hassjob.job_type == HassJobType.Callback: + if hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) hassjob.target(*args) @@ -2215,11 +2215,11 @@ class ServiceRegistry: """Execute a service.""" job = handler.job target = job.target - if job.job_type == HassJobType.Coroutinefunction: + if job.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: target = cast(Callable[..., Coroutine[Any, Any, _R]], target) return await target(service_call) - if job.job_type == HassJobType.Callback: + if job.job_type is HassJobType.Callback: if TYPE_CHECKING: target = cast(Callable[..., _R], target) return target(service_call) From 6f339541c6ee1aedd985cc5b1ca7f9afe75100fc Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 2 Jan 2024 06:46:39 -0500 Subject: [PATCH 0141/1544] Fix Hydrawise data not refreshing (#105923) Co-authored-by: Robert Resch --- .../components/hydrawise/binary_sensor.py | 2 +- .../components/hydrawise/coordinator.py | 27 ++++++++++++++++--- homeassistant/components/hydrawise/entity.py | 3 +++ homeassistant/components/hydrawise/sensor.py | 2 +- homeassistant/components/hydrawise/switch.py | 2 +- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 65355a1829f..0b12fcb3ddb 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -70,7 +70,7 @@ async def async_setup_entry( config_entry.entry_id ] entities = [] - for controller in coordinator.data.controllers: + for controller in coordinator.data.controllers.values(): entities.append( HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) ) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 412108f859f..71922928651 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from pydrawise import HydrawiseBase -from pydrawise.schema import User +from pydrawise.schema import Controller, User, Zone from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -13,9 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): +@dataclass +class HydrawiseData: + """Container for data fetched from the Hydrawise API.""" + + user: User + controllers: dict[int, Controller] + zones: dict[int, Zone] + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """The Hydrawise Data Update Coordinator.""" + api: HydrawiseBase + def __init__( self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta ) -> None: @@ -23,6 +35,13 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) self.api = api - async def _async_update_data(self) -> User: + async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" - return await self.api.get_user() + user = await self.api.get_user() + controllers = {} + zones = {} + for controller in user.controllers: + controllers[controller.id] = controller + for zone in controller.zones: + zones[zone.id] = zone + return HydrawiseData(user=user, controllers=controllers, zones=zones) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c707690ce95..887de6ba648 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -48,5 +48,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + self.controller = self.coordinator.data.controllers[self.controller.id] + if self.zone: + self.zone = self.coordinator.data.zones[self.zone.id] self._update_attrs() super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 79a318f778f..f8490ad00e1 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -76,7 +76,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSensor(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SENSOR_TYPES ) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 5a3a3a62895..8a92a56975a 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -81,7 +81,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSwitch(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SWITCH_TYPES ) From 41646a65144768d565e350924effa5ed717c4dda Mon Sep 17 00:00:00 2001 From: Stanislas Date: Tue, 2 Jan 2024 12:47:16 +0100 Subject: [PATCH 0142/1544] Xiaomi MIIO: fix typo in error log (#106852) --- homeassistant/components/xiaomi_miio/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 2660a1b2be1..3e952c1ab3f 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -417,7 +417,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): async def async_set_fan_level(self, level: int = 1) -> bool: """Set the fan level.""" return await self._try_command( - "Setting the favorite level of the miio device failed.", + "Setting the fan level of the miio device failed.", self._device.set_fan_level, level, ) From bbdccede855f5dfdcab000d780b1b5fb174b9e9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:48:14 -1000 Subject: [PATCH 0143/1544] Refactor restore state saving to avoid a dict lookup of ATTR_RESTORED (#106854) --- homeassistant/helpers/restore_state.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 625bab8b218..0878114552f 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -178,8 +178,8 @@ class RestoreStateData: now = dt_util.utcnow() all_states = self.hass.states.async_all() # Entities currently backed by an entity object - current_entity_ids = { - state.entity_id + current_states_by_entity_id = { + state.entity_id: state for state in all_states if not state.attributes.get(ATTR_RESTORED) } @@ -187,13 +187,12 @@ class RestoreStateData: # Start with the currently registered states stored_states = [ StoredState( - state, self.entities[state.entity_id].extra_restore_state_data, now + current_states_by_entity_id[entity_id], + entity.extra_restore_state_data, + now, ) - for state in all_states - if state.entity_id in self.entities - and - # Ignore all states that are entity registry placeholders - not state.attributes.get(ATTR_RESTORED) + for entity_id, entity in self.entities.items() + if entity_id in current_states_by_entity_id ] expiration_time = now - STATE_EXPIRATION @@ -201,7 +200,7 @@ class RestoreStateData: # Don't save old states that have entities in the current run # They are either registered and already part of stored_states, # or no longer care about restoring. - if entity_id in current_entity_ids: + if entity_id in current_states_by_entity_id: continue # Don't save old states that have expired From 73bc65059ba6a62e9b4b06508df46b9c538fdbaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:48:34 -1000 Subject: [PATCH 0144/1544] Use shorthand attr for screenlogic climate preset modes (#106858) --- homeassistant/components/screenlogic/climate.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1e9a90395f4..7cdfbba10c0 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -94,6 +94,9 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) + self._attr_preset_modes = [ + HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes + ] self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] @@ -140,11 +143,6 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): return HEAT_MODE(self._last_preset).title return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title - @property - def preset_modes(self) -> list[str]: - """All available presets.""" - return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] - async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: From 3f1263a533bb9d2b3f566c4862b7f42271b33624 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:49:53 -1000 Subject: [PATCH 0145/1544] Refactor light platform to avoid duplicate property calls (#106857) --- homeassistant/components/light/__init__.py | 96 ++++++++++++---------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 77510b02035..6307b41f557 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -345,6 +345,9 @@ def filter_turn_off_params( light: LightEntity, params: dict[str, Any] ) -> dict[str, Any]: """Filter out params not used in turn off or not supported by the light.""" + if not params: + return params + supported_features = light.supported_features_compat if LightEntityFeature.FLASH not in supported_features: @@ -947,8 +950,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def color_temp_kelvin(self) -> int | None: """Return the CT color value in Kelvin.""" - if self._attr_color_temp_kelvin is None and self.color_temp: - return color_util.color_temperature_mired_to_kelvin(self.color_temp) + if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp): + return color_util.color_temperature_mired_to_kelvin(color_temp) return self._attr_color_temp_kelvin @cached_property @@ -993,19 +996,21 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: - data[ATTR_MIN_COLOR_TEMP_KELVIN] = self.min_color_temp_kelvin - data[ATTR_MAX_COLOR_TEMP_KELVIN] = self.max_color_temp_kelvin - if not self.max_color_temp_kelvin: + min_color_temp_kelvin = self.min_color_temp_kelvin + max_color_temp_kelvin = self.max_color_temp_kelvin + data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin + data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin + if not max_color_temp_kelvin: data[ATTR_MIN_MIREDS] = None else: data[ATTR_MIN_MIREDS] = color_util.color_temperature_kelvin_to_mired( - self.max_color_temp_kelvin + max_color_temp_kelvin ) - if not self.min_color_temp_kelvin: + if not min_color_temp_kelvin: data[ATTR_MAX_MIREDS] = None else: data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( - self.min_color_temp_kelvin + min_color_temp_kelvin ) if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list @@ -1018,30 +1023,27 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self, color_mode: ColorMode | str ) -> dict[str, tuple[float, ...]]: data: dict[str, tuple[float, ...]] = {} - if color_mode == ColorMode.HS and self.hs_color: - hs_color = self.hs_color + if color_mode == ColorMode.HS and (hs_color := self.hs_color): data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) - elif color_mode == ColorMode.XY and self.xy_color: - xy_color = self.xy_color + elif color_mode == ColorMode.XY and (xy_color := self.xy_color): data[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) data[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color) data[ATTR_XY_COLOR] = (round(xy_color[0], 6), round(xy_color[1], 6)) - elif color_mode == ColorMode.RGB and self.rgb_color: - rgb_color = self.rgb_color + elif color_mode == ColorMode.RGB and (rgb_color := self.rgb_color): data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.RGBW and self._light_internal_rgbw_color: - rgbw_color = self._light_internal_rgbw_color + elif color_mode == ColorMode.RGBW and ( + rgbw_color := self._light_internal_rgbw_color + ): rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) data[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBW_COLOR] = tuple(int(x) for x in rgbw_color[0:4]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.RGBWW and self.rgbww_color: - rgbww_color = self.rgbww_color + elif color_mode == ColorMode.RGBWW and (rgbww_color := self.rgbww_color): rgb_color = color_util.color_rgbww_to_rgb( *rgbww_color, self.min_color_temp_kelvin, self.max_color_temp_kelvin ) @@ -1049,8 +1051,10 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_RGB_COLOR] = tuple(int(x) for x in rgb_color[0:3]) data[ATTR_RGBWW_COLOR] = tuple(int(x) for x in rgbww_color[0:5]) data[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) - elif color_mode == ColorMode.COLOR_TEMP and self.color_temp_kelvin: - hs_color = color_util.color_temperature_to_hs(self.color_temp_kelvin) + elif color_mode == ColorMode.COLOR_TEMP and ( + color_temp_kelvin := self.color_temp_kelvin + ): + hs_color = color_util.color_temperature_to_hs(color_temp_kelvin) data[ATTR_HS_COLOR] = (round(hs_color[0], 3), round(hs_color[1], 3)) data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) @@ -1062,22 +1066,26 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return state attributes.""" data: dict[str, Any] = {} supported_features = self.supported_features_compat - supported_color_modes = self._light_internal_supported_color_modes + supported_color_modes = self.supported_color_modes + legacy_supported_color_modes = ( + supported_color_modes or self._light_internal_supported_color_modes + ) supported_features_value = supported_features.value - color_mode = self._light_internal_color_mode if self.is_on else None + _is_on = self.is_on + color_mode = self._light_internal_color_mode if _is_on else None - if color_mode and color_mode not in supported_color_modes: + if color_mode and color_mode not in legacy_supported_color_modes: # Increase severity to warning in 2021.6, reject in 2021.10 _LOGGER.debug( "%s: set to unsupported color_mode: %s, supported_color_modes: %s", self.entity_id, color_mode, - supported_color_modes, + legacy_supported_color_modes, ) data[ATTR_COLOR_MODE] = color_mode - if brightness_supported(self.supported_color_modes): + if brightness_supported(supported_color_modes): if color_mode in COLOR_MODES_BRIGHTNESS: data[ATTR_BRIGHTNESS] = self.brightness else: @@ -1085,20 +1093,19 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 - if self.is_on: + if _is_on: data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - if color_temp_supported(self.supported_color_modes): + if color_temp_supported(supported_color_modes): if color_mode == ColorMode.COLOR_TEMP: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if self.color_temp_kelvin: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: data[ ATTR_COLOR_TEMP - ] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + ] = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) else: data[ATTR_COLOR_TEMP] = None else: @@ -1107,43 +1114,42 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 - if self.is_on: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if self.color_temp_kelvin: + if _is_on: + color_temp_kelvin = self.color_temp_kelvin + data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin + if color_temp_kelvin: data[ ATTR_COLOR_TEMP - ] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + ] = color_util.color_temperature_kelvin_to_mired(color_temp_kelvin) else: data[ATTR_COLOR_TEMP] = None else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - if color_supported(supported_color_modes) or color_temp_supported( - supported_color_modes + if color_supported(legacy_supported_color_modes) or color_temp_supported( + legacy_supported_color_modes ): data[ATTR_HS_COLOR] = None data[ATTR_RGB_COLOR] = None data[ATTR_XY_COLOR] = None - if ColorMode.RGBW in supported_color_modes: + if ColorMode.RGBW in legacy_supported_color_modes: data[ATTR_RGBW_COLOR] = None - if ColorMode.RGBWW in supported_color_modes: + if ColorMode.RGBWW in legacy_supported_color_modes: data[ATTR_RGBWW_COLOR] = None if color_mode: data.update(self._light_internal_convert_color(color_mode)) if LightEntityFeature.EFFECT in supported_features: - data[ATTR_EFFECT] = self.effect if self.is_on else None + data[ATTR_EFFECT] = self.effect if _is_on else None return data @property def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: """Calculate supported color modes with backwards compatibility.""" - if self.supported_color_modes is not None: - return self.supported_color_modes + if (_supported_color_modes := self.supported_color_modes) is not None: + return _supported_color_modes # Backwards compatibility for supported_color_modes added in 2021.4 # Add warning in 2021.6, remove in 2021.10 From 038e55a2cbe39608caf8fc0ecb91c62d60539b00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 01:51:05 -1000 Subject: [PATCH 0146/1544] Fix emulated_hue brightness check (#106783) --- .../components/emulated_hue/hue_api.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 05e5c1ece07..0730eced60c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from functools import lru_cache import hashlib from http import HTTPStatus @@ -41,6 +42,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + ColorMode, LightEntityFeature, ) from homeassistant.components.media_player import ( @@ -115,12 +117,19 @@ UNAUTHORIZED_USER = [ {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} ] -DIMMABLE_SUPPORT_FEATURES = ( - CoverEntityFeature.SET_POSITION - | FanEntityFeature.SET_SPEED - | MediaPlayerEntityFeature.VOLUME_SET - | ClimateEntityFeature.TARGET_TEMPERATURE -) +DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + fan.DOMAIN: FanEntityFeature.SET_SPEED, + media_player.DOMAIN: MediaPlayerEntityFeature.VOLUME_SET, + climate.DOMAIN: ClimateEntityFeature.TARGET_TEMPERATURE, +} + +ENTITY_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature, + fan.DOMAIN: FanEntityFeature, + media_player.DOMAIN: MediaPlayerEntityFeature, + climate.DOMAIN: ClimateEntityFeature, +} @lru_cache(maxsize=32) @@ -756,7 +765,6 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -773,9 +781,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "manufacturername": "Home Assistant", "swversion": "123", } - - color_supported = light.color_supported(color_modes) - color_temp_supported = light.color_temp_supported(color_modes) + is_light = state.domain == light.DOMAIN + color_supported = is_light and light.color_supported(color_modes) + color_temp_supported = is_light and light.color_temp_supported(color_modes) if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature @@ -820,9 +828,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) - elif entity_features & DIMMABLE_SUPPORT_FEATURES or light.brightness_supported( - color_modes - ): + elif state_supports_hue_brightness(state, color_modes): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" @@ -845,6 +851,21 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: return retval +def state_supports_hue_brightness( + state: State, color_modes: Iterable[ColorMode] +) -> bool: + """Return True if the state supports brightness.""" + domain = state.domain + if domain == light.DOMAIN: + return light.brightness_supported(color_modes) + if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): + return False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + enum = ENTITY_FEATURES_BY_DOMAIN[domain] + features = enum(features) if type(features) is int else features # noqa: E721 + return required_feature in features + + def create_hue_success_response( entity_number: str, attr: str, value: str ) -> dict[str, Any]: From e3b09a5470b676cbf874abf2347a928a1ad1da0c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 2 Jan 2024 12:53:03 +0100 Subject: [PATCH 0147/1544] Migrate vizio tests to use freezegun (#105417) --- tests/components/vizio/test_media_player.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 660de3ff6b6..142c5f74b84 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -6,6 +6,7 @@ from datetime import timedelta from typing import Any from unittest.mock import call, patch +from freezegun import freeze_time import pytest from pyvizio.api.apps import AppConfig from pyvizio.const import ( @@ -472,7 +473,7 @@ async def _test_update_availability_switch( future_interval = timedelta(minutes=1) # Setup device as if time is right now - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): await _test_setup_speaker(hass, initial_power_state) # Clear captured logs so that only availability state changes are captured for @@ -485,9 +486,7 @@ async def _test_update_availability_switch( with patch( "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=final_power_state, - ), patch("homeassistant.util.dt.utcnow", return_value=future), patch( - "homeassistant.util.utcnow", return_value=future - ): + ), freeze_time(future): async_fire_time_changed(hass, future) await hass.async_block_till_done() if final_power_state is None: From bdaf269ba36d9fa348709c49474e26f8f14d8077 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 2 Jan 2024 12:53:36 +0100 Subject: [PATCH 0148/1544] Migrate geo_rss_events test to use freezegun (#105895) --- tests/components/geo_rss_events/test_sensor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index 02225df3755..c86ef393875 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -1,6 +1,7 @@ """The test for the geo rss events sensor platform.""" from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor @@ -56,7 +57,9 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant, mock_feed) -> None: +async def test_setup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_feed +) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -68,10 +71,8 @@ async def test_setup(hass: HomeAssistant, mock_feed) -> None: mock_feed.return_value.update.return_value = "OK", [mock_entry_1, mock_entry_2] utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch( - "homeassistant.util.dt.utcnow", return_value=utcnow - ), assert_setup_component(1, sensor.DOMAIN): + freezer.move_to(utcnow) + with assert_setup_component(1, sensor.DOMAIN): assert await async_setup_component(hass, sensor.DOMAIN, VALID_CONFIG) # Artificially trigger update. hass.bus.fire(EVENT_HOMEASSISTANT_START) From 4747460286fb8d3b9d8c31e2ecd84b0dca3577b6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:07:47 +0100 Subject: [PATCH 0149/1544] Enable strict typing for arwn (#106840) --- .strict-typing | 1 + homeassistant/components/arwn/sensor.py | 61 +++++++++++++++---------- mypy.ini | 10 ++++ 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/.strict-typing b/.strict-typing index b00fd677f3c..ff0ecd1c3ae 100644 --- a/.strict-typing +++ b/.strict-typing @@ -84,6 +84,7 @@ homeassistant.components.aranet.* homeassistant.components.arcam_fmj.* homeassistant.components.arris_tg2492lg.* homeassistant.components.aruba.* +homeassistant.components.arwn.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* homeassistant.components.asterisk_cdr.* diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index d468a93eca0..caf7dc6f45e 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components import mqtt from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -20,7 +21,7 @@ DATA_ARWN = "arwn" TOPIC = "arwn/#" -def discover_sensors(topic, payload): +def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None: """Given a topic, dynamically create the right sensor type. Async friendly. @@ -34,22 +35,26 @@ def discover_sensors(topic, payload): unit = UnitOfTemperature.FAHRENHEIT else: unit = UnitOfTemperature.CELSIUS - return ArwnSensor( - topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE - ) + return [ + ArwnSensor( + topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE + ) + ] if domain == "moisture": name = f"{parts[2]} Moisture" - return ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent") + return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")] if domain == "rain": if len(parts) >= 3 and parts[2] == "today": - return ArwnSensor( - topic, - "Rain Since Midnight", - "since_midnight", - UnitOfPrecipitationDepth.INCHES, - device_class=SensorDeviceClass.PRECIPITATION, - ) - return ( + return [ + ArwnSensor( + topic, + "Rain Since Midnight", + "since_midnight", + UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + ) + ] + return [ ArwnSensor( topic + "/total", "Total Rainfall", @@ -64,11 +69,13 @@ def discover_sensors(topic, payload): unit, device_class=SensorDeviceClass.PRECIPITATION, ), - ) + ] if domain == "barometer": - return ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines") + return [ + ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines") + ] if domain == "wind": - return ( + return [ ArwnSensor( topic + "/speed", "Wind Speed", @@ -86,10 +93,11 @@ def discover_sensors(topic, payload): ArwnSensor( topic + "/dir", "Wind Direction", "direction", DEGREE, "mdi:compass" ), - ) + ] + return None -def _slug(name): +def _slug(name: str) -> str: return f"sensor.arwn_{slugify(name)}" @@ -128,9 +136,6 @@ async def async_setup_platform( if (store := hass.data.get(DATA_ARWN)) is None: store = hass.data[DATA_ARWN] = {} - if isinstance(sensors, ArwnSensor): - sensors = (sensors,) - if "timestamp" in event: del event["timestamp"] @@ -159,7 +164,15 @@ class ArwnSensor(SensorEntity): _attr_should_poll = False - def __init__(self, topic, name, state_key, units, icon=None, device_class=None): + def __init__( + self, + topic: str, + name: str, + state_key: str, + units: str, + icon: str | None = None, + device_class: SensorDeviceClass | None = None, + ) -> None: """Initialize the sensor.""" self.entity_id = _slug(name) self._attr_name = name @@ -170,9 +183,9 @@ class ArwnSensor(SensorEntity): self._attr_icon = icon self._attr_device_class = device_class - def set_event(self, event): + def set_event(self, event: dict[str, Any]) -> None: """Update the sensor with the most recent event.""" - ev = {} + ev: dict[str, Any] = {} ev.update(event) self._attr_extra_state_attributes = ev self._attr_native_value = ev.get(self._state_key, None) diff --git a/mypy.ini b/mypy.ini index 9ffe5e48c1f..f1c2cfa2cf3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -600,6 +600,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.arwn.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true From 0d7bb2d1242db2925ecfdce1ce3171c628fa7069 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 2 Jan 2024 13:11:19 +0100 Subject: [PATCH 0150/1544] Improve entity descriptions in Tami4 (#106776) * Improve entity descriptions in Tami4 * Improve entity descriptions in Tami4 * Improve entity descriptions in Tami4 * Improve entity descriptions in Tami4 * Improve entity descriptions in Tami4 --- homeassistant/components/tami4/button.py | 38 ++++++++++++++++-------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py index 30ff4824e18..c17a296e219 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -1,12 +1,13 @@ """Button entities for Tami4Edge.""" +from collections.abc import Callable +from dataclasses import dataclass import logging from Tami4EdgeAPI import Tami4EdgeAPI -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 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 API, DOMAIN @@ -14,10 +15,21 @@ from .entity import Tami4EdgeBaseEntity _LOGGER = logging.getLogger(__name__) -ENTITY_DESCRIPTION = EntityDescription( - key="boil_water", - translation_key="boil_water", - icon="mdi:kettle-steam", + +@dataclass(frozen=True, kw_only=True) +class Tami4EdgeButtonEntityDescription(ButtonEntityDescription): + """A class that describes Tami4Edge button entities.""" + + press_fn: Callable[[Tami4EdgeAPI], None] + + +BUTTONS: tuple[Tami4EdgeButtonEntityDescription] = ( + Tami4EdgeButtonEntityDescription( + key="boil_water", + translation_key="boil_water", + icon="mdi:kettle-steam", + press_fn=lambda api: api.boil_water(), + ), ) @@ -27,16 +39,16 @@ async def async_setup_entry( """Perform the setup for Tami4Edge.""" api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] - async_add_entities([Tami4EdgeBoilButton(api)]) + async_add_entities( + Tami4EdgeButton(api, entity_description) for entity_description in BUTTONS + ) -class Tami4EdgeBoilButton(Tami4EdgeBaseEntity, ButtonEntity): - """Boil button entity for Tami4Edge.""" +class Tami4EdgeButton(Tami4EdgeBaseEntity, ButtonEntity): + """Button entity for Tami4Edge.""" - def __init__(self, api: Tami4EdgeAPI) -> None: - """Initialize the button entity.""" - super().__init__(api, ENTITY_DESCRIPTION) + entity_description: Tami4EdgeButtonEntityDescription def press(self) -> None: """Handle the button press.""" - self._api.boil_water() + self.entity_description.press_fn(self._api) From 2df9e5e7b93b00d00d1aff0c1b7313863d031ac8 Mon Sep 17 00:00:00 2001 From: Robert Groot <8398505+iamrgroot@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:24:17 +0100 Subject: [PATCH 0151/1544] Changed setup of EnergyZero services (#106224) * Changed setup of energyzero services * PR review updates * Dict access instead of get Co-authored-by: Martin Hjelmare * Added tests for unloaded state --------- Co-authored-by: Martin Hjelmare --- .../components/energyzero/__init__.py | 15 +++- .../components/energyzero/services.py | 49 +++++++++-- .../components/energyzero/services.yaml | 10 +++ .../components/energyzero/strings.json | 14 ++++ tests/components/energyzero/test_services.py | 82 +++++++++++++++++-- 5 files changed, 155 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 0eac874f1ed..8878a99e562 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -5,12 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator -from .services import async_register_services +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up EnergyZero services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -27,8 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_register_services(hass, coordinator) - return True diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index fb451c40401..d8e548c22f8 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -9,6 +9,7 @@ from typing import Final from energyzero import Electricity, Gas, VatOption import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,11 +18,13 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator +ATTR_CONFIG_ENTRY: Final = "config_entry" ATTR_START: Final = "start" ATTR_END: Final = "end" ATTR_INCL_VAT: Final = "incl_vat" @@ -30,6 +33,11 @@ GAS_SERVICE_NAME: Final = "get_gas_prices" ENERGY_SERVICE_NAME: Final = "get_energy_prices" SERVICE_SCHEMA: Final = vol.Schema( { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), vol.Required(ATTR_INCL_VAT): bool, vol.Optional(ATTR_START): str, vol.Optional(ATTR_END): str, @@ -75,12 +83,43 @@ def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: } +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EnergyZeroDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + async def __get_prices( call: ServiceCall, *, - coordinator: EnergyZeroDataUpdateCoordinator, + hass: HomeAssistant, price_type: PriceType, ) -> ServiceResponse: + coordinator = __get_coordinator(hass, call) + start = __get_date(call.data.get(ATTR_START)) end = __get_date(call.data.get(ATTR_END)) @@ -108,22 +147,20 @@ async def __get_prices( @callback -def async_register_services( - hass: HomeAssistant, coordinator: EnergyZeroDataUpdateCoordinator -): +def async_setup_services(hass: HomeAssistant) -> None: """Set up EnergyZero services.""" hass.services.async_register( DOMAIN, GAS_SERVICE_NAME, - partial(__get_prices, coordinator=coordinator, price_type=PriceType.GAS), + partial(__get_prices, hass=hass, price_type=PriceType.GAS), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, ENERGY_SERVICE_NAME, - partial(__get_prices, coordinator=coordinator, price_type=PriceType.ENERGY), + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml index 1bcc5ae34be..dc8df9aa6d0 100644 --- a/homeassistant/components/energyzero/services.yaml +++ b/homeassistant/components/energyzero/services.yaml @@ -1,5 +1,10 @@ get_gas_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero incl_vat: required: true default: true @@ -17,6 +22,11 @@ get_gas_prices: datetime: get_energy_prices: fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero incl_vat: required: true default: true diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 81f54f4222a..9858838aff7 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -12,6 +12,12 @@ "exceptions": { "invalid_date": { "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." } }, "entity": { @@ -50,6 +56,10 @@ "name": "Get gas prices", "description": "Request gas prices from EnergyZero.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "incl_vat": { "name": "Including VAT", "description": "Include VAT in the prices." @@ -68,6 +78,10 @@ "name": "Get energy prices", "description": "Request energy prices from EnergyZero.", "fields": { + "config_entry": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::description%]" + }, "incl_vat": { "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]", "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]" diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index 7939b06ce8e..c0b54729e03 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -6,12 +6,15 @@ import voluptuous as vol from homeassistant.components.energyzero.const import DOMAIN from homeassistant.components.energyzero.services import ( + ATTR_CONFIG_ENTRY, ENERGY_SERVICE_NAME, GAS_SERVICE_NAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from tests.common import MockConfigEntry + @pytest.mark.usefixtures("init_integration") async def test_has_services( @@ -29,6 +32,7 @@ async def test_has_services( @pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) async def test_service( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, service: str, incl_vat: dict[str, bool], @@ -36,8 +40,9 @@ async def test_service( end: dict[str, str], ) -> None: """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} - data = incl_vat | start | end + data = entry | incl_vat | start | end assert snapshot == await hass.services.async_call( DOMAIN, @@ -48,32 +53,72 @@ async def test_service( ) +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) @pytest.mark.parametrize( - ("service_data", "error", "error_message"), + ("config_entry_data", "service_data", "error", "error_message"), [ - ({}, vol.er.Error, "required key not provided .+"), + ({}, {}, vol.er.Error, "required key not provided .+"), ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, {"incl_vat": "incorrect vat"}, vol.er.Error, "expected bool for dictionary value .+", ), ( - {"incl_vat": True, "start": "incorrect date"}, + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ( - {"incl_vat": True, "end": "incorrect date"}, + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, ServiceValidationError, "Invalid datetime provided.", ), ], + indirect=["config_entry_data"], ) async def test_service_validation( hass: HomeAssistant, service: str, + config_entry_data: dict[str, str], service_data: dict[str, str], error: type[Exception], error_message: str, @@ -84,7 +129,32 @@ async def test_service_validation( await hass.services.async_call( DOMAIN, service, - service_data, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test service calls with unloaded config entry.""" + + await mock_config_entry.async_unload(hass) + + data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True} + + with pytest.raises( + ServiceValidationError, match=f"{mock_config_entry.title} is not loaded" + ): + await hass.services.async_call( + DOMAIN, + service, + data, blocking=True, return_response=True, ) From f0132a6b885f477af39335218703012de5aa17cf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:28:15 +0100 Subject: [PATCH 0152/1544] Add reauthentication for tedee integration (#106667) * start work * add reauth * title * reuse user step for reauth * Update strings.json * simplify flow * remove inline if * remove await hass block --- homeassistant/components/tedee/config_flow.py | 47 ++++++++-- homeassistant/components/tedee/coordinator.py | 4 +- homeassistant/components/tedee/strings.json | 13 ++- tests/components/tedee/test_config_flow.py | 86 ++++++++++++++++++- 4 files changed, 137 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index e31bcd91693..47a35089e66 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Tedee integration.""" +from collections.abc import Mapping from typing import Any from pytedee_async import ( @@ -9,16 +10,18 @@ from pytedee_async import ( ) import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME -class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tedee.""" + reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -26,7 +29,10 @@ class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] + if self.reauth_entry: + host = self.reauth_entry.data[CONF_HOST] + else: + host = user_input[CONF_HOST] local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN] tedee_client = TedeeClient(local_token=local_access_token, local_ip=host) try: @@ -35,8 +41,16 @@ class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key" except TedeeClientException: errors[CONF_HOST] = "invalid_host" - else: + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={**self.reauth_entry.data, **user_input}, + ) + await self.hass.config_entries.async_reload( + self.context["entry_id"] + ) + return self.async_abort(reason="reauth_successful") await self.async_set_unique_id(local_bridge.serial) self._abort_if_unique_id_configured() return self.async_create_entry(title=NAME, data=user_input) @@ -45,9 +59,30 @@ class TedeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_LOCAL_ACCESS_TOKEN): str, + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + ): str, } ), errors=errors, ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + default=entry_data[CONF_LOCAL_ACCESS_TOKEN], + ): str, + } + ), + ) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 13e26541557..18fca035532 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -15,7 +15,7 @@ from pytedee_async import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN @@ -74,7 +74,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): try: await update_fn() except TedeeLocalAuthException as ex: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( "Authentication failed. Local access token is invalid" ) from ex diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index db6a450c1f3..1f0a5f0dc7e 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -11,10 +11,21 @@ "host": "The IP address of the bridge you want to connect to.", "local_access_token": "You can find it in the tedee app under \"Bridge Settings\" -> \"Local API\"." } + }, + "reauth_confirm": { + "title": "Update of access key required", + "description": "Tedee needs an updated access key, because the existing one is invalid, or might have expired.", + "data": { + "local_access_token": "[%key:component::tedee::config::step::user::data::local_access_token%]" + }, + "data_description": { + "local_access_token": "[%key:component::tedee::config::step::user::data_description::local_access_token%]" + } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 73132d3bd78..4feb9bb8ca5 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock from pytedee_async import TedeeClientException, TedeeLocalAuthException import pytest -from homeassistant import config_entries from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,7 +19,7 @@ LOCAL_ACCESS_TOKEN = "api_token" async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: """Test config flow with one bridge.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM @@ -48,7 +48,7 @@ async def test_flow_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM @@ -82,7 +82,7 @@ async def test_config_flow_errors( ) -> None: """Test the config flow errors.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -100,3 +100,81 @@ async def test_config_flow_errors( assert result2["type"] == FlowResultType.FORM assert result2["errors"] == error assert len(mock_tedee.get_local_bridge.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Test that the reauth flow works.""" + + mock_config_entry.add_to_hass(hass) + + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data={ + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + CONF_HOST: "192.168.1.42", + }, + ) + + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), + ( + TedeeLocalAuthException("boom."), + {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, + ), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + side_effect: Exception, + error: dict[str, str], +) -> None: + """Test that the reauth flow errors.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data={ + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + CONF_HOST: "192.168.1.42", + }, + ) + + mock_tedee.get_local_bridge.side_effect = side_effect + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + assert len(mock_tedee.get_local_bridge.mock_calls) == 1 From d2a03a470613d5a0220a38c7fd0feaa65d89d990 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 2 Jan 2024 13:28:55 +0100 Subject: [PATCH 0153/1544] Avoid unnecessary domain dataclass in Discovergy (#106869) --- .../components/discovergy/__init__.py | 39 ++++++------------- .../components/discovergy/diagnostics.py | 14 +++---- homeassistant/components/discovergy/sensor.py | 19 +++++---- 3 files changed, 27 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index f21a03ef748..786f589bf7b 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -1,12 +1,9 @@ """The Discovergy integration.""" from __future__ import annotations -from dataclasses import dataclass - from pydiscovergy import Discovergy from pydiscovergy.authentication import BasicAuth import pydiscovergy.error as discovergyError -from pydiscovergy.models import Meter from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform @@ -20,35 +17,21 @@ from .coordinator import DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] -@dataclass -class DiscovergyData: - """Discovergy data class to share meters and api client.""" - - api_client: Discovergy - meters: list[Meter] - coordinators: dict[str, DiscovergyUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Discovergy from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # init discovergy data class - discovergy_data = DiscovergyData( - api_client=Discovergy( - email=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - httpx_client=get_async_client(hass), - authentication=BasicAuth(), - ), - meters=[], - coordinators={}, + client = Discovergy( + email=entry.data[CONF_EMAIL], + password=entry.data[CONF_PASSWORD], + httpx_client=get_async_client(hass), + authentication=BasicAuth(), ) try: # try to get meters from api to check if credentials are still valid and for later use # if no exception is raised everything is fine to go - discovergy_data.meters = await discovergy_data.api_client.meters() + meters = await client.meters() except discovergyError.InvalidLogin as err: raise ConfigEntryAuthFailed("Invalid email or password") from err except Exception as err: @@ -57,19 +40,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err # Init coordinators for meters - for meter in discovergy_data.meters: + coordinators = [] + for meter in meters: # Create coordinator for meter, set config entry and fetch initial data, # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, meter=meter, - discovergy_client=discovergy_data.api_client, + discovergy_client=client, ) await coordinator.async_config_entry_first_refresh() + coordinators.append(coordinator) - discovergy_data.coordinators[meter.meter_id] = coordinator - - hass.data[DOMAIN][entry.entry_id] = discovergy_data + hass.data[DOMAIN][entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index 75c6f97c701..99d559e94bc 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -8,12 +8,11 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import DiscovergyData from .const import DOMAIN +from .coordinator import DiscovergyUpdateCoordinator TO_REDACT_METER = { "serial_number", - "full_serial_number", "location", "full_serial_number", "printed_full_serial_number", @@ -27,15 +26,16 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" flattened_meter: list[dict] = [] last_readings: dict[str, dict] = {} - data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] - for meter in data.meters: + for coordinator in coordinators: # make a dict of meter data and redact some data - flattened_meter.append(async_redact_data(asdict(meter), TO_REDACT_METER)) + flattened_meter.append( + async_redact_data(asdict(coordinator.meter), TO_REDACT_METER) + ) # get last reading for meter and make a dict of it - coordinator = data.coordinators[meter.meter_id] - last_readings[meter.meter_id] = asdict(coordinator.data) + last_readings[coordinator.meter.meter_id] = asdict(coordinator.data) return { "meters": flattened_meter, diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index df16551fff2..00513db484b 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime -from pydiscovergy.models import Meter, Reading +from pydiscovergy.models import Reading from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,8 +24,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DiscovergyData, DiscovergyUpdateCoordinator from .const import DOMAIN, MANUFACTURER +from .coordinator import DiscovergyUpdateCoordinator def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | None: @@ -165,21 +165,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Discovergy sensors.""" - data: DiscovergyData = hass.data[DOMAIN][entry.entry_id] + coordinators: list[DiscovergyUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id] entities: list[DiscovergySensor] = [] - for meter in data.meters: + for coordinator in coordinators: sensors: tuple[DiscovergySensorEntityDescription, ...] = () - coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] # select sensor descriptions based on meter type and combine with additional sensors - if meter.measurement_type == "ELECTRICITY": + if coordinator.meter.measurement_type == "ELECTRICITY": sensors = ELECTRICITY_SENSORS + ADDITIONAL_SENSORS - elif meter.measurement_type == "GAS": + elif coordinator.meter.measurement_type == "GAS": sensors = GAS_SENSORS + ADDITIONAL_SENSORS entities.extend( - DiscovergySensor(value_key, description, meter, coordinator) + DiscovergySensor(value_key, description, coordinator) for description in sensors for value_key in {description.key, *description.alternative_keys} if description.value_fn(coordinator.data, value_key, description.scale) @@ -200,15 +199,15 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt self, data_key: str, description: DiscovergySensorEntityDescription, - meter: Meter, coordinator: DiscovergyUpdateCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.data_key = data_key - self.entity_description = description + + meter = coordinator.meter self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, From 06fa3068219c7936337da8f20baee6bf6bf05757 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 2 Jan 2024 13:29:07 +0100 Subject: [PATCH 0154/1544] Mark humidifier entity component as strictly typed (#106721) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .strict-typing | 1 + homeassistant/components/humidifier/__init__.py | 2 +- homeassistant/components/humidifier/reproduce_state.py | 5 ++--- mypy.ini | 10 ++++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index ff0ecd1c3ae..db69d828003 100644 --- a/.strict-typing +++ b/.strict-typing @@ -202,6 +202,7 @@ homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* +homeassistant.components.humidifier.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 75d4f0fd225..ea6e8972cc6 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -91,7 +91,7 @@ __dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) @bind_hass -def is_on(hass, entity_id): +def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the humidifier is on based on the statemachine. Async friendly. diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index b0e9a29cacc..be4f1afbeb9 100644 --- a/homeassistant/components/humidifier/reproduce_state.py +++ b/homeassistant/components/humidifier/reproduce_state.py @@ -32,10 +32,9 @@ async def _async_reproduce_states( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - async def call_service(service: str, keys: Iterable, data=None): + async def call_service(service: str, keys: Iterable[str]) -> None: """Call service with set of attributes given.""" - data = data or {} - data["entity_id"] = state.entity_id + data = {"entity_id": state.entity_id} for key in keys: if key in state.attributes: data[key] = state.attributes[key] diff --git a/mypy.ini b/mypy.ini index f1c2cfa2cf3..9ffbac47b25 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1781,6 +1781,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.humidifier.*] +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.hydrawise.*] check_untyped_defs = true disallow_incomplete_defs = true From 6e6575afe5057649d4a3c84d9762186a1559de58 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:29:38 +0100 Subject: [PATCH 0155/1544] Enable strict typing for apache_kafka (#106823) --- .strict-typing | 1 + .../components/apache_kafka/__init__.py | 46 ++++++++++--------- mypy.ini | 10 ++++ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.strict-typing b/.strict-typing index db69d828003..6ab9736b144 100644 --- a/.strict-typing +++ b/.strict-typing @@ -75,6 +75,7 @@ homeassistant.components.androidtv_remote.* homeassistant.components.anel_pwrctrl.* homeassistant.components.anova.* homeassistant.components.anthemav.* +homeassistant.components.apache_kafka.* homeassistant.components.apcupsd.* homeassistant.components.apprise.* homeassistant.components.aprs.* diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index c974735791e..d909fb9f51f 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,7 +1,10 @@ """Support for Apache Kafka.""" +from __future__ import annotations + from datetime import datetime import json import sys +from typing import Any, Literal import voluptuous as vol @@ -15,11 +18,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import FILTER_SCHEMA -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util import ssl as ssl_util if sys.version_info < (3, 12): @@ -84,11 +88,11 @@ class DateTimeJSONEncoder(json.JSONEncoder): Additionally add encoding for datetime objects as isoformat. """ - def default(self, o): + def default(self, o: Any) -> str: """Implement encoding logic.""" if isinstance(o, datetime): return o.isoformat() - return super().default(o) + return super().default(o) # type: ignore[no-any-return] class KafkaManager: @@ -96,15 +100,15 @@ class KafkaManager: def __init__( self, - hass, - ip_address, - port, - topic, - entities_filter, - security_protocol, - username, - password, - ): + hass: HomeAssistant, + ip_address: str, + port: int, + topic: str, + entities_filter: EntityFilter, + security_protocol: Literal["PLAINTEXT", "SASL_SSL"], + username: str | None, + password: str | None, + ) -> None: """Initialize.""" self._encoder = DateTimeJSONEncoder() self._entities_filter = entities_filter @@ -121,30 +125,30 @@ class KafkaManager: ) self._topic = topic - def _encode_event(self, event): + def _encode_event(self, event: EventType[EventStateChangedData]) -> bytes | None: """Translate events into a binary JSON payload.""" - state = event.data.get("new_state") + state = event.data["new_state"] if ( state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) or not self._entities_filter(state.entity_id) ): - return + return None return json.dumps(obj=state.as_dict(), default=self._encoder.encode).encode( "utf-8" ) - async def start(self): + async def start(self) -> None: """Start the Kafka manager.""" - self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) + self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) # type: ignore[arg-type] await self._producer.start() - async def shutdown(self, _): + async def shutdown(self, _: Event) -> None: """Shut the manager down.""" await self._producer.stop() - async def write(self, event): + async def write(self, event: EventType[EventStateChangedData]) -> None: """Write a binary payload to Kafka.""" payload = self._encode_event(event) diff --git a/mypy.ini b/mypy.ini index 9ffbac47b25..55ec39de449 100644 --- a/mypy.ini +++ b/mypy.ini @@ -510,6 +510,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apache_kafka.*] +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.apcupsd.*] check_untyped_defs = true disallow_incomplete_defs = true From 0b9242f8094969aec806fb6be139e49cba105f64 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:30:04 +0100 Subject: [PATCH 0156/1544] Add translatable title to logbook (#106810) --- homeassistant/components/logbook/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index aad9c122d23..27ad49b0e3a 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -1,4 +1,5 @@ { + "title": "Logbook", "services": { "log": { "name": "Log", From 729a0fbcd585afab7775706655ef97151fecc17b Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:34:19 +0100 Subject: [PATCH 0157/1544] Move urllib3 constraint to pyproject.toml (#106768) --- homeassistant/package_constraints.txt | 6 +----- pyproject.toml | 4 ++++ requirements.txt | 1 + script/gen_requirements_all.py | 5 ----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b687b79aa4..869048f66ac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -55,6 +55,7 @@ scapy==2.5.0 SQLAlchemy==2.0.24 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 @@ -65,11 +66,6 @@ zeroconf==0.131.0 # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 diff --git a/pyproject.toml b/pyproject.toml index f2f577cfa19..f611cc73f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,10 @@ dependencies = [ "requests==2.31.0", "typing-extensions>=4.9.0,<5.0", "ulid-transform==0.9.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 + # https://github.com/home-assistant/core/issues/97248 + "urllib3>=1.26.5,<2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.4", diff --git a/requirements.txt b/requirements.txt index 2cac92b4972..55cbdc31730 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,6 +30,7 @@ PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3cecff68fb0..7f652b14302 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -59,11 +59,6 @@ CONSTRAINT_BASE = """ # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 -# Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 -# https://github.com/home-assistant/core/issues/97248 -urllib3>=1.26.5,<2 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 From 15cdd42c99ab7c088b0d7859d149ff5de0527019 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:37:58 +0100 Subject: [PATCH 0158/1544] Apply late review comments on media player (#106727) --- .../components/media_player/significant_change.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index 3e11cbdb9cd..adc96fc8b83 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -27,7 +27,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_ENTITY_PICTURE_LOCAL, ATTR_GROUP_MEMBERS, *ATTR_TO_PROPERTY, -} +} - INSIGNIFICANT_ATTRIBUTES @callback @@ -44,18 +44,10 @@ def async_check_significant_change( return True old_attrs_s = set( - { - k: v - for k, v in old_attrs.items() - if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES - }.items() + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() ) new_attrs_s = set( - { - k: v - for k, v in new_attrs.items() - if k in SIGNIFICANT_ATTRIBUTES - INSIGNIFICANT_ATTRIBUTES - }.items() + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() ) changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} From 8f9bd75a3643e37aac391d924229bf8d9ca5a3b0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 13:57:25 +0100 Subject: [PATCH 0159/1544] Enable strict typing of date_time (#106868) * Enable strict typing of date_time * Fix parse_datetime * Add test * Add comments * Update tests/util/test_dt.py Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- .strict-typing | 1 + homeassistant/components/time_date/sensor.py | 28 +++++++++++--------- homeassistant/util/dt.py | 27 +++++++++++++++++-- mypy.ini | 10 +++++++ tests/util/test_dt.py | 6 +++++ 5 files changed, 57 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index 6ab9736b144..87d5a853add 100644 --- a/.strict-typing +++ b/.strict-typing @@ -373,6 +373,7 @@ homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* homeassistant.components.time.* +homeassistant.components.time_date.* homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 5646c7a7018..eb0f291ad3f 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,14 +1,14 @@ """Support for showing the date and the time.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_DISPLAY_OPTIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time @@ -47,7 +47,7 @@ async def async_setup_platform( ) -> None: """Set up the Time and Date sensor.""" if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False async_add_entities( @@ -58,28 +58,28 @@ async def async_setup_platform( class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" - def __init__(self, hass, option_type): + def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" self._name = OPTION_TYPES[option_type] self.type = option_type - self._state = None + self._state: str | None = None self.hass = hass - self.unsub = None + self.unsub: CALLBACK_TYPE | None = None self._update_internal_state(dt_util.utcnow()) @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of the sensor.""" return self._state @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" if "date" in self.type and "time" in self.type: return "mdi:calendar-clock" @@ -99,7 +99,7 @@ class TimeDateSensor(SensorEntity): self.unsub() self.unsub = None - def get_next_interval(self): + def get_next_interval(self) -> datetime: """Compute next time an update should occur.""" now = dt_util.utcnow() @@ -121,7 +121,7 @@ class TimeDateSensor(SensorEntity): return next_interval - def _update_internal_state(self, time_date): + def _update_internal_state(self, time_date: datetime) -> None: time = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT) time_utc = time_date.strftime(TIME_STR_FORMAT) date = dt_util.as_local(time_date).date().isoformat() @@ -155,10 +155,12 @@ class TimeDateSensor(SensorEntity): self._state = f"@{beat:03d}" elif self.type == "date_time_iso": - self._state = dt_util.parse_datetime(f"{date} {time}").isoformat() + self._state = dt_util.parse_datetime( + f"{date} {time}", raise_on_error=True + ).isoformat() @callback - def point_in_time_listener(self, time_date): + def point_in_time_listener(self, time_date: datetime) -> None: """Get the latest data and update state.""" self._update_internal_state(time_date) self.async_write_ha_state() diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4859c5c85dd..81237e1eca6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,7 +6,7 @@ from contextlib import suppress import datetime as dt from functools import partial import re -from typing import Any +from typing import Any, Literal, overload import zoneinfo import ciso8601 @@ -177,18 +177,41 @@ def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datet # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/main/LICENSE +@overload def parse_datetime(dt_str: str) -> dt.datetime | None: + ... + + +@overload +def parse_datetime(dt_str: str, *, raise_on_error: Literal[True]) -> dt.datetime: + ... + + +@overload +def parse_datetime( + dt_str: str, *, raise_on_error: Literal[False] | bool +) -> dt.datetime | None: + ... + + +def parse_datetime(dt_str: str, *, raise_on_error: bool = False) -> dt.datetime | None: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, the output uses a timezone with a fixed offset from UTC. Raises ValueError if the input is well formatted but not a valid datetime. - Returns None if the input isn't well formatted. + + If the input isn't well formatted, returns None if raise_on_error is False + or raises ValueError if it's True. """ + # First try if the string can be parsed by the fast ciso8601 library with suppress(ValueError, IndexError): return ciso8601.parse_datetime(dt_str) + # ciso8601 failed to parse the string, fall back to regex if not (match := DATETIME_RE.match(dt_str)): + if raise_on_error: + raise ValueError return None kws: dict[str, Any] = match.groupdict() if kws["microsecond"]: diff --git a/mypy.ini b/mypy.ini index 55ec39de449..6f3ca7ce54e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3492,6 +3492,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.time_date.*] +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.todo.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index a973135d831..3b6293d7c17 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -147,6 +147,12 @@ def test_parse_datetime_returns_none_for_incorrect_format() -> None: assert dt_util.parse_datetime("not a datetime string") is None +def test_parse_datetime_raises_for_incorrect_format() -> None: + """Test parse_datetime raises ValueError if raise_on_error is set with an incorrect format.""" + with pytest.raises(ValueError): + dt_util.parse_datetime("not a datetime string", raise_on_error=True) + + @pytest.mark.parametrize( ("duration_string", "expected_result"), [ From 8f8c0ef13b1bdb3233c247b139e060a8ebf7efe5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 14:11:45 +0100 Subject: [PATCH 0160/1544] Deprecate 'beat' display option in Time & Date (#106871) * Deprecate 'beat' display option in Time & Date * Move deprecation warning * Update homeassistant/components/time_date/const.py Co-authored-by: Sander --------- Co-authored-by: G Johansson Co-authored-by: Sander --- homeassistant/components/time_date/const.py | 6 +++ homeassistant/components/time_date/sensor.py | 20 ++++++++++ .../components/time_date/strings.json | 8 ++++ tests/components/time_date/test_sensor.py | 40 +++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 homeassistant/components/time_date/const.py create mode 100644 homeassistant/components/time_date/strings.json diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py new file mode 100644 index 00000000000..4d0ff354a6c --- /dev/null +++ b/homeassistant/components/time_date/const.py @@ -0,0 +1,6 @@ +"""Constants for the Time & Date integration.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "time_date" diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index eb0f291ad3f..a1d5024e9b1 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -12,9 +12,12 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) TIME_STR_FORMAT = "%H:%M" @@ -50,6 +53,23 @@ async def async_setup_platform( _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False + if "beat" in config[CONF_DISPLAY_OPTIONS]: + async_create_issue( + hass, + DOMAIN, + "deprecated_beat", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_beat", + translation_placeholders={ + "config_key": "beat", + "display_options": "display_options", + "integration": DOMAIN, + }, + ) + _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") + async_add_entities( [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] ) diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json new file mode 100644 index 00000000000..582fd44a45b --- /dev/null +++ b/homeassistant/components/time_date/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_beat": { + "title": "The `{config_key}` Time & Date sensor is being removed", + "description": "Please remove the `{config_key}` key from the `{display_options}` for the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index f9ef8a7cfe9..150ea4e8225 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,9 +1,13 @@ """The tests for time_date sensor platform.""" from freezegun.api import FrozenDateTimeFactory +import pytest +from homeassistant.components.time_date.const import DOMAIN import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -172,3 +176,39 @@ async def test_icons(hass: HomeAssistant) -> None: assert device.icon == "mdi:calendar-clock" device = time_date.TimeDateSensor(hass, "date_time_iso") assert device.icon == "mdi:calendar-clock" + + +@pytest.mark.parametrize( + ( + "display_options", + "expected_warnings", + "expected_issues", + ), + [ + (["time", "date"], [], []), + (["beat"], ["'beat': is deprecated"], ["deprecated_beat"]), + (["time", "beat"], ["'beat': is deprecated"], ["deprecated_beat"]), + ], +) +async def test_deprecation_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + display_options: list[str], + expected_warnings: list[str], + expected_issues: list[str], +) -> None: + """Test deprecation warning for swatch beat.""" + config = {"sensor": {"platform": "time_date", "display_options": display_options}} + + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + warnings = [record for record in caplog.records if record.levelname == "WARNING"] + assert len(warnings) == len(expected_warnings) + for expected_warning in expected_warnings: + assert any(expected_warning in warning.message for warning in warnings) + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == len(expected_issues) + for expected_issue in expected_issues: + assert (DOMAIN, expected_issue) in issue_registry.issues From bf0d891f68b7895947ef61ddf5260f2d98f0f026 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 2 Jan 2024 08:59:45 -0500 Subject: [PATCH 0161/1544] Bump Zigpy to 0.60.4 (#106870) --- 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 db5939123e4..06ebfaaa6a0 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.109", "zigpy-deconz==0.22.4", - "zigpy==0.60.3", + "zigpy==0.60.4", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 6ba98b21171..ac87a7336c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2890,7 +2890,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.3 +zigpy==0.60.4 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bba7d7906de..e843f45cc6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2189,7 +2189,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.3 +zigpy==0.60.4 # homeassistant.components.zwave_js zwave-js-server-python==0.55.2 From 09b65f14b96d53812d44eb02f6fe603f1f2c5ace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 04:28:58 -1000 Subject: [PATCH 0162/1544] Index entities by domain for entity services (#106759) --- homeassistant/components/isy994/services.py | 19 ++++++++--- homeassistant/helpers/entity_component.py | 24 ++++++-------- homeassistant/helpers/entity_platform.py | 13 +++++--- homeassistant/helpers/service.py | 35 +++++++++------------ tests/components/tts/test_init.py | 4 ++- tests/helpers/test_service.py | 28 ++++++++--------- 6 files changed, 63 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 7d7696755cf..fec6c141915 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call @@ -120,6 +121,14 @@ SERVICE_SEND_PROGRAM_COMMAND_SCHEMA = vol.All( ) +def async_get_entities(hass: HomeAssistant) -> dict[str, Entity]: + """Get entities for a domain.""" + entities: dict[str, Entity] = {} + for platform in async_get_platforms(hass, DOMAIN): + entities.update(platform.entities) + return entities + + @callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Create and register services for the ISY integration.""" @@ -159,7 +168,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 async def _async_send_raw_node_command(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_send_raw_node_command", call + hass, async_get_entities(hass), "async_send_raw_node_command", call ) hass.services.async_register( @@ -171,7 +180,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 async def _async_send_node_command(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_send_node_command", call + hass, async_get_entities(hass), "async_send_node_command", call ) hass.services.async_register( @@ -183,7 +192,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 async def _async_get_zwave_parameter(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_get_zwave_parameter", call + hass, async_get_entities(hass), "async_get_zwave_parameter", call ) hass.services.async_register( @@ -195,7 +204,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 async def _async_set_zwave_parameter(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_set_zwave_parameter", call + hass, async_get_entities(hass), "async_set_zwave_parameter", call ) hass.services.async_register( @@ -207,7 +216,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 async def _async_rename_node(call: ServiceCall) -> None: await entity_service_call( - hass, async_get_platforms(hass, DOMAIN), "async_rename_node", call + hass, async_get_entities(hass), "async_rename_node", call ) hass.services.async_register( diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 30e892a8840..b3eb8722997 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -89,12 +89,13 @@ class EntityComponent(Generic[_EntityT]): self.config: ConfigType | None = None + domain_platform = self._async_init_entity_platform(domain, None) self._platforms: dict[ str | tuple[str, timedelta | None, str | None], EntityPlatform - ] = {domain: self._async_init_entity_platform(domain, None)} - self.async_add_entities = self._platforms[domain].async_add_entities - self.add_entities = self._platforms[domain].add_entities - + ] = {domain: domain_platform} + self.async_add_entities = domain_platform.async_add_entities + self.add_entities = domain_platform.add_entities + self._entities: dict[str, entity.Entity] = domain_platform.domain_entities hass.data.setdefault(DATA_INSTANCES, {})[domain] = self @property @@ -105,18 +106,11 @@ class EntityComponent(Generic[_EntityT]): callers that iterate over this asynchronously should make a copy using list() before iterating. """ - return chain.from_iterable( - platform.entities.values() # type: ignore[misc] - for platform in self._platforms.values() - ) + return self._entities.values() # type: ignore[return-value] def get_entity(self, entity_id: str) -> _EntityT | None: """Get an entity.""" - for platform in self._platforms.values(): - entity_obj = platform.entities.get(entity_id) - if entity_obj is not None: - return entity_obj # type: ignore[return-value] - return None + return self._entities.get(entity_id) # type: ignore[return-value] def register_shutdown(self) -> None: """Register shutdown on Home Assistant STOP event. @@ -237,7 +231,7 @@ class EntityComponent(Generic[_EntityT]): """Handle the service.""" result = await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features + self.hass, self._entities, func, call, required_features ) if result: @@ -270,7 +264,7 @@ class EntityComponent(Generic[_EntityT]): ) -> EntityServiceResponse | None: """Handle the service.""" return await service.entity_service_call( - self.hass, self._platforms.values(), func, call, required_features + self.hass, self._entities, func, call, required_features ) self.hass.services.async_register( diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 221203902c5..1bf7d95135b 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -55,6 +55,7 @@ SLOW_ADD_MIN_TIMEOUT = 500 PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" +DATA_DOMAIN_ENTITIES = "domain_entities" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -147,6 +148,10 @@ class EntityPlatform: self.platform_name, [] ).append(self) + self.domain_entities: dict[str, Entity] = hass.data.setdefault( + DATA_DOMAIN_ENTITIES, {} + ).setdefault(domain, {}) + def __repr__(self) -> str: """Represent an EntityPlatform.""" return ( @@ -734,6 +739,7 @@ class EntityPlatform: entity_id = entity.entity_id self.entities[entity_id] = entity + self.domain_entities[entity_id] = entity if not restored: # Reserve the state in the state machine @@ -746,6 +752,7 @@ class EntityPlatform: def remove_entity_cb() -> None: """Remove entity from entities dict.""" self.entities.pop(entity_id) + self.domain_entities.pop(entity_id) entity.async_on_remove(remove_entity_cb) @@ -830,11 +837,7 @@ class EntityPlatform: """Handle the service.""" return await service.entity_service_call( self.hass, - [ - plf - for plf in self.hass.data[DATA_ENTITY_PLATFORM][self.platform_name] - if plf.domain == self.domain - ], + self.domain_entities, func, call, required_features, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9af69acc6b2..59fd061d8c9 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -58,7 +58,6 @@ from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: from .entity import Entity - from .entity_platform import EntityPlatform _EntityT = TypeVar("_EntityT", bound=Entity) @@ -741,7 +740,7 @@ def async_set_service_schema( def _get_permissible_entity_candidates( call: ServiceCall, - platforms: Iterable[EntityPlatform], + entities: dict[str, Entity], entity_perms: None | (Callable[[str, str], bool]), target_all_entities: bool, all_referenced: set[str] | None, @@ -754,9 +753,8 @@ def _get_permissible_entity_candidates( # is allowed to control. return [ entity - for platform in platforms - for entity in platform.entities.values() - if entity_perms(entity.entity_id, POLICY_CONTROL) + for entity_id, entity in entities.items() + if entity_perms(entity_id, POLICY_CONTROL) ] assert all_referenced is not None @@ -771,29 +769,26 @@ def _get_permissible_entity_candidates( ) elif target_all_entities: - return [ - entity for platform in platforms for entity in platform.entities.values() - ] + return list(entities.values()) # We have already validated they have permissions to control all_referenced # entities so we do not need to check again. - assert all_referenced is not None - if single_entity := len(all_referenced) == 1 and list(all_referenced)[0]: - for platform in platforms: - if (entity := platform.entities.get(single_entity)) is not None: - return [entity] + if TYPE_CHECKING: + assert all_referenced is not None + if ( + len(all_referenced) == 1 + and (single_entity := list(all_referenced)[0]) + and (entity := entities.get(single_entity)) is not None + ): + return [entity] - return [ - platform.entities[entity_id] - for platform in platforms - for entity_id in all_referenced.intersection(platform.entities) - ] + return [entities[entity_id] for entity_id in all_referenced.intersection(entities)] @bind_hass async def entity_service_call( hass: HomeAssistant, - platforms: Iterable[EntityPlatform], + registered_entities: dict[str, Entity], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], call: ServiceCall, required_features: Iterable[int] | None = None, @@ -832,7 +827,7 @@ async def entity_service_call( # A list with entities to call the service on. entity_candidates = _get_permissible_entity_candidates( call, - platforms, + registered_entities, entity_perms, target_all_entities, all_referenced, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 990d8d273ed..d56542b2a57 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1406,7 +1406,9 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None with patch.dict( hass.data[tts.DATA_TTS_MANAGER].providers, {}, clear=True - ), patch.dict(hass.data[tts.DOMAIN]._platforms, {}, clear=True): + ), patch.dict(hass.data[tts.DOMAIN]._platforms, {}, clear=True), patch.dict( + hass.data[tts.DOMAIN]._entities, {}, clear=True + ): assert tts.async_resolve_engine(hass, None) is None with patch.dict(hass.data[tts.DATA_TTS_MANAGER].providers, {"cloud": object()}): diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 04324cdbfa3..628ead473d7 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -802,7 +802,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], @@ -821,7 +821,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - with pytest.raises(exceptions.HomeAssistantError): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall( "test_domain", "test_service", {"entity_id": "light.living_room"} @@ -838,7 +838,7 @@ async def test_call_with_both_required_features( test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], @@ -857,7 +857,7 @@ async def test_call_with_one_of_required_features( test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], @@ -878,7 +878,7 @@ async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: test_service_mock = Mock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, test_service_mock, ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), ) @@ -890,7 +890,7 @@ async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: mock_method = mock_entities["light.kitchen"].sync_method = Mock(return_value=None) await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, "sync_method", ServiceCall( "test_domain", @@ -908,7 +908,7 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: with pytest.raises(exceptions.UnknownUser) as err: await service.entity_service_call( hass, - [], + {}, Mock(), ServiceCall( "test_domain", @@ -935,7 +935,7 @@ async def test_call_context_target_all( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -963,7 +963,7 @@ async def test_call_context_target_specific( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -987,7 +987,7 @@ async def test_call_context_target_specific_no_auth( ): await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -1007,7 +1007,7 @@ async def test_call_no_context_target_all( """Check we target all if no user context given.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", "test_service", data={"entity_id": ENTITY_MATCH_ALL} @@ -1026,7 +1026,7 @@ async def test_call_no_context_target_specific( """Check we can target specified entities.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall( "test_domain", @@ -1048,7 +1048,7 @@ async def test_call_with_match_all( """Check we only target allowed entities if targeting all.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), ) @@ -1065,7 +1065,7 @@ async def test_call_with_omit_entity_id( """Check service call if we do not pass an entity ID.""" await service.entity_service_call( hass, - [Mock(entities=mock_entities)], + mock_entities, Mock(), ServiceCall("test_domain", "test_service"), ) From ba0cb3bd05c341ee255b00e8ef2c0f4d3b73cd26 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 2 Jan 2024 15:39:28 +0100 Subject: [PATCH 0163/1544] Add Reolink image settings (#105415) --- homeassistant/components/reolink/number.py | 70 +++++++++++++++++++ homeassistant/components/reolink/strings.json | 15 ++++ 2 files changed, 85 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 09869b06e96..b27976eaa0e 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -371,6 +371,76 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.daynight_threshold(ch), method=lambda api, ch, value: api.set_daynight_threshold(ch, int(value)), ), + ReolinkNumberEntityDescription( + key="image_brightness", + cmd_key="GetImage", + translation_key="image_brightness", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_bright"), + value=lambda api, ch: api.image_brightness(ch), + method=lambda api, ch, value: api.set_image(ch, bright=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_contrast", + cmd_key="GetImage", + translation_key="image_contrast", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_contrast"), + value=lambda api, ch: api.image_contrast(ch), + method=lambda api, ch, value: api.set_image(ch, contrast=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_saturation", + cmd_key="GetImage", + translation_key="image_saturation", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_satruation"), + value=lambda api, ch: api.image_saturation(ch), + method=lambda api, ch, value: api.set_image(ch, saturation=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_sharpness", + cmd_key="GetImage", + translation_key="image_sharpness", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_sharpen"), + value=lambda api, ch: api.image_sharpness(ch), + method=lambda api, ch, value: api.set_image(ch, sharpen=int(value)), + ), + ReolinkNumberEntityDescription( + key="image_hue", + cmd_key="GetImage", + translation_key="image_hue", + icon="mdi:image-edit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=255, + supported=lambda api, ch: api.supported(ch, "isp_hue"), + value=lambda api, ch: api.image_hue(ch), + method=lambda api, ch, value: api.set_image(ch, hue=int(value)), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 04dd0e787ac..92e9a6164f8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -308,6 +308,21 @@ }, "day_night_switch_threshold": { "name": "Day night switch threshold" + }, + "image_brightness": { + "name": "Image brightness" + }, + "image_contrast": { + "name": "Image contrast" + }, + "image_saturation": { + "name": "Image saturation" + }, + "image_sharpness": { + "name": "Image sharpness" + }, + "image_hue": { + "name": "Image hue" } }, "select": { From 513261baffdb608444de4f804df149102154916f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 16:04:38 +0100 Subject: [PATCH 0164/1544] Improve time_date tests (#106878) --- tests/components/time_date/test_sensor.py | 360 ++++++++++++++-------- 1 file changed, 227 insertions(+), 133 deletions(-) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 150ea4e8225..4989a65984b 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,181 +1,275 @@ """The tests for time_date sensor platform.""" +from unittest.mock import ANY, Mock, patch + from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.time_date.const import DOMAIN import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import event, issue_registry as ir from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import async_fire_time_changed -async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test timing intervals of sensors.""" - device = time_date.TimeDateSensor(hass, "time") - now = dt_util.utc_from_timestamp(45.5) - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time == dt_util.utc_from_timestamp(60) - - device = time_date.TimeDateSensor(hass, "beat") - now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") - - device = time_date.TimeDateSensor(hass, "date_time") - now = dt_util.utc_from_timestamp(1495068899) - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time == dt_util.utc_from_timestamp(1495068900) - - now = dt_util.utcnow() - device = time_date.TimeDateSensor(hass, "time_date") - next_time = device.get_next_interval() - assert next_time > now +ALL_DISPLAY_OPTIONS = list(time_date.OPTION_TYPES.keys()) +CONFIG = {"sensor": {"platform": "time_date", "display_options": ALL_DISPLAY_OPTIONS}} -async def test_states(hass: HomeAssistant) -> None: +@patch("homeassistant.components.time_date.sensor.async_track_point_in_utc_time") +@pytest.mark.parametrize( + ("display_option", "start_time", "tracked_time"), + [ + ( + "time", + dt_util.utc_from_timestamp(45.5), + dt_util.utc_from_timestamp(60), + ), + ( + "beat", + dt_util.parse_datetime("2020-11-13 00:00:29+01:00"), + dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00"), + ), + ( + "date_time", + dt_util.utc_from_timestamp(1495068899), + dt_util.utc_from_timestamp(1495068900), + ), + ( + "time_date", + dt_util.utc_from_timestamp(1495068899), + dt_util.utc_from_timestamp(1495068900), + ), + ], +) +async def test_intervals( + mock_track_interval: Mock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + display_option: str, + start_time, + tracked_time, +) -> None: + """Test timing intervals of sensors when time zone is UTC.""" + hass.config.set_time_zone("UTC") + config = {"sensor": {"platform": "time_date", "display_options": [display_option]}} + + freezer.move_to(start_time) + + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) + + +async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test states of sensors.""" hass.config.set_time_zone("UTC") - now = dt_util.utc_from_timestamp(1495068856) - device = time_date.TimeDateSensor(hass, "time") - device._update_internal_state(now) - assert device.state == "00:54" + freezer.move_to(now) - device = time_date.TimeDateSensor(hass, "date") - device._update_internal_state(now) - assert device.state == "2017-05-18" + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() - device = time_date.TimeDateSensor(hass, "time_utc") - device._update_internal_state(now) - assert device.state == "00:54" + state = hass.states.get("sensor.time") + assert state.state == "00:54" - device = time_date.TimeDateSensor(hass, "date_time") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.date") + assert state.state == "2017-05-18" - device = time_date.TimeDateSensor(hass, "date_time_utc") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.time_utc") + assert state.state == "00:54" - device = time_date.TimeDateSensor(hass, "beat") - device._update_internal_state(now) - assert device.state == "@079" - device._update_internal_state(dt_util.utc_from_timestamp(1602952963.2)) - assert device.state == "@738" + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-18, 00:54" - device = time_date.TimeDateSensor(hass, "date_time_iso") - device._update_internal_state(now) - assert device.state == "2017-05-18T00:54:00" + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:54" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@079" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-18T00:54:00" + + now = dt_util.utc_from_timestamp(1602952963.2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # The time should be 2020-10-17 18:42 / @738, however the time_date sensor + # does not check the current time when calculating the state, it instead checks + # the time when it expected an update + state = hass.states.get("sensor.time") + assert state.state == "00:55" + + state = hass.states.get("sensor.date") + assert state.state == "2017-05-19" + + state = hass.states.get("sensor.time_utc") + assert state.state == "00:55" + + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-18, 00:55" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:55" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@080" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-18T00:55:00" -async def test_states_non_default_timezone(hass: HomeAssistant) -> None: +async def test_states_non_default_timezone( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test states of sensors in a timezone other than UTC.""" hass.config.set_time_zone("America/New_York") - now = dt_util.utc_from_timestamp(1495068856) - device = time_date.TimeDateSensor(hass, "time") - device._update_internal_state(now) - assert device.state == "20:54" + freezer.move_to(now) - device = time_date.TimeDateSensor(hass, "date") - device._update_internal_state(now) - assert device.state == "2017-05-17" + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() - device = time_date.TimeDateSensor(hass, "time_utc") - device._update_internal_state(now) - assert device.state == "00:54" + state = hass.states.get("sensor.time") + assert state.state == "20:54" - device = time_date.TimeDateSensor(hass, "date_time") - device._update_internal_state(now) - assert device.state == "2017-05-17, 20:54" + state = hass.states.get("sensor.date") + assert state.state == "2017-05-17" - device = time_date.TimeDateSensor(hass, "date_time_utc") - device._update_internal_state(now) - assert device.state == "2017-05-18, 00:54" + state = hass.states.get("sensor.time_utc") + assert state.state == "00:54" - device = time_date.TimeDateSensor(hass, "beat") - device._update_internal_state(now) - assert device.state == "@079" + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-17, 20:54" - device = time_date.TimeDateSensor(hass, "date_time_iso") - device._update_internal_state(now) - assert device.state == "2017-05-17T20:54:00" + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:54" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@079" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-17T20:54:00" + + now = dt_util.utc_from_timestamp(1602952963.2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # The time should be 2020-10-17 12:42 / @738, however the time_date sensor + # does not check the current time when calculating the state, it instead checks + # the time when it expected an update + state = hass.states.get("sensor.time") + assert state.state == "20:55" + + state = hass.states.get("sensor.date") + assert state.state == "2017-05-18" + + state = hass.states.get("sensor.time_utc") + assert state.state == "00:55" + + state = hass.states.get("sensor.date_time") + assert state.state == "2017-05-17, 20:55" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2017-05-18, 00:55" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@080" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2017-05-17T20:55:00" +@patch( + "homeassistant.components.time_date.sensor.async_track_point_in_utc_time", + side_effect=event.async_track_point_in_utc_time, +) +@pytest.mark.parametrize( + ("time_zone", "start_time", "tracked_time"), + [ + ( + "America/New_York", + dt_util.utc_from_timestamp(50000), + # start of local day in EST was 18000.0 + # so the second day was 18000 + 86400 + 104400, + ), + ( + "America/Edmonton", + dt_util.parse_datetime("2017-11-13 19:47:19-07:00"), + dt_util.as_timestamp("2017-11-14 00:00:00-07:00"), + ), + # Entering DST + ( + "Europe/Prague", + dt_util.parse_datetime("2020-03-29 00:00+01:00"), + dt_util.as_timestamp("2020-03-30 00:00+02:00"), + ), + ( + "Europe/Prague", + dt_util.parse_datetime("2020-03-29 03:00+02:00"), + dt_util.as_timestamp("2020-03-30 00:00+02:00"), + ), + # Leaving DST + ( + "Europe/Prague", + dt_util.parse_datetime("2020-10-25 00:00+02:00"), + dt_util.as_timestamp("2020-10-26 00:00+01:00"), + ), + ( + "Europe/Prague", + dt_util.parse_datetime("2020-10-25 23:59+01:00"), + dt_util.as_timestamp("2020-10-26 00:00+01:00"), + ), + ], +) async def test_timezone_intervals( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + mock_track_interval: Mock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, + start_time, + tracked_time, ) -> None: - """Test date sensor behavior in a timezone besides UTC.""" - hass.config.set_time_zone("America/New_York") + """Test timing intervals of sensors in timezone other than UTC.""" + hass.config.set_time_zone(time_zone) + freezer.move_to(start_time) - device = time_date.TimeDateSensor(hass, "date") - now = dt_util.utc_from_timestamp(50000) - freezer.move_to(now) - next_time = device.get_next_interval() - # start of local day in EST was 18000.0 - # so the second day was 18000 + 86400 - assert next_time.timestamp() == 104400 + config = {"sensor": {"platform": "time_date", "display_options": ["date"]}} + await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() - hass.config.set_time_zone("America/Edmonton") - now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") - device = time_date.TimeDateSensor(hass, "date") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") + mock_track_interval.assert_called_once() + next_time = mock_track_interval.mock_calls[0][1][2] - # Entering DST - hass.config.set_time_zone("Europe/Prague") - - now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") - - now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") - - # Leaving DST - now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") - - now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - freezer.move_to(now) - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") - - -async def test_timezone_intervals_empty_parameter( - hass: HomeAssistant, freezer: FrozenDateTimeFactory -) -> None: - """Test get_interval() without parameters.""" - freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) - hass.config.set_time_zone("America/Edmonton") - device = time_date.TimeDateSensor(hass, "date") - next_time = device.get_next_interval() - assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") + assert next_time.timestamp() == tracked_time async def test_icons(hass: HomeAssistant) -> None: """Test attributes of sensors.""" - device = time_date.TimeDateSensor(hass, "time") - assert device.icon == "mdi:clock" - device = time_date.TimeDateSensor(hass, "date") - assert device.icon == "mdi:calendar" - device = time_date.TimeDateSensor(hass, "date_time") - assert device.icon == "mdi:calendar-clock" - device = time_date.TimeDateSensor(hass, "date_time_utc") - assert device.icon == "mdi:calendar-clock" - device = time_date.TimeDateSensor(hass, "date_time_iso") - assert device.icon == "mdi:calendar-clock" + await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date") + assert state.attributes["icon"] == "mdi:calendar" + state = hass.states.get("sensor.time_utc") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date_time") + assert state.attributes["icon"] == "mdi:calendar-clock" + state = hass.states.get("sensor.date_time_utc") + assert state.attributes["icon"] == "mdi:calendar-clock" + state = hass.states.get("sensor.internet_time") + assert state.attributes["icon"] == "mdi:clock" + state = hass.states.get("sensor.date_time_iso") + assert state.attributes["icon"] == "mdi:calendar-clock" @pytest.mark.parametrize( From 03cbf8b28d2343464cb3c71c71d8ae2e75208d8a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 16:22:16 +0100 Subject: [PATCH 0165/1544] Fix state update in time_date sensor (#106879) --- homeassistant/components/time_date/sensor.py | 33 +++++++++--------- tests/components/time_date/test_sensor.py | 36 +++++++++----------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index a1d5024e9b1..550e849af9d 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -86,8 +86,6 @@ class TimeDateSensor(SensorEntity): self.hass = hass self.unsub: CALLBACK_TYPE | None = None - self._update_internal_state(dt_util.utcnow()) - @property def name(self) -> str: """Return the name of the sensor.""" @@ -109,9 +107,7 @@ class TimeDateSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Set up first update.""" - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval() - ) + self._update_state_and_setup_listener() async def async_will_remove_from_hass(self) -> None: """Cancel next update.""" @@ -119,25 +115,23 @@ class TimeDateSensor(SensorEntity): self.unsub() self.unsub = None - def get_next_interval(self) -> datetime: + def get_next_interval(self, time_date: datetime) -> datetime: """Compute next time an update should occur.""" - now = dt_util.utcnow() - if self.type == "date": - tomorrow = dt_util.as_local(now) + timedelta(days=1) + tomorrow = dt_util.as_local(time_date) + timedelta(days=1) return dt_util.start_of_local_day(tomorrow) if self.type == "beat": # Add 1 hour because @0 beats is at 23:00:00 UTC. - timestamp = dt_util.as_timestamp(now + timedelta(hours=1)) + timestamp = dt_util.as_timestamp(time_date + timedelta(hours=1)) interval = 86.4 else: - timestamp = dt_util.as_timestamp(now) + timestamp = dt_util.as_timestamp(time_date) interval = 60 delta = interval - (timestamp % interval) - next_interval = now + timedelta(seconds=delta) - _LOGGER.debug("%s + %s -> %s (%s)", now, delta, next_interval, self.type) + next_interval = time_date + timedelta(seconds=delta) + _LOGGER.debug("%s + %s -> %s (%s)", time_date, delta, next_interval, self.type) return next_interval @@ -179,11 +173,16 @@ class TimeDateSensor(SensorEntity): f"{date} {time}", raise_on_error=True ).isoformat() + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.utcnow() + self._update_internal_state(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_internal_state(time_date) + self._update_state_and_setup_listener() self.async_write_ha_state() - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval() - ) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 4989a65984b..e156c1d5a3e 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -94,34 +94,32 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No state = hass.states.get("sensor.date_time_iso") assert state.state == "2017-05-18T00:54:00" + # Time travel now = dt_util.utc_from_timestamp(1602952963.2) freezer.move_to(now) async_fire_time_changed(hass, now) await hass.async_block_till_done() - # The time should be 2020-10-17 18:42 / @738, however the time_date sensor - # does not check the current time when calculating the state, it instead checks - # the time when it expected an update state = hass.states.get("sensor.time") - assert state.state == "00:55" + assert state.state == "16:42" state = hass.states.get("sensor.date") - assert state.state == "2017-05-19" + assert state.state == "2020-10-17" state = hass.states.get("sensor.time_utc") - assert state.state == "00:55" + assert state.state == "16:42" state = hass.states.get("sensor.date_time") - assert state.state == "2017-05-18, 00:55" + assert state.state == "2020-10-17, 16:42" state = hass.states.get("sensor.date_time_utc") - assert state.state == "2017-05-18, 00:55" + assert state.state == "2020-10-17, 16:42" state = hass.states.get("sensor.internet_time") - assert state.state == "@080" + assert state.state == "@738" state = hass.states.get("sensor.date_time_iso") - assert state.state == "2017-05-18T00:55:00" + assert state.state == "2020-10-17T16:42:00" async def test_states_non_default_timezone( @@ -156,34 +154,32 @@ async def test_states_non_default_timezone( state = hass.states.get("sensor.date_time_iso") assert state.state == "2017-05-17T20:54:00" + # Time travel now = dt_util.utc_from_timestamp(1602952963.2) freezer.move_to(now) async_fire_time_changed(hass, now) await hass.async_block_till_done() - # The time should be 2020-10-17 12:42 / @738, however the time_date sensor - # does not check the current time when calculating the state, it instead checks - # the time when it expected an update state = hass.states.get("sensor.time") - assert state.state == "20:55" + assert state.state == "12:42" state = hass.states.get("sensor.date") - assert state.state == "2017-05-18" + assert state.state == "2020-10-17" state = hass.states.get("sensor.time_utc") - assert state.state == "00:55" + assert state.state == "16:42" state = hass.states.get("sensor.date_time") - assert state.state == "2017-05-17, 20:55" + assert state.state == "2020-10-17, 12:42" state = hass.states.get("sensor.date_time_utc") - assert state.state == "2017-05-18, 00:55" + assert state.state == "2020-10-17, 16:42" state = hass.states.get("sensor.internet_time") - assert state.state == "@080" + assert state.state == "@738" state = hass.states.get("sensor.date_time_iso") - assert state.state == "2017-05-17T20:55:00" + assert state.state == "2020-10-17T12:42:00" @patch( From a7f4d426ee059f9cc015c879906d0599ddba552c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 16:28:22 +0100 Subject: [PATCH 0166/1544] Handle time zone change in time_date (#106880) --- homeassistant/components/time_date/sensor.py | 13 ++++++++-- tests/components/time_date/test_sensor.py | 25 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 550e849af9d..d3453c70b38 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -7,8 +7,8 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_DISPLAY_OPTIONS -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time @@ -107,6 +107,15 @@ class TimeDateSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Set up first update.""" + + async def async_update_config(event: Event) -> None: + """Handle core config update.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + self.async_on_remove( + self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_update_config) + ) self._update_state_and_setup_listener() async def async_will_remove_from_hass(self) -> None: diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index e156c1d5a3e..e8741a43427 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -181,6 +181,31 @@ async def test_states_non_default_timezone( state = hass.states.get("sensor.date_time_iso") assert state.state == "2020-10-17T12:42:00" + # Change time zone + await hass.config.async_update(time_zone="Europe/Prague") + await hass.async_block_till_done() + + state = hass.states.get("sensor.time") + assert state.state == "18:42" + + state = hass.states.get("sensor.date") + assert state.state == "2020-10-17" + + state = hass.states.get("sensor.time_utc") + assert state.state == "16:42" + + state = hass.states.get("sensor.date_time") + assert state.state == "2020-10-17, 18:42" + + state = hass.states.get("sensor.date_time_utc") + assert state.state == "2020-10-17, 16:42" + + state = hass.states.get("sensor.internet_time") + assert state.state == "@738" + + state = hass.states.get("sensor.date_time_iso") + assert state.state == "2020-10-17T18:42:00" + @patch( "homeassistant.components.time_date.sensor.async_track_point_in_utc_time", From e7b0bf24534a1c069c05e499084797e54737a862 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Jan 2024 16:50:07 +0100 Subject: [PATCH 0167/1544] Disable polling in time_date sensor (#106881) --- homeassistant/components/time_date/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index d3453c70b38..c00d362428b 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -78,6 +78,8 @@ async def async_setup_platform( class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" + _attr_should_poll = False + def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" self._name = OPTION_TYPES[option_type] From 2d0325a471285f3df1c209ac58f6deb15b1068b8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 2 Jan 2024 17:07:47 +0100 Subject: [PATCH 0168/1544] Mark stt entity component as strictly typed (#106723) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .strict-typing | 1 + homeassistant/components/stt/legacy.py | 18 +++++++++++++----- mypy.ini | 10 ++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index 87d5a853add..b4abed22bf8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -351,6 +351,7 @@ homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* homeassistant.components.streamlabswater.* +homeassistant.components.stt.* homeassistant.components.suez_water.* homeassistant.components.sun.* homeassistant.components.surepetcare.* diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 45f8ccefc68..bd1cfbca3d2 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform from .const import ( @@ -37,11 +37,12 @@ def async_get_provider( hass: HomeAssistant, domain: str | None = None ) -> Provider | None: """Return provider.""" + providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] if domain: - return hass.data[DATA_PROVIDERS].get(domain) + return providers.get(domain) provider = async_default_provider(hass) - return hass.data[DATA_PROVIDERS][provider] if provider is not None else None + return providers[provider] if provider is not None else None @callback @@ -51,7 +52,11 @@ def async_setup_legacy( """Set up legacy speech-to-text providers.""" providers = hass.data[DATA_PROVIDERS] = {} - async def async_setup_platform(p_type, p_config=None, discovery_info=None): + async def async_setup_platform( + p_type: str, + p_config: ConfigType | None = None, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Set up an STT platform.""" if p_config is None: p_config = {} @@ -73,7 +78,9 @@ def async_setup_legacy( return # Add discovery support - async def async_platform_discovered(platform, info): + async def async_platform_discovered( + platform: str, info: DiscoveryInfoType | None + ) -> None: """Handle for discovered platform.""" await async_setup_platform(platform, discovery_info=info) @@ -82,6 +89,7 @@ def async_setup_legacy( return [ async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN) + if p_type ] diff --git a/mypy.ini b/mypy.ini index 6f3ca7ce54e..9fd8f6a1305 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3272,6 +3272,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.stt.*] +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.suez_water.*] check_untyped_defs = true disallow_incomplete_defs = true From 4bfeb87377dc648ed2026da3ef80ac8287dbc2de Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 2 Jan 2024 10:43:33 -0600 Subject: [PATCH 0169/1544] Remove deprecated Life360 yaml configuration (#106286) --- homeassistant/components/life360/__init__.py | 131 +------------------ homeassistant/components/life360/const.py | 7 - 2 files changed, 2 insertions(+), 136 deletions(-) diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index c6e0fad14c6..8bd0895821b 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -2,128 +2,22 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any -import voluptuous as vol - -from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EXCLUDE, - CONF_INCLUDE, - CONF_PASSWORD, - CONF_PREFIX, - CONF_USERNAME, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_CIRCLES, - CONF_DRIVING_SPEED, - CONF_ERROR_THRESHOLD, - CONF_MAX_GPS_ACCURACY, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_SHOW_AS_STATE, - CONF_WARNING_THRESHOLD, - DEFAULT_OPTIONS, - DOMAIN, - LOGGER, - SHOW_DRIVING, - SHOW_MOVING, -) +from .const import DOMAIN from .coordinator import Life360DataUpdateCoordinator, MissingLocReason PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] -CONF_ACCOUNTS = "accounts" - -SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] - - -def _show_as_state(config: dict) -> dict: - if opts := config.pop(CONF_SHOW_AS_STATE): - if SHOW_DRIVING in opts: - config[SHOW_DRIVING] = True - if SHOW_MOVING in opts: - LOGGER.warning( - "%s is no longer supported as an option for %s", - SHOW_MOVING, - CONF_SHOW_AS_STATE, - ) - return config - - -def _unsupported(unsupported: set[str]) -> Callable[[dict], dict]: - """Warn about unsupported options and remove from config.""" - - def validator(config: dict) -> dict: - if unsupported_keys := unsupported & set(config): - LOGGER.warning( - "The following options are no longer supported: %s", - ", ".join(sorted(unsupported_keys)), - ) - return {k: v for k, v in config.items() if k not in unsupported} - - return validator - - -ACCOUNT_SCHEMA = { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, -} -CIRCLES_MEMBERS = { - vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), -} -LIFE360_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ACCOUNTS): vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]), - vol.Optional(CONF_CIRCLES): CIRCLES_MEMBERS, - vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), - vol.Optional(CONF_ERROR_THRESHOLD): vol.Coerce(int), - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_MAX_UPDATE_WAIT): cv.time_period, - vol.Optional(CONF_MEMBERS): CIRCLES_MEMBERS, - vol.Optional(CONF_PREFIX): vol.Any(None, cv.string), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( - cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)] - ), - vol.Optional(CONF_WARNING_THRESHOLD): vol.Coerce(int), - } - ), - _unsupported( - { - CONF_ACCOUNTS, - CONF_CIRCLES, - CONF_ERROR_THRESHOLD, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_PREFIX, - CONF_SCAN_INTERVAL, - CONF_WARNING_THRESHOLD, - } - ), - _show_as_state, -) -CONFIG_SCHEMA = vol.Schema( - vol.All({DOMAIN: LIFE360_SCHEMA}, cv.removed(DOMAIN, raise_if_present=False)), - extra=vol.ALLOW_EXTRA, -) - @dataclass class IntegData: """Integration data.""" - cfg_options: dict[str, Any] | None = None # ConfigEntry.entry_id: Life360DataUpdateCoordinator coordinators: dict[str, Life360DataUpdateCoordinator] = field( init=False, default_factory=dict @@ -137,34 +31,13 @@ class IntegData: logged_circles: list[str] = field(init=False, default_factory=list) logged_places: list[str] = field(init=False, default_factory=list) - def __post_init__(self): - """Finish initialization of cfg_options.""" - self.cfg_options = self.cfg_options or {} - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up integration.""" - hass.data.setdefault(DOMAIN, IntegData(config.get(DOMAIN))) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" hass.data.setdefault(DOMAIN, IntegData()) - # Check if this entry was created when this was a "legacy" tracker. If it was, - # update with missing data. - if not entry.unique_id: - hass.config_entries.async_update_entry( - entry, - unique_id=entry.data[CONF_USERNAME].lower(), - options=DEFAULT_OPTIONS | hass.data[DOMAIN].cfg_options, - ) - coordinator = Life360DataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN].coordinators[entry.entry_id] = coordinator # Set up components for our platforms. diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 333ce14fbf6..d310a5177b1 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -24,17 +24,10 @@ ATTR_SPEED = "speed" ATTR_WIFI_ON = "wifi_on" CONF_AUTHORIZATION = "authorization" -CONF_CIRCLES = "circles" CONF_DRIVING_SPEED = "driving_speed" -CONF_ERROR_THRESHOLD = "error_threshold" CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" -CONF_MAX_UPDATE_WAIT = "max_update_wait" -CONF_MEMBERS = "members" -CONF_SHOW_AS_STATE = "show_as_state" -CONF_WARNING_THRESHOLD = "warning_threshold" SHOW_DRIVING = "driving" -SHOW_MOVING = "moving" DEFAULT_OPTIONS = { CONF_DRIVING_SPEED: None, From 54ba00095be267c581c28ce2e19d76f72b6441ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 06:50:10 -1000 Subject: [PATCH 0170/1544] Close stale connections in yalexs_ble to ensure setup can proceed (#106842) --- homeassistant/components/yalexs_ble/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 11516015b6c..b5683777c24 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -10,6 +10,7 @@ from yalexs_ble import ( LockState, PushLock, YaleXSBLEError, + close_stale_connections_by_address, local_name_is_unique, ) @@ -47,6 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") + # Ensure any lingering connections are closed since the device may not be + # advertising when its connected to another client which will prevent us + # from setting the device and setup will fail. + await close_stale_connections_by_address(address) + @callback def _async_update_ble( service_info: bluetooth.BluetoothServiceInfoBleak, From 2e4c4729c410a877a0d49a6b8ea3698a7dece396 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 2 Jan 2024 08:51:15 -0800 Subject: [PATCH 0171/1544] Improve fitbit authentication error handling (#106885) --- .../components/fitbit/application_credentials.py | 2 ++ tests/components/fitbit/test_init.py | 11 +++++++++-- tests/components/fitbit/test_sensor.py | 10 +++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caa47351f45..bbd7af09183 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -69,6 +69,8 @@ class FitbitOAuth2Implementation(AuthImplementation): ) if err.status == HTTPStatus.UNAUTHORIZED: raise FitbitAuthException(f"Unauthorized error: {err}") from err + if err.status == HTTPStatus.BAD_REQUEST: + raise FitbitAuthException(f"Bad Request error: {err}") from err raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: raise FitbitApiException(f"Client connection error: {err}") from err diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 3ed3695ff3d..74312348af1 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -106,7 +106,13 @@ async def test_token_refresh_success( ) -@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.UNAUTHORIZED), + (12345, HTTPStatus.BAD_REQUEST), + ], +) @pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, @@ -114,13 +120,14 @@ async def test_token_requires_reauth( config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + server_status: HTTPStatus, closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, - status=HTTPStatus.UNAUTHORIZED, + status=server_status, closing=closing, ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 871088eae63..91aafd944b0 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -599,21 +599,25 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes"), - [(["heartrate"])], + ("scopes", "server_status"), + [ + (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), + (["heartrate"], HTTPStatus.BAD_REQUEST), + ], ) async def test_sensor_update_failed( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, + server_status: HTTPStatus, ) -> None: """Test a failed sensor update when talking to the API.""" requests_mock.register_uri( "GET", TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + status_code=server_status, ) assert await integration_setup() From afed45d5d0f7ea501924c6c51e1f24a78cf982ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 06:58:00 -1000 Subject: [PATCH 0172/1544] Replace intersection with isdisjoint in apple_tv config flow (#106633) --- homeassistant/components/apple_tv/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 6a85ea1d1a8..fc65253fe43 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -127,7 +127,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _entry_unique_id_from_identifers(self, all_identifiers: set[str]) -> str | None: """Search existing entries for an identifier and return the unique id.""" for entry in self._async_current_entries(): - if all_identifiers.intersection( + if not all_identifiers.isdisjoint( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ): return entry.unique_id @@ -326,7 +326,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): existing_identifiers = set( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ) - if not all_identifiers.intersection(existing_identifiers): + if all_identifiers.isdisjoint(existing_identifiers): continue combined_identifiers = existing_identifiers | all_identifiers if entry.data.get( From 943fb2791e1e5c84579a7793ce2523c6a444376e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 2 Jan 2024 10:50:28 -0800 Subject: [PATCH 0173/1544] Improve To-do service error handling (#106886) --- homeassistant/components/todo/__init__.py | 23 ++++++++++++++++----- homeassistant/components/todo/strings.json | 8 +++++++ tests/components/shopping_list/test_todo.py | 3 ++- tests/components/todo/test_init.py | 14 ++++++------- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 0f39d38eb46..afcb8e28f74 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -106,8 +106,11 @@ def _validate_supported_features( if desc.service_field not in call_data: continue if not supported_features or not supported_features & desc.required_feature: - raise ValueError( - f"Entity does not support setting field '{desc.service_field}'" + raise ServiceValidationError( + f"Entity does not support setting field '{desc.service_field}'", + translation_domain=DOMAIN, + translation_key="update_field_not_supported", + translation_placeholders={"service_field": desc.service_field}, ) @@ -481,7 +484,12 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> item = call.data["item"] found = _find_by_uid_or_summary(item, entity.todo_items) if not found: - raise ValueError(f"Unable to find To-do item '{item}'") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) _validate_supported_features(entity.supported_features, call.data) @@ -509,7 +517,12 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> for item in call.data.get("item", []): found = _find_by_uid_or_summary(item, entity.todo_items) if not found or not found.uid: - raise ValueError(f"Unable to find To-do item '{item}") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 3da921a8f47..5ef7a5fe35b 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -90,5 +90,13 @@ "completed": "Completed" } } + }, + "exceptions": { + "item_not_found": { + "message": "Unable to find To-do item: {item}" + }, + "update_field_not_supported": { + "message": "Entity does not support setting field: {service_field}" + } } } diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 7722bd8b6da..373c449497c 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.typing import WebSocketGenerator @@ -338,7 +339,7 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index e1440b292ee..5a8f6183cbb 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -347,12 +347,12 @@ async def test_add_item_service_raises( ({"item": ""}, vol.Invalid, "length of value must be at least 1"), ( {"item": "Submit forms", "description": "Submit tax forms"}, - ValueError, + ServiceValidationError, "does not support setting field 'description'", ), ( {"item": "Submit forms", "due_date": "2023-11-17"}, - ValueError, + ServiceValidationError, "does not support setting field 'due_date'", ), ( @@ -360,7 +360,7 @@ async def test_add_item_service_raises( "item": "Submit forms", "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, - ValueError, + ServiceValidationError, "does not support setting field 'due_datetime'", ), ], @@ -622,7 +622,7 @@ async def test_update_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "update_item", @@ -681,7 +681,7 @@ async def test_update_todo_item_field_unsupported( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="does not support"): + with pytest.raises(ServiceValidationError, match="does not support"): await hass.services.async_call( DOMAIN, "update_item", @@ -931,7 +931,7 @@ async def test_remove_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "remove_item", From dcee8e67c4b63633f78aa0827b0cca2f236888c7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:04:28 +0100 Subject: [PATCH 0174/1544] Add strict typing to command_line (#106889) * Add strict typing to command_line * Code review --- .strict-typing | 1 + homeassistant/components/command_line/__init__.py | 2 +- homeassistant/components/command_line/binary_sensor.py | 6 +++--- homeassistant/components/command_line/cover.py | 10 +++++----- homeassistant/components/command_line/sensor.py | 10 +++++----- homeassistant/components/command_line/switch.py | 8 ++++---- mypy.ini | 10 ++++++++++ 7 files changed, 29 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index b4abed22bf8..a976deed8c1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -116,6 +116,7 @@ homeassistant.components.clickatell.* homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* +homeassistant.components.command_line.* homeassistant.components.configurator.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index e1a051cea33..ba4292b5a65 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -200,7 +200,7 @@ async def async_load_platforms( load_coroutines: list[Coroutine[Any, Any, None]] = [] platforms: list[Platform] = [] - reload_configs: list[tuple] = [] + reload_configs: list[tuple[Platform, dict[str, Any]]] = [] for platform_config in command_line_config: for platform, _config in platform_config.items(): if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f559812207f..31259ddf909 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import cast from homeassistant.components.binary_sensor import ( @@ -115,7 +115,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() - await self._update_entity_state(None) + await self._update_entity_state() self.async_on_remove( async_track_time_interval( self.hass, @@ -126,7 +126,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): ), ) - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 6b413712ed7..93c007297ea 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast from homeassistant.components.cover import CoverEntity @@ -147,7 +147,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): if TYPE_CHECKING: return None - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() @@ -186,14 +186,14 @@ class CommandCover(ManualTriggerEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_open) - await self._update_entity_state(None) + await self._update_entity_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_close) - await self._update_entity_state(None) + await self._update_entity_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job(self._move_cover, self._command_stop) - await self._update_entity_state(None) + await self._update_entity_state() diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 99390e77357..c1d60b9d2fd 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta import json from typing import Any, cast @@ -108,7 +108,7 @@ class CommandSensor(ManualTriggerSensorEntity): """Initialize the sensor.""" super().__init__(self.hass, config) self.data = data - self._attr_extra_state_attributes = {} + self._attr_extra_state_attributes: dict[str, Any] = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template @@ -118,12 +118,12 @@ class CommandSensor(ManualTriggerSensorEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return extra state attributes.""" - return cast(dict, self._attr_extra_state_attributes) + return self._attr_extra_state_attributes async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() - await self._update_entity_state(None) + await self._update_entity_state() self.async_on_remove( async_track_time_interval( self.hass, @@ -134,7 +134,7 @@ class CommandSensor(ManualTriggerSensorEntity): ), ) - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8d30de310ef..0af6163312c 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity @@ -155,7 +155,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if TYPE_CHECKING: return None - async def _update_entity_state(self, now) -> None: + async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" if self._process_updates is None: self._process_updates = asyncio.Lock() @@ -197,11 +197,11 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() - await self._update_entity_state(None) + 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() - await self._update_entity_state(None) + await self._update_entity_state() diff --git a/mypy.ini b/mypy.ini index 9fd8f6a1305..7a2e16c37ed 100644 --- a/mypy.ini +++ b/mypy.ini @@ -920,6 +920,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.command_line.*] +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.configurator.*] check_untyped_defs = true disallow_incomplete_defs = true From fde03d7888e3c25e8a34cd452290f2db8e605316 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:41:39 +0100 Subject: [PATCH 0175/1544] Enable strict typing for co2signal (#106888) --- .strict-typing | 1 + homeassistant/components/co2signal/helpers.py | 6 ++++-- homeassistant/components/co2signal/util.py | 5 +++-- mypy.ini | 10 ++++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index a976deed8c1..f04d8d85e98 100644 --- a/.strict-typing +++ b/.strict-typing @@ -116,6 +116,7 @@ homeassistant.components.clickatell.* homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* +homeassistant.components.co2signal.* homeassistant.components.command_line.* homeassistant.components.configurator.* homeassistant.components.cover.* diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 937b72a357c..d64a0abb1e7 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -1,4 +1,6 @@ """Helper functions for the CO2 Signal integration.""" +from __future__ import annotations + from collections.abc import Mapping from typing import Any @@ -16,11 +18,11 @@ async def fetch_latest_carbon_intensity( ) -> CarbonIntensityResponse: """Fetch the latest carbon intensity based on country code or location coordinates.""" if CONF_COUNTRY_CODE in config: - return await em.latest_carbon_intensity_by_country_code( + return await em.latest_carbon_intensity_by_country_code( # type: ignore[no-any-return] code=config[CONF_COUNTRY_CODE] ) - return await em.latest_carbon_intensity_by_coordinates( + return await em.latest_carbon_intensity_by_coordinates( # type: ignore[no-any-return] lat=config.get(CONF_LATITUDE, hass.config.latitude), lon=config.get(CONF_LONGITUDE, hass.config.longitude), ) diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py index 68403b4803e..b588e0abef9 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -2,14 +2,15 @@ from __future__ import annotations from collections.abc import Mapping +from typing import Any from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE -def get_extra_name(config: Mapping) -> str | None: +def get_extra_name(config: Mapping[str, Any]) -> str | None: """Return the extra name describing the location if not home.""" if CONF_COUNTRY_CODE in config: - return config[CONF_COUNTRY_CODE] + return config[CONF_COUNTRY_CODE] # type: ignore[no-any-return] if CONF_LATITUDE in config: return f"{round(config[CONF_LATITUDE], 2)}, {round(config[CONF_LONGITUDE], 2)}" diff --git a/mypy.ini b/mypy.ini index 7a2e16c37ed..a65df261427 100644 --- a/mypy.ini +++ b/mypy.ini @@ -920,6 +920,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.co2signal.*] +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.command_line.*] check_untyped_defs = true disallow_incomplete_defs = true From 73b36086cf1a7835c3e51a99d947e3942fb3577c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 09:41:54 -1000 Subject: [PATCH 0176/1544] Avoid tuple construction to check HKC available (#106902) --- .../components/homekit_controller/entity.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index d1f48a67e7f..ceb1505518b 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -27,6 +27,7 @@ class HomeKitEntity(Entity): pollable_characteristics: list[tuple[int, int]] watchable_characteristics: list[tuple[int, int]] all_characteristics: set[tuple[int, int]] + all_iids: set[int] accessory_info: Service def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: @@ -149,6 +150,7 @@ class HomeKitEntity(Entity): self.pollable_characteristics = [] self.watchable_characteristics = [] self.all_characteristics = set() + self.all_iids = set() char_types = self.get_characteristic_types() @@ -164,6 +166,7 @@ class HomeKitEntity(Entity): self.all_characteristics.update(self.pollable_characteristics) self.all_characteristics.update(self.watchable_characteristics) + self.all_iids = {iid for _, iid in self.all_characteristics} def _setup_characteristic(self, char: Characteristic) -> None: """Configure an entity based on a HomeKit characteristics metadata.""" @@ -219,11 +222,11 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available and all( - c.available - for c in self.service.characteristics - if (self._aid, c.iid) in self.all_characteristics - ) + all_iids = self.all_iids + for char in self.service.characteristics: + if char.iid in all_iids and not char.available: + return False + return self._accessory.available @property def device_info(self) -> DeviceInfo: From 584b6c286233062c37c105b375dfe500eea49a1d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Jan 2024 20:42:27 +0100 Subject: [PATCH 0177/1544] Update frontend to 20240102.0 (#106898) --- 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 02a311a42ce..7579426e1e1 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==20240101.0"] + "requirements": ["home-assistant-frontend==20240102.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 869048f66ac..2a6f6411bb1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 home-assistant-intents==2023.12.05 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ac87a7336c6..22d23738d21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e843f45cc6c..2b3b11133d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240101.0 +home-assistant-frontend==20240102.0 # homeassistant.components.conversation home-assistant-intents==2023.12.05 From 9231e00561286bca1fb002470b4ed349da29e0d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 09:44:17 -1000 Subject: [PATCH 0178/1544] Update switchbot to use close_stale_connections_by_address (#106835) --- homeassistant/components/switchbot/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 445920ad276..6bad3c25142 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -98,6 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connectable means we can make connections to the device connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES address: str = entry.data[CONF_ADDRESS] + + await switchbot.close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), connectable ) @@ -106,7 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Switchbot {sensor_type} with address {address}" ) - await switchbot.close_stale_connections(ble_device) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) if cls is switchbot.SwitchbotLock: try: From 43fa51b6965819a7b35a3f20273f4f8d61f098bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:48:51 +0100 Subject: [PATCH 0179/1544] Enable strict typing for blueprint (#106887) --- .strict-typing | 1 + homeassistant/components/blueprint/models.py | 24 +++++++++---------- homeassistant/components/blueprint/schemas.py | 4 ++-- .../components/blueprint/websocket_api.py | 4 ++-- homeassistant/util/yaml/loader.py | 8 +++++-- mypy.ini | 10 ++++++++ 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index f04d8d85e98..7d7fffbd714 100644 --- a/.strict-typing +++ b/.strict-typing @@ -101,6 +101,7 @@ homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* homeassistant.components.blue_current.* +homeassistant.components.blueprint.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 63a1c1b45f0..33fb87cc578 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -90,17 +90,17 @@ class Blueprint: @property def name(self) -> str: """Return blueprint name.""" - return self.data[CONF_BLUEPRINT][CONF_NAME] + return self.data[CONF_BLUEPRINT][CONF_NAME] # type: ignore[no-any-return] @property - def inputs(self) -> dict: + def inputs(self) -> dict[str, Any]: """Return blueprint inputs.""" - return self.data[CONF_BLUEPRINT][CONF_INPUT] + return self.data[CONF_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] @property - def metadata(self) -> dict: + def metadata(self) -> dict[str, Any]: """Return blueprint metadata.""" - return self.data[CONF_BLUEPRINT] + return self.data[CONF_BLUEPRINT] # type: ignore[no-any-return] def update_metadata(self, *, source_url: str | None = None) -> None: """Update metadata.""" @@ -140,12 +140,12 @@ class BlueprintInputs: self.config_with_inputs = config_with_inputs @property - def inputs(self): + def inputs(self) -> dict[str, Any]: """Return the inputs.""" - return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] + return self.config_with_inputs[CONF_USE_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] @property - def inputs_with_default(self): + def inputs_with_default(self) -> dict[str, Any]: """Return the inputs and fallback to defaults.""" no_input = set(self.blueprint.inputs) - set(self.inputs) @@ -212,7 +212,7 @@ class DomainBlueprints: async with self._load_lock: self._blueprints = {} - def _load_blueprint(self, blueprint_path) -> Blueprint: + def _load_blueprint(self, blueprint_path: str) -> Blueprint: """Load a blueprint.""" try: blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) @@ -262,7 +262,7 @@ class DomainBlueprints: async def async_get_blueprint(self, blueprint_path: str) -> Blueprint: """Get a blueprint.""" - def load_from_cache(): + def load_from_cache() -> Blueprint: """Load blueprint from cache.""" if (blueprint := self._blueprints[blueprint_path]) is None: raise FailedToLoad( @@ -337,7 +337,7 @@ class DomainBlueprints: return exists async def async_add_blueprint( - self, blueprint: Blueprint, blueprint_path: str, allow_override=False + self, blueprint: Blueprint, blueprint_path: str, allow_override: bool = False ) -> bool: """Add a blueprint.""" overrides_existing = await self.hass.async_add_executor_job( @@ -359,7 +359,7 @@ class DomainBlueprints: integration = await loader.async_get_integration(self.hass, self.domain) - def populate(): + def populate() -> None: if self.blueprint_folder.exists(): return diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index c8271cc700d..fd3aa967336 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -25,7 +25,7 @@ from .const import ( ) -def version_validator(value): +def version_validator(value: Any) -> str: """Validate a Home Assistant version.""" if not isinstance(value, str): raise vol.Invalid("Version needs to be a string") @@ -36,7 +36,7 @@ def version_validator(value): raise vol.Invalid("Version needs to be formatted as {major}.{minor}.{patch}") try: - parts = [int(p) for p in parts] + [int(p) for p in parts] except ValueError: raise vol.Invalid( "Major, minor and patch version needs to be an integer" diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 3c7cc3769c8..1989f0f563c 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -18,7 +18,7 @@ from .errors import FailedToLoad, FileAlreadyExists @callback -def async_setup(hass: HomeAssistant): +def async_setup(hass: HomeAssistant) -> None: """Set up the websocket API.""" websocket_api.async_register_command(hass, ws_list_blueprints) websocket_api.async_register_command(hass, ws_import_blueprint) @@ -76,7 +76,7 @@ async def ws_import_blueprint( imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) if imported_blueprint is None: - connection.send_error( + connection.send_error( # type: ignore[unreachable] msg["id"], websocket_api.ERR_NOT_SUPPORTED, "This url is not supported" ) return diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 60e917a6a99..97dbb7d8789 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -215,7 +215,9 @@ class SafeLineLoader(PythonSafeLoader): LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: +def load_yaml( + fname: str | os.PathLike[str], secrets: Secrets | None = None +) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -225,7 +227,9 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: raise HomeAssistantError(exc) from exc -def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: +def load_yaml_dict( + fname: str | os.PathLike[str], secrets: Secrets | None = None +) -> dict: """Load a YAML file and ensure the top level is a dict. Raise if the top level is not a dict. diff --git a/mypy.ini b/mypy.ini index a65df261427..e7323b1cd07 100644 --- a/mypy.ini +++ b/mypy.ini @@ -770,6 +770,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blueprint.*] +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.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true From e1f078b70aa9097436603ea7b250ac8c223feb53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 09:50:26 -1000 Subject: [PATCH 0180/1544] Bump aiohttp-zlib-ng to 0.2.0 (#106691) --- .github/workflows/wheels.yml | 8 ++++---- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b23f1b5b05..de25640b9b6 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -106,7 +106,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev" + apk: "libffi-dev;openssl-dev;yaml-dev;nasm" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 399cbf70ad7..15c84974c13 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3" + "aiohttp-zlib-ng==0.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2a6f6411bb1..f2144038a8b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.2.0 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index f611cc73f1b..1d8dc736aaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3", + "aiohttp-zlib-ng==0.2.0", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 55cbdc31730..f845883b331 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.2.0 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 22d23738d21..8ad4f9e059e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.1 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.2.0 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b3b11133d0..edf1af21ff9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.1 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.2.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 1526c321f162d214009a923611bbfeb2965c32f4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 20:55:59 +0100 Subject: [PATCH 0181/1544] Enable strict typing for axis (#106844) --- .strict-typing | 1 + .../components/axis/binary_sensor.py | 4 +- homeassistant/components/axis/device.py | 55 +++++++++++-------- mypy.ini | 10 ++++ 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/.strict-typing b/.strict-typing index 7d7fffbd714..51164a552ab 100644 --- a/.strict-typing +++ b/.strict-typing @@ -94,6 +94,7 @@ homeassistant.components.asuswrt.* homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* +homeassistant.components.axis.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bayesian.* diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 4cc81947e27..d68de7742dc 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta from axis.models.event import Event, EventGroup, EventOperation, EventTopic @@ -81,7 +81,7 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): self._attr_is_on = event.is_tripped @callback - def scheduled_update(now): + def scheduled_update(now: datetime) -> None: """Timer callback for sensor update.""" self.cancel_scheduled_update = None self.async_write_ha_state() diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 0c132814e39..243ad90d4a3 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_TRIGGER_TIME, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -65,80 +65,87 @@ class AxisNetworkDevice: self.additional_diagnostics: dict[str, Any] = {} @property - def host(self): + def host(self) -> str: """Return the host address of this device.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def port(self): + def port(self) -> int: """Return the HTTP port of this device.""" - return self.config_entry.data[CONF_PORT] + port: int = self.config_entry.data[CONF_PORT] + return port @property - def username(self): + def username(self) -> str: """Return the username of this device.""" - return self.config_entry.data[CONF_USERNAME] + username: str = self.config_entry.data[CONF_USERNAME] + return username @property - def password(self): + def password(self) -> str: """Return the password of this device.""" - return self.config_entry.data[CONF_PASSWORD] + password: str = self.config_entry.data[CONF_PASSWORD] + return password @property - def model(self): + def model(self) -> str: """Return the model of this device.""" - return self.config_entry.data[CONF_MODEL] + model: str = self.config_entry.data[CONF_MODEL] + return model @property - def name(self): + def name(self) -> str: """Return the name of this device.""" - return self.config_entry.data[CONF_NAME] + name: str = self.config_entry.data[CONF_NAME] + return name @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID (serial number) of this device.""" - return self.config_entry.unique_id + assert (unique_id := self.config_entry.unique_id) + return unique_id # Options @property - def option_events(self): + def option_events(self) -> bool: """Config entry option defining if platforms based on events should be created.""" return self.config_entry.options.get(CONF_EVENTS, DEFAULT_EVENTS) @property - def option_stream_profile(self): + def option_stream_profile(self) -> str: """Config entry option defining what stream profile camera platform should use.""" return self.config_entry.options.get( CONF_STREAM_PROFILE, DEFAULT_STREAM_PROFILE ) @property - def option_trigger_time(self): + def option_trigger_time(self) -> int: """Config entry option defining minimum number of seconds to keep trigger high.""" return self.config_entry.options.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME) @property - def option_video_source(self): + def option_video_source(self) -> str: """Config entry option defining what video source camera platform should use.""" return self.config_entry.options.get(CONF_VIDEO_SOURCE, DEFAULT_VIDEO_SOURCE) # Signals @property - def signal_reachable(self): + def signal_reachable(self) -> str: """Device specific event to signal a change in connection status.""" return f"axis_reachable_{self.unique_id}" @property - def signal_new_address(self): + def signal_new_address(self) -> str: """Device specific event to signal a change in device address.""" return f"axis_new_address_{self.unique_id}" # Callbacks @callback - def async_connection_status_callback(self, status): + def async_connection_status_callback(self, status: Signal) -> None: """Handle signals of device connection status. This is called on every RTSP keep-alive message. @@ -202,7 +209,7 @@ class AxisNetworkDevice: # Setup and teardown methods - def async_setup_events(self): + def async_setup_events(self) -> None: """Set up the device events.""" if self.option_events: @@ -222,7 +229,7 @@ class AxisNetworkDevice: self.api.stream.connection_status_callback.clear() self.api.stream.stop() - async def shutdown(self, event) -> None: + async def shutdown(self, event: Event) -> None: """Stop the event stream.""" self.disconnect_from_stream() diff --git a/mypy.ini b/mypy.ini index e7323b1cd07..00ea7662ed6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -700,6 +700,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.axis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.backup.*] check_untyped_defs = true disallow_incomplete_defs = true From f0d520d91ff388b04c61f9f8892596a16a382c52 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Jan 2024 22:01:12 +0100 Subject: [PATCH 0182/1544] Remove assert for unique_id (#106910) * Remove assert for unique_id * Use str | None return instead --- homeassistant/components/axis/device.py | 9 ++++----- homeassistant/components/axis/entity.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 243ad90d4a3..67ef61af8ac 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -101,10 +101,9 @@ class AxisNetworkDevice: return name @property - def unique_id(self) -> str: + def unique_id(self) -> str | None: """Return the unique ID (serial number) of this device.""" - assert (unique_id := self.config_entry.unique_id) - return unique_id + return self.config_entry.unique_id # Options @@ -176,8 +175,8 @@ class AxisNetworkDevice: device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, configuration_url=self.api.config.url, - connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, - identifiers={(AXIS_DOMAIN, self.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, # type: ignore[arg-type] + identifiers={(AXIS_DOMAIN, self.unique_id)}, # type: ignore[arg-type] manufacturer=ATTR_MANUFACTURER, model=f"{self.model} {self.product_type}", name=self.name, diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index 5a1fede53c7..81f0b1678fb 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -42,7 +42,7 @@ class AxisEntity(Entity): self.device = device self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, device.unique_id)}, + identifiers={(AXIS_DOMAIN, device.unique_id)}, # type: ignore[arg-type] serial_number=device.unique_id, ) From 5003993658d34c0109276a391c44e9fe1d91cb3b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 2 Jan 2024 15:35:48 -0600 Subject: [PATCH 0183/1544] Bump intents to 2024.1.2 (#106909) --- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 8 ++--- tests/components/conversation/test_init.py | 30 +++++++++---------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index cb03499d8e4..5f0c7b171ae 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f2144038a8b..b4808bd8ce0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20240102.0 -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8ad4f9e059e..9daea15640c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1041,7 +1041,7 @@ holidays==0.39 home-assistant-frontend==20240102.0 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edf1af21ff9..33bcb911666 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -834,7 +834,7 @@ holidays==0.39 home-assistant-frontend==20240102.0 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f7145a9ab56..35d967f37da 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -249,7 +249,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -279,7 +279,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -309,7 +309,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -339,7 +339,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index fdbf10b0c7f..0f47f9ac3d9 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -85,7 +85,7 @@ async def test_http_processing_intent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -135,7 +135,7 @@ async def test_http_processing_intent_target_ha_agent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -186,7 +186,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -222,7 +222,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -255,7 +255,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -331,7 +331,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -364,7 +364,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -449,7 +449,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -483,7 +483,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -540,7 +540,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -624,7 +624,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -656,7 +656,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -740,7 +740,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -769,7 +769,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -855,7 +855,7 @@ async def test_http_processing_intent_conversion_not_expose_new( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, From 87c79ef57fff88a635fa59879f7be8572396d1d8 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 2 Jan 2024 23:23:50 +0100 Subject: [PATCH 0184/1544] Add tedee bridge as via_device for tedee integration (#106914) * add bridge as via_device * add bridge as via_device * move getting bridge to update_data * add bridge property --- homeassistant/components/tedee/__init__.py | 11 +++++++ homeassistant/components/tedee/coordinator.py | 23 +++++++++++---- homeassistant/components/tedee/entity.py | 1 + .../components/tedee/snapshots/test_init.ambr | 29 +++++++++++++++++++ .../components/tedee/snapshots/test_lock.ambr | 4 +-- tests/components/tedee/test_init.py | 23 ++++++++++++++- 6 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 tests/components/tedee/snapshots/test_init.ambr diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 1eb6b7a0333..eeb0f8e0d5a 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -4,6 +4,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import TedeeApiCoordinator @@ -24,6 +25,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.bridge.serial)}, + manufacturer="Tedee", + name=coordinator.bridge.name, + model="Bridge", + serial_number=coordinator.bridge.serial, + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 18fca035532..90539f881c3 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -11,6 +11,7 @@ from pytedee_async import ( TedeeLocalAuthException, TedeeLock, ) +from pytedee_async.bridge import TedeeBridge from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -40,6 +41,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): update_interval=SCAN_INTERVAL, ) + self._bridge: TedeeBridge | None = None self.tedee_client = TedeeClient( local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN], local_ip=self.config_entry.data[CONF_HOST], @@ -47,18 +49,31 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() + @property + def bridge(self) -> TedeeBridge: + """Return bridge.""" + assert self._bridge + return self._bridge + async def _async_update_data(self) -> dict[int, TedeeLock]: """Fetch data from API endpoint.""" + if self._bridge is None: + + async def _async_get_bridge() -> None: + self._bridge = await self.tedee_client.get_local_bridge() + + _LOGGER.debug("Update coordinator: Getting bridge from API") + await self._async_update(_async_get_bridge) _LOGGER.debug("Update coordinator: Getting locks from API") # once every hours get all lock details, otherwise use the sync endpoint if self._next_get_locks <= time.time(): _LOGGER.debug("Updating through /my/lock endpoint") - await self._async_update_locks(self.tedee_client.get_locks) + await self._async_update(self.tedee_client.get_locks) self._next_get_locks = time.time() + GET_LOCKS_INTERVAL_SECONDS else: _LOGGER.debug("Updating through /sync endpoint") - await self._async_update_locks(self.tedee_client.sync) + await self._async_update(self.tedee_client.sync) _LOGGER.debug( "available_locks: %s", @@ -67,9 +82,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): return self.tedee_client.locks_dict - async def _async_update_locks( - self, update_fn: Callable[[], Awaitable[None]] - ) -> None: + async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None: """Update locks based on update function.""" try: await update_fn() diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 86baa81b452..6dfcbebe3de 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -32,6 +32,7 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): name=lock.lock_name, manufacturer="Tedee", model=lock.lock_type, + via_device=(DOMAIN, coordinator.bridge.serial), ) @callback diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr new file mode 100644 index 00000000000..e10a9f298bb --- /dev/null +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_bridge_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tedee', + '0000-0000', + ), + }), + 'is_new': False, + 'manufacturer': 'Tedee', + 'model': 'Bridge', + 'name': 'Bridge-AB1C', + 'name_by_user': None, + 'serial_number': '0000-0000', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index b7c20f39750..dd0eab46c90 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -68,7 +68,7 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': None, - 'via_device_id': None, + 'via_device_id': , }) # --- # name: test_lock_without_pullspring @@ -140,6 +140,6 @@ 'serial_number': None, 'suggested_area': None, 'sw_version': None, - 'via_device_id': None, + 'via_device_id': , }) # --- diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 874a827e458..ca64c01a983 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -3,9 +3,11 @@ from unittest.mock import MagicMock from pytedee_async.exception import TedeeAuthException, TedeeClientException import pytest +from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -37,7 +39,7 @@ async def test_config_entry_not_ready( mock_tedee: MagicMock, side_effect: Exception, ) -> None: - """Test the LaMetric configuration entry not ready.""" + """Test the Tedee configuration entry not ready.""" mock_tedee.get_locks.side_effect = side_effect mock_config_entry.add_to_hass(hass) @@ -46,3 +48,22 @@ async def test_config_entry_not_ready( assert len(mock_tedee.get_locks.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_bridge_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure the bridge device is registered.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + {(mock_config_entry.domain, mock_tedee.get_local_bridge.return_value.serial)} + ) + assert device + assert device == snapshot From 608d52f167d64e3e0a1a7a37a5d768df1b2373af Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Tue, 2 Jan 2024 23:40:38 +0100 Subject: [PATCH 0185/1544] Add translatable title to holiday (#106825) --- homeassistant/components/holiday/strings.json | 1 + homeassistant/generated/integrations.json | 2 +- script/hassfest/translations.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index 4762a48c659..53d403e790e 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -1,4 +1,5 @@ { + "title": "Holiday", "config": { "abort": { "already_configured": "Already configured. Only a single configuration for country/province combination possible." diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c55b6aecce9..6df3dc5cbd6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2453,7 +2453,6 @@ "iot_class": "local_push" }, "holiday": { - "name": "Holiday", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -6971,6 +6970,7 @@ "google_travel_time", "group", "growatt_server", + "holiday", "homekit_controller", "input_boolean", "input_button", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index fa2956dd47d..738ebcb260a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -33,6 +33,7 @@ ALLOW_NAME_TRANSLATION = { "garages_amsterdam", "generic", "google_travel_time", + "holiday", "homekit_controller", "islamic_prayer_times", "local_calendar", From f66438b0ceaa1d75a64e4c7dda73854865fa0f3c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 2 Jan 2024 23:47:32 +0100 Subject: [PATCH 0186/1544] Remove group_members from significant attributes in media player (#106916) --- homeassistant/components/media_player/significant_change.py | 2 -- tests/components/media_player/test_significant_change.py | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index adc96fc8b83..43a253d9220 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -11,7 +11,6 @@ from homeassistant.helpers.significant_change import ( from . import ( ATTR_ENTITY_PICTURE_LOCAL, - ATTR_GROUP_MEMBERS, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_VOLUME_LEVEL, @@ -25,7 +24,6 @@ INSIGNIFICANT_ATTRIBUTES: set[str] = { SIGNIFICANT_ATTRIBUTES: set[str] = { ATTR_ENTITY_PICTURE_LOCAL, - ATTR_GROUP_MEMBERS, *ATTR_TO_PROPERTY, } - INSIGNIFICANT_ATTRIBUTES diff --git a/tests/components/media_player/test_significant_change.py b/tests/components/media_player/test_significant_change.py index 1b0ac6fe5aa..233f133c342 100644 --- a/tests/components/media_player/test_significant_change.py +++ b/tests/components/media_player/test_significant_change.py @@ -51,7 +51,11 @@ async def test_significant_state_change() -> None: {ATTR_ENTITY_PICTURE_LOCAL: "new_value"}, True, ), - ({ATTR_GROUP_MEMBERS: "old_value"}, {ATTR_GROUP_MEMBERS: "new_value"}, True), + ( + {ATTR_GROUP_MEMBERS: ["old1", "old2"]}, + {ATTR_GROUP_MEMBERS: ["old1", "new"]}, + False, + ), ({ATTR_INPUT_SOURCE: "old_value"}, {ATTR_INPUT_SOURCE: "new_value"}, True), ( {ATTR_MEDIA_ALBUM_ARTIST: "old_value"}, From 711498793a370f3cd532f70ae4a709c5ab29fbef Mon Sep 17 00:00:00 2001 From: Joe Neuman Date: Tue, 2 Jan 2024 15:19:00 -0800 Subject: [PATCH 0187/1544] Fix qBittorrent torrent count when empty (#106903) * Fix qbittorrent torrent cound when empty * lint fix * Change based on comment --- homeassistant/components/qbittorrent/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 9373aec8544..78e8ba59d44 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -165,6 +165,10 @@ def count_torrents_in_states( coordinator: QBittorrentDataCoordinator, states: list[str] ) -> int: """Count the number of torrents in specified states.""" + # When torrents are not in the returned data, there are none, return 0. + if "torrents" not in coordinator.data: + return 0 + if not states: return len(coordinator.data["torrents"]) From 938c32d35ecc6fc92003202ac6475967b836cea7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 3 Jan 2024 06:40:42 +0100 Subject: [PATCH 0188/1544] Avoid triggering ping device tracker `home` after restore (#106913) --- homeassistant/components/ping/device_tracker.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index d627082a499..6b904043b30 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -136,7 +136,7 @@ async def async_setup_entry( class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" - _first_offline: datetime | None = None + _last_seen: datetime | None = None def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator @@ -171,14 +171,12 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) def is_connected(self) -> bool: """Return true if ping returns is_alive or considered home.""" if self.coordinator.data.is_alive: - self._first_offline = None - return True + self._last_seen = dt_util.utcnow() - now = dt_util.utcnow() - if self._first_offline is None: - self._first_offline = now - - return (self._first_offline + self._consider_home_interval) > now + return ( + self._last_seen is not None + and (dt_util.utcnow() - self._last_seen) < self._consider_home_interval + ) @property def entity_registry_enabled_default(self) -> bool: From 32b6e4d5de80c1b7aa73bf9459a7a22fdff57e8c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 3 Jan 2024 08:52:41 +0100 Subject: [PATCH 0189/1544] Bump aioelectricitymaps to v0.1.6 (#106932) --- homeassistant/components/co2signal/helpers.py | 4 ++-- homeassistant/components/co2signal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index d64a0abb1e7..f61fadaf88c 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -18,11 +18,11 @@ async def fetch_latest_carbon_intensity( ) -> CarbonIntensityResponse: """Fetch the latest carbon intensity based on country code or location coordinates.""" if CONF_COUNTRY_CODE in config: - return await em.latest_carbon_intensity_by_country_code( # type: ignore[no-any-return] + return await em.latest_carbon_intensity_by_country_code( code=config[CONF_COUNTRY_CODE] ) - return await em.latest_carbon_intensity_by_coordinates( # type: ignore[no-any-return] + return await em.latest_carbon_intensity_by_coordinates( lat=config.get(CONF_LATITUDE, hass.config.latitude), lon=config.get(CONF_LONGITUDE, hass.config.longitude), ) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index f91232c1a28..c2e70bdb21e 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.1.5"] + "requirements": ["aioelectricitymaps==0.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9daea15640c..e7c8bad22c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.5 +aioelectricitymaps==0.1.6 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33bcb911666..f44323bad1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.5 +aioelectricitymaps==0.1.6 # homeassistant.components.emonitor aioemonitor==1.0.5 From b3f997156a7a52f84eb8743e6ab048485a19bf39 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 3 Jan 2024 09:06:26 +0100 Subject: [PATCH 0190/1544] Enable strict typing for counter (#106906) --- .strict-typing | 1 + homeassistant/components/counter/__init__.py | 14 ++++++++------ mypy.ini | 10 ++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index 51164a552ab..e46f439e4ca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -121,6 +121,7 @@ homeassistant.components.cloud.* homeassistant.components.co2signal.* homeassistant.components.command_line.* homeassistant.components.configurator.* +homeassistant.components.counter.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 42676498c9f..7d69025fb97 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Self +from typing import Any, Self, TypeVar import voluptuous as vol @@ -22,6 +22,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +_T = TypeVar("_T") + _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = "initial" @@ -59,7 +61,7 @@ STORAGE_FIELDS = { } -def _none_to_empty_dict(value): +def _none_to_empty_dict(value: _T | None) -> _T | dict[str, Any]: if value is None: return {} return value @@ -140,12 +142,12 @@ class CounterStorageCollection(collection.DictStorageCollection): async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" - return self.CREATE_UPDATE_SCHEMA(data) + return self.CREATE_UPDATE_SCHEMA(data) # type: ignore[no-any-return] @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return info[CONF_NAME] # type: ignore[no-any-return] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" @@ -211,9 +213,9 @@ class Counter(collection.CollectionEntity, RestoreEntity): @property def unique_id(self) -> str | None: """Return unique id of the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] - def compute_next_state(self, state) -> int: + def compute_next_state(self, state: int | None) -> int | None: """Keep the state within the range of min/max values.""" if self._config[CONF_MINIMUM] is not None: state = max(self._config[CONF_MINIMUM], state) diff --git a/mypy.ini b/mypy.ini index 00ea7662ed6..53f5b0715ce 100644 --- a/mypy.ini +++ b/mypy.ini @@ -970,6 +970,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.counter.*] +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.cover.*] check_untyped_defs = true disallow_incomplete_defs = true From 710e55fb09f51965bfdb4fea77168a0f39511da4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 22:12:35 -1000 Subject: [PATCH 0191/1544] Bump SQLAlchemy to 2.0.25 (#106931) * Bump SQLAlchemy to 2.0.25 changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.25 * drop unused ignore now that upstream is fixed --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/recorder/statistics.py | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 6f3371681e5..13ba7400952 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.24", + "SQLAlchemy==2.0.25", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8f932ecf499..8348933769c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -101,7 +101,7 @@ QUERY_STATISTICS_SHORT_TERM = ( StatisticsShortTerm.sum, ) -QUERY_STATISTICS_SUMMARY_MEAN = ( # type: ignore[var-annotated] +QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, func.avg(StatisticsShortTerm.mean), func.min(StatisticsShortTerm.min), diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 0409f2cdf6f..1188a9ec05e 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.24", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.25", "sqlparse==0.4.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b4808bd8ce0..041dfab96f3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.24 +SQLAlchemy==2.0.25 typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 urllib3>=1.26.5,<2 diff --git a/requirements_all.txt b/requirements_all.txt index e7c8bad22c3..8d80015fce1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -128,7 +128,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.24 +SQLAlchemy==2.0.25 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f44323bad1b..501d3208e1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -113,7 +113,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.24 +SQLAlchemy==2.0.25 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 From 7b3ec60f904b441903a0fe1fc1e3fc4212191de9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 2 Jan 2024 22:13:23 -1000 Subject: [PATCH 0192/1544] Speed up getting the mean of statistics (#106930) All the values we need to get the mean for are always list[float] so we can use a much simpler algorithm to get the mean of the list --- homeassistant/components/recorder/statistics.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8348933769c..023f94ec88e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -11,7 +11,6 @@ from itertools import chain, groupby import logging from operator import itemgetter import re -from statistics import mean from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text @@ -145,6 +144,17 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" +def mean(values: list[float]) -> float | None: + """Return the mean of the values. + + This is a very simple version that only works + with a non-empty list of floats. The built-in + statistics.mean is more robust but is is almost + an order of magnitude slower. + """ + return sum(values) / len(values) + + _LOGGER = logging.getLogger(__name__) From 59a01fcf9c42a233ecaa92c6328116128569881c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 3 Jan 2024 09:15:39 +0100 Subject: [PATCH 0193/1544] Add try-catch for invalid auth to Tado (#106774) Co-authored-by: Martin Hjelmare --- homeassistant/components/tado/config_flow.py | 3 +++ .../components/tado/device_tracker.py | 2 ++ homeassistant/components/tado/strings.json | 6 ++++- tests/components/tado/test_config_flow.py | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 3e183b0a9b5..f9f4f80bde1 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any +import PyTado from PyTado.interface import Tado import requests.exceptions import voluptuous as vol @@ -136,6 +137,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except exceptions.HomeAssistantError: return self.async_abort(reason="import_failed") + except PyTado.exceptions.TadoWrongCredentialsException: + return self.async_abort(reason="import_failed_invalid_auth") home_id = validate_result[UNIQUE_ID] await self.async_set_unique_id(home_id) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 426c7d9ed5d..c10ab118060 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -55,6 +55,8 @@ async def async_get_scanner( translation_key = "import_aborted" if import_result.get("reason") == "import_failed": translation_key = "import_failed" + if import_result.get("reason") == "import_failed_invalid_auth": + translation_key = "import_failed_invalid_auth" async_create_issue( hass, diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 157b98e33ea..d50d1490566 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -133,9 +133,13 @@ "title": "Import aborted", "description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado." }, - "failed_to_import": { + "import_failed": { "title": "Failed to import", "description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration." + }, + "import_failed_invalid_auth": { + "title": "Failed to import, invalid credentials", + "description": "Failed to import the configuration for the Tado Device Tracker, due to invalid credentials. Please fix the YAML configuration and restart Home Assistant. Alternatively you can use the UI to configure Tado. Don't forget to delete the YAML configuration, once the import is successful." } } } diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index d83a4b22efc..ac04777dc1c 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from ipaddress import ip_address from unittest.mock import MagicMock, patch +import PyTado import pytest import requests @@ -346,6 +347,27 @@ async def test_import_step_validation_failed(hass: HomeAssistant) -> None: assert result["reason"] == "import_failed" +async def test_import_step_device_authentication_failed(hass: HomeAssistant) -> None: + """Test import step with device tracker authentication failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=PyTado.exceptions.TadoWrongCredentialsException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed_invalid_auth" + + async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: """Test import step with unique ID already configured.""" entry = MockConfigEntry( From 8ad66c11b0436efc3ac170563e8ec6e314bdeb34 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 3 Jan 2024 10:30:32 +0100 Subject: [PATCH 0194/1544] Change Tado deprecation version to 2024.7.0 (#106938) Change version to 2024.7.0 --- homeassistant/components/tado/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index c10ab118060..9c50318639d 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -62,7 +62,7 @@ async def async_get_scanner( hass, DOMAIN, "deprecated_yaml_import_device_tracker", - breaks_in_ha_version="2024.6.0", + breaks_in_ha_version="2024.7.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key=translation_key, From be6ceb020e0687d95de05ba97a7a299007da36ad Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 11:32:48 +0100 Subject: [PATCH 0195/1544] Update frontend to 20240103.0 (#106942) --- 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 7579426e1e1..42cd3eb1f33 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==20240102.0"] + "requirements": ["home-assistant-frontend==20240103.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 041dfab96f3..8f479443eda 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8d80015fce1..7356af03c13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 501d3208e1e..42438e2fe34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240102.0 +home-assistant-frontend==20240103.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From c90f6f2feacc34c43547dcee1f0424a0b8f87def Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 3 Jan 2024 12:29:05 +0100 Subject: [PATCH 0196/1544] Fix creating cloud hook twice for mobile_app (#106945) --- homeassistant/components/mobile_app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 94d268f9412..cb5c0ae5c3d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -118,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await create_cloud_hook() if ( - CONF_CLOUDHOOK_URL not in registration + CONF_CLOUDHOOK_URL not in entry.data and cloud.async_active_subscription(hass) and cloud.async_is_connected(hass) ): From a7ec78601eff06d090f352fe0f9df3f5ea769764 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 13:18:09 +0100 Subject: [PATCH 0197/1544] Update frontend to 20240103.1 (#106948) --- 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 42cd3eb1f33..9a753edd059 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==20240103.0"] + "requirements": ["home-assistant-frontend==20240103.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8f479443eda..54edc94f1b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7356af03c13..78be380a7e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42438e2fe34..339652524d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.0 +home-assistant-frontend==20240103.1 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From 40d4061fc5078d4595ec15652abdbb4915adeeb3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 3 Jan 2024 13:42:55 +0100 Subject: [PATCH 0198/1544] Only set precision in modbus if not configured. (#106952) Only set precision if not configured. --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/base_platform.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 89a50862b6c..cc1b3c74356 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -190,7 +190,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, - vol.Optional(CONF_PRECISION, default=0): cv.positive_int, + vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( CONF_SWAP, ): vol.In( diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 1c7c8f65140..d3ec06bbdd7 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -185,10 +185,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._swap = config[CONF_SWAP] self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] - self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] - if self._scale < 1 and not self._precision: - self._precision = 2 + self._precision = config.get(CONF_PRECISION, 2 if self._scale < 1 else 0) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 From df97b0e945c8d6334b86a2b5102168a1149dda5e Mon Sep 17 00:00:00 2001 From: CR-Tech <41435902+crug80@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:44:14 +0100 Subject: [PATCH 0199/1544] Removed double assignment of _attr_target_temperature_step in __init__ (#106611) Removed double/wrong assignement of _attr_target_temperature_step into climate Init routine --- homeassistant/components/modbus/climate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 76132014413..aa345324dc8 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -125,7 +125,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): ) self._attr_min_temp = config[CONF_MIN_TEMP] self._attr_max_temp = config[CONF_MAX_TEMP] - self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] if CONF_HVAC_MODE_REGISTER in config: From e2b2732a9097ee32b24e3eaaecedcd1f88b1ecba Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 3 Jan 2024 14:43:17 +0100 Subject: [PATCH 0200/1544] Set precision to halves in flexit_bacnet (#106959) flexit_bacnet: set precision to halves for target temperature --- homeassistant/components/flexit_bacnet/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 28f4a6ae178..c15cb59a6f3 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -65,7 +65,7 @@ class FlexitClimateEntity(ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) - _attr_target_temperature_step = PRECISION_WHOLE + _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, device: FlexitBACnet) -> None: From d071299233f1ec5e8439acf3591dc8adeb4287e7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Jan 2024 15:28:22 +0100 Subject: [PATCH 0201/1544] Update frontend to 20240103.3 (#106963) --- 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 9a753edd059..52f3932237b 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==20240103.1"] + "requirements": ["home-assistant-frontend==20240103.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54edc94f1b5..9038f448529 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.1 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 78be380a7e6..a61f4898343 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 339652524d4..cfe8464d543 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.1 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From eb01998395ef091d11dabc66d940ca9c53e3fe70 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 3 Jan 2024 17:34:47 +0100 Subject: [PATCH 0202/1544] Add support for placeholders in entity name translations (#104453) * add placeholder support to entity name translation * add negativ tests * make property also available via description * fix doc string in translation_placeholders() * fix detection of placeholder * validate placeholders for localized strings * add test * Cache translation_placeholders property * Make translation_placeholders uncondotionally return dict * Fall back to unsubstituted name in case of mismatch * Only replace failing translations with English * Update snapshots * Blow up on non stable releases * Fix test * Update entity.py --------- Co-authored-by: Erik Co-authored-by: Joost Lekkerkerker --- homeassistant/helpers/entity.py | 49 ++++- homeassistant/helpers/translation.py | 49 ++++- tests/helpers/snapshots/test_entity.ambr | 51 +++-- tests/helpers/test_entity.py | 197 ++++++++++++++++++ tests/helpers/test_translation.py | 71 +++++++ .../test/translations/de.json | 10 + .../test/translations/en.json | 11 + .../test/translations/es.json | 11 + 8 files changed, 425 insertions(+), 24 deletions(-) create mode 100644 tests/testing_config/custom_components/test/translations/de.json create mode 100644 tests/testing_config/custom_components/test/translations/en.json create mode 100644 tests/testing_config/custom_components/test/translations/es.json diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3c3c8474e67..ea0267b21db 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -43,7 +43,13 @@ from homeassistant.const import ( STATE_UNKNOWN, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + callback, + get_release_channel, +) from homeassistant.exceptions import ( HomeAssistantError, InvalidStateError, @@ -245,6 +251,7 @@ class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): has_entity_name: bool = False name: str | UndefinedType | None = UNDEFINED translation_key: str | None = None + translation_placeholders: Mapping[str, str] | None = None unit_of_measurement: str | None = None @@ -429,6 +436,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "state", "supported_features", "translation_key", + "translation_placeholders", "unique_id", "unit_of_measurement", } @@ -473,6 +481,9 @@ class Entity( # If we reported this entity was added without its platform set _no_platform_reported = False + # If we reported the name translation placeholders do not match the name + _name_translation_placeholders_reported = False + # Protect for multiple updates _update_staged = False @@ -537,6 +548,7 @@ class Entity( _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None _attr_translation_key: str | None + _attr_translation_placeholders: Mapping[str, str] _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None @@ -628,6 +640,29 @@ class Entity( f".{self.translation_key}.name" ) + def _substitute_name_placeholders(self, name: str) -> str: + """Substitute placeholders in entity name.""" + try: + return name.format(**self.translation_placeholders) + except KeyError as err: + if not self._name_translation_placeholders_reported: + if get_release_channel() != "stable": + raise HomeAssistantError("Missing placeholder %s" % err) from err + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) has translation placeholders '%s' which do not " + "match the name '%s', please %s" + ), + self.entity_id, + type(self), + self.translation_placeholders, + name, + report_issue, + ) + self._name_translation_placeholders_reported = True + return name + def _name_internal( self, device_class_name: str | None, @@ -643,7 +678,7 @@ class Entity( ): if TYPE_CHECKING: assert isinstance(name, str) - return name + return self._substitute_name_placeholders(name) if hasattr(self, "entity_description"): description_name = self.entity_description.name if description_name is UNDEFINED and self._default_to_device_class_name(): @@ -853,6 +888,16 @@ class Entity( return self.entity_description.translation_key return None + @final + @cached_property + def translation_placeholders(self) -> Mapping[str, str]: + """Return the translation placeholders for translated entity's name.""" + if hasattr(self, "_attr_translation_placeholders"): + return self._attr_translation_placeholders + if hasattr(self, "entity_description"): + return self.entity_description.translation_placeholders or {} + return {} + # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index eac5cdb0a3f..4e13707257b 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping import logging +import string from typing import Any from homeassistant.core import HomeAssistant, callback @@ -242,6 +243,42 @@ class _TranslationCache: self.loaded[language].update(components) + def _validate_placeholders( + self, + language: str, + updated_resources: dict[str, Any], + cached_resources: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Validate if updated resources have same placeholders as cached resources.""" + if cached_resources is None: + return updated_resources + + mismatches: set[str] = set() + + for key, value in updated_resources.items(): + if key not in cached_resources: + continue + tuples = list(string.Formatter().parse(value)) + updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + + tuples = list(string.Formatter().parse(cached_resources[key])) + cached_placeholders = {tup[1] for tup in tuples if tup[1] is not None} + if updated_placeholders != cached_placeholders: + _LOGGER.error( + ( + "Validation of translation placeholders for localized (%s) string " + "%s failed" + ), + language, + key, + ) + mismatches.add(key) + + for mismatch in mismatches: + del updated_resources[mismatch] + + return updated_resources + @callback def _build_category_cache( self, @@ -274,12 +311,14 @@ class _TranslationCache: ).setdefault(category, {}) if isinstance(resource, dict): - category_cache.update( - recursive_flatten( - f"component.{component}.{category}.", - resource, - ) + resources_flatten = recursive_flatten( + f"component.{component}.{category}.", + resource, ) + resources_flatten = self._validate_placeholders( + language, resources_flatten, category_cache + ) + category_cache.update(resources_flatten) else: category_cache[f"component.{component}.{category}"] = resource diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr index cec9d05c8e1..70f86feaf79 100644 --- a/tests/helpers/snapshots/test_entity.ambr +++ b/tests/helpers/snapshots/test_entity.ambr @@ -11,11 +11,12 @@ 'key': 'blah', 'name': , 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_entity_description_as_dataclass.1 - "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, translation_placeholders=None, unit_of_measurement=None)" # --- # name: test_extending_entity_description dict({ @@ -30,11 +31,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.1 - "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.10 dict({ @@ -50,11 +52,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.11 - "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.12 dict({ @@ -70,11 +73,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.13 - "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.14 dict({ @@ -90,11 +94,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.15 - "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.16 dict({ @@ -110,11 +115,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.17 - "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.18 dict({ @@ -130,11 +136,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.19 - "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2C(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.2 dict({ @@ -149,6 +156,7 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- @@ -166,11 +174,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.21 - "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription2D(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.22 dict({ @@ -186,11 +195,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.23 - "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription3A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.24 dict({ @@ -206,11 +216,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.25 - "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription3B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.26 dict({ @@ -226,11 +237,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.27 - "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription4A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.28 dict({ @@ -246,14 +258,15 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.29 - "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription4B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.3 - "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.30 dict({ @@ -267,11 +280,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.31 - "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None)" # --- # name: test_extending_entity_description.4 dict({ @@ -287,11 +301,12 @@ 'key': 'blah', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.5 - "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extension='ext', extra='foo')" + "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extension='ext', extra='foo')" # --- # name: test_extending_entity_description.6 dict({ @@ -307,11 +322,12 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.7 - "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1A(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- # name: test_extending_entity_description.8 dict({ @@ -327,9 +343,10 @@ 'mixin': 'mixin', 'name': 'name', 'translation_key': None, + 'translation_placeholders': None, 'unit_of_measurement': None, }) # --- # name: test_extending_entity_description.9 - "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" + "test_extending_entity_description..ComplexEntityDescription1B(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, translation_placeholders=None, unit_of_measurement=None, extra='foo')" # --- diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index ef23687a166..dd26b947f67 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1137,6 +1137,203 @@ async def test_friendly_name_description_device_class_name( ) +@pytest.mark.parametrize( + ( + "has_entity_name", + "translation_key", + "translations", + "placeholders", + "expected_friendly_name", + ), + ( + (False, None, None, None, "Entity Blu"), + (True, None, None, None, "Device Bla Entity Blu"), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent" + }, + }, + None, + "Device Bla English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + {"placeholder": "special"}, + "Device Bla special English ent", + ), + ( + True, + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "English ent {placeholder}" + }, + }, + {"placeholder": "special"}, + "Device Bla English ent special", + ), + ), +) +async def test_entity_name_translation_placeholders( + hass: HomeAssistant, + has_entity_name: bool, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + expected_friendly_name: str | None, +) -> None: + """Test friendly name when the entity name translation has placeholders.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + ent = MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=has_entity_name, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ): + await _test_friendly_name(hass, ent, expected_friendly_name) + + +@pytest.mark.parametrize( + ( + "translation_key", + "translations", + "placeholders", + "release_channel", + "expected_error", + ), + ( + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "stable", + ( + "has translation placeholders '{'placeholder': 'special'}' which do " + "not match the name '{placeholder} English ent {2ndplaceholder}'" + ), + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent {2ndplaceholder}" + }, + }, + {"placeholder": "special"}, + "beta", + "HomeAssistantError: Missing placeholder '2ndplaceholder'", + ), + ( + "test_entity", + { + "en": { + "component.test.entity.test_domain.test_entity.name": "{placeholder} English ent" + }, + }, + None, + "stable", + ( + "has translation placeholders '{}' which do " + "not match the name '{placeholder} English ent'" + ), + ), + ), +) +async def test_entity_name_translation_placeholder_errors( + hass: HomeAssistant, + translation_key: str | None, + translations: dict[str, str] | None, + placeholders: dict[str, str] | None, + release_channel: str, + expected_error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test entity name translation has placeholder issues.""" + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + return translations[language] + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([ent]) + return True + + ent = MockEntity( + unique_id="qwer", + ) + ent.entity_description = entity.EntityDescription( + "test", + has_entity_name=True, + translation_key=translation_key, + name="Entity Blu", + ) + if placeholders is not None: + ent._attr_translation_placeholders = placeholders + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + caplog.clear() + + with patch( + "homeassistant.helpers.entity_platform.translation.async_get_translations", + side_effect=async_get_translations, + ), patch( + "homeassistant.helpers.entity.get_release_channel", return_value=release_channel + ): + await entity_platform.async_setup_entry(config_entry) + + assert expected_error in caplog.text + + @pytest.mark.parametrize( ("has_entity_name", "entity_name", "expected_friendly_name"), ( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 62152299932..350e706ca1d 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -98,6 +98,77 @@ def test_load_translations_files(hass: HomeAssistant) -> None: } +@pytest.mark.parametrize( + ("language", "expected_translation", "expected_errors"), + ( + ( + "en", + { + "component.test.entity.switch.other1.name": "Other 1", + "component.test.entity.switch.other2.name": "Other 2", + "component.test.entity.switch.other3.name": "Other 3", + "component.test.entity.switch.other4.name": "Other 4", + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [], + ), + ( + "es", + { + "component.test.entity.switch.other1.name": "Otra 1", + "component.test.entity.switch.other2.name": "Otra 2", + "component.test.entity.switch.other3.name": "Otra 3", + "component.test.entity.switch.other4.name": "Otra 4", + "component.test.entity.switch.outlet.name": "Enchufe {placeholder}", + }, + [], + ), + ( + "de", + { + # Correct + "component.test.entity.switch.other1.name": "Anderes 1", + # Translation has placeholder missing in English + "component.test.entity.switch.other2.name": "Other 2", + # Correct (empty translation) + "component.test.entity.switch.other3.name": "", + # Translation missing + "component.test.entity.switch.other4.name": "Other 4", + # Mismatch in placeholders + "component.test.entity.switch.outlet.name": "Outlet {placeholder}", + }, + [ + "component.test.entity.switch.other2.name", + "component.test.entity.switch.outlet.name", + ], + ), + ), +) +async def test_load_translations_files_invalid_localized_placeholders( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, + language: str, + expected_translation: dict, + expected_errors: bool, +) -> None: + """Test the load translation files with invalid localized placeholders.""" + caplog.clear() + translations = await translation.async_get_translations( + hass, language, "entity", ["test"] + ) + assert translations == expected_translation + + assert ("Validation of translation placeholders" in caplog.text) == ( + len(expected_errors) > 0 + ) + for expected_error in expected_errors: + assert ( + f"Validation of translation placeholders for localized ({language}) string {expected_error} failed" + in caplog.text + ) + + async def test_get_translations( hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None ) -> None: diff --git a/tests/testing_config/custom_components/test/translations/de.json b/tests/testing_config/custom_components/test/translations/de.json new file mode 100644 index 00000000000..57d26f28ec0 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/de.json @@ -0,0 +1,10 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Anderes 1" }, + "other2": { "name": "Anderes 2 {placeholder}" }, + "other3": { "name": "" }, + "outlet": { "name": "Steckdose {something}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/en.json b/tests/testing_config/custom_components/test/translations/en.json new file mode 100644 index 00000000000..56404508c4c --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/en.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Other 1" }, + "other2": { "name": "Other 2" }, + "other3": { "name": "Other 3" }, + "other4": { "name": "Other 4" }, + "outlet": { "name": "Outlet {placeholder}" } + } + } +} diff --git a/tests/testing_config/custom_components/test/translations/es.json b/tests/testing_config/custom_components/test/translations/es.json new file mode 100644 index 00000000000..62624ad5db6 --- /dev/null +++ b/tests/testing_config/custom_components/test/translations/es.json @@ -0,0 +1,11 @@ +{ + "entity": { + "switch": { + "other1": { "name": "Otra 1" }, + "other2": { "name": "Otra 2" }, + "other3": { "name": "Otra 3" }, + "other4": { "name": "Otra 4" }, + "outlet": { "name": "Enchufe {placeholder}" } + } + } +} From dde4217b2b4c1601770de9226080dc0bc4360b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Wed, 3 Jan 2024 21:08:58 +0100 Subject: [PATCH 0203/1544] Close stale connections (Airthings BLE) (#106748) Co-authored-by: J. Nick Koston --- homeassistant/components/airthings_ble/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index c642ebf9563..1d62442f14d 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -30,6 +31,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: is_metric = hass.config.units is METRIC_SYSTEM assert address is not None + await close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address(hass, address) if not ble_device: From aad2f2cd450785bed0fde6ae095ea64fb2b5d542 Mon Sep 17 00:00:00 2001 From: Patrick Frazer Date: Wed, 3 Jan 2024 15:10:00 -0500 Subject: [PATCH 0204/1544] Bump dropmqttapi to 1.0.2 (#106978) --- homeassistant/components/drop_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json index f65c1848aff..5df34fce561 100644 --- a/homeassistant/components/drop_connect/manifest.json +++ b/homeassistant/components/drop_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/drop_connect", "iot_class": "local_push", "mqtt": ["drop_connect/discovery/#"], - "requirements": ["dropmqttapi==1.0.1"] + "requirements": ["dropmqttapi==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a61f4898343..ef9d8e02e55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -716,7 +716,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.drop_connect -dropmqttapi==1.0.1 +dropmqttapi==1.0.2 # homeassistant.components.dsmr dsmr-parser==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfe8464d543..137584154a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ discovery30303==0.2.1 dremel3dpy==2.1.1 # homeassistant.components.drop_connect -dropmqttapi==1.0.1 +dropmqttapi==1.0.2 # homeassistant.components.dsmr dsmr-parser==1.3.1 From bba24a1251cb7a139f1bb0394e606a15fa24a19c Mon Sep 17 00:00:00 2001 From: Robbert Verbruggen Date: Wed, 3 Jan 2024 22:56:50 +0100 Subject: [PATCH 0205/1544] Bump rachiopy to 1.1.0 (#106975) --- CODEOWNERS | 4 ++-- homeassistant/components/rachio/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 724f08bfd5e..21d692d2942 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1034,8 +1034,8 @@ build.json @home-assistant/supervisor /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza -/homeassistant/components/rachio/ @bdraco -/tests/components/rachio/ @bdraco +/homeassistant/components/rachio/ @bdraco @rfverbruggen +/tests/components/rachio/ @bdraco @rfverbruggen /homeassistant/components/radarr/ @tkdrob /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json index e58341633b1..1a9d71233c2 100644 --- a/homeassistant/components/rachio/manifest.json +++ b/homeassistant/components/rachio/manifest.json @@ -2,7 +2,7 @@ "domain": "rachio", "name": "Rachio", "after_dependencies": ["cloud"], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco", "@rfverbruggen"], "config_flow": true, "dependencies": ["http"], "dhcp": [ @@ -25,7 +25,7 @@ }, "iot_class": "cloud_push", "loggers": ["rachiopy"], - "requirements": ["RachioPy==1.0.3"], + "requirements": ["RachioPy==1.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ef9d8e02e55..eda6128e7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -118,7 +118,7 @@ PyViCare==2.32.0 PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 +RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 137584154a5..af9f10cdd40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -103,7 +103,7 @@ PyViCare==2.32.0 PyXiaomiGateway==0.14.3 # homeassistant.components.rachio -RachioPy==1.0.3 +RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==7.0 From bcc7570d817c99d025133199a6371f5a9e41f2a0 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 4 Jan 2024 01:40:59 +0100 Subject: [PATCH 0206/1544] Bump openwebifpy to 4.0.4 (#107000) --- homeassistant/components/enigma2/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 19a2cf863f9..42fbcb5b9bc 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.0.3"] + "requirements": ["openwebifpy==4.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index eda6128e7d3..cc475d44173 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.0.3 +openwebifpy==4.0.4 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 From 987663e4defa00fed8c61f180ffaf6874297796f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:41:50 -1000 Subject: [PATCH 0207/1544] Fix missing backwards compatiblity layer for humidifier supported_features (#107026) fixes #107018 --- homeassistant/components/humidifier/__init__.py | 2 +- tests/components/humidifier/test_init.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index ea6e8972cc6..184c638e8f5 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -214,7 +214,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if HumidifierEntityFeature.MODES in self.supported_features: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_MODE] = self.mode return data diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index 45da5ba750f..24cf4b6d962 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import humidifier from homeassistant.components.humidifier import ( + ATTR_MODE, HumidifierEntity, HumidifierEntityFeature, ) @@ -75,6 +76,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> """Test deprecated supported features ints.""" class MockHumidifierEntity(HumidifierEntity): + _attr_mode = "mode1" + @property def supported_features(self) -> int: """Return supported features.""" @@ -89,3 +92,5 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> caplog.clear() assert entity.supported_features_compat is HumidifierEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + assert entity.state_attributes[ATTR_MODE] == "mode1" From afcf8c97187729a324959f1637146145aacccdc7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 4 Jan 2024 01:45:59 +0100 Subject: [PATCH 0208/1544] Get Shelly RPC device `gen` from config entry data (#107019) Use gen from config entry data --- homeassistant/components/shelly/config_flow.py | 17 +++++++++-------- homeassistant/components/shelly/const.py | 2 ++ homeassistant/components/shelly/coordinator.py | 3 ++- homeassistant/components/shelly/utils.py | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 68b0f1f8ccc..29daf050163 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -25,6 +25,7 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DOMAIN, LOGGER, @@ -84,7 +85,7 @@ async def validate_input( "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, "model": rpc_device.shelly.get("model"), - "gen": gen, + CONF_GEN: gen, } # Gen1 @@ -99,7 +100,7 @@ async def validate_input( "title": block_device.name, CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), "model": block_device.model, - "gen": gen, + CONF_GEN: gen, } @@ -153,7 +154,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], - "gen": device_info["gen"], + CONF_GEN: device_info[CONF_GEN], }, ) errors["base"] = "firmware_not_fully_provisioned" @@ -190,7 +191,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self.host, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], - "gen": device_info["gen"], + CONF_GEN: device_info[CONF_GEN], }, ) errors["base"] = "firmware_not_fully_provisioned" @@ -288,7 +289,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): "host": self.host, CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], "model": self.device_info["model"], - "gen": self.device_info["gen"], + CONF_GEN: self.device_info[CONF_GEN], }, ) self._set_confirm_only() @@ -321,7 +322,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") - if self.entry.data.get("gen", 1) != 1: + if self.entry.data.get(CONF_GEN, 1) != 1: user_input[CONF_USERNAME] = "admin" try: await validate_input(self.hass, host, info, user_input) @@ -334,7 +335,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - if self.entry.data.get("gen", 1) in BLOCK_GENERATIONS: + if self.entry.data.get(CONF_GEN, 1) in BLOCK_GENERATIONS: schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -363,7 +364,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return ( - config_entry.data.get("gen") in RPC_GENERATIONS + config_entry.data.get(CONF_GEN) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index ca1c450c9fa..1e2c22691fb 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -214,3 +214,5 @@ DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_MOTION_2, MODEL_VALVE, ) + +CONF_GEN = "gen" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index a7659ecc392..77fa0bd2efd 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -33,6 +33,7 @@ from .const import ( ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DOMAIN, @@ -135,7 +136,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): manufacturer="Shelly", model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, - hw_version=f"gen{self.device.gen} ({self.model})", + hw_version=f"gen{self.entry.data[CONF_GEN]} ({self.model})", configuration_url=f"http://{self.entry.data[CONF_HOST]}", ) self.device_id = device_entry.id diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a43d9cb0bcb..d40b22ca50a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -34,6 +34,7 @@ from homeassistant.util.dt import utcnow from .const import ( BASIC_INPUTS_EVENTS_TYPES, CONF_COAP_PORT, + CONF_GEN, DEFAULT_COAP_PORT, DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, @@ -281,7 +282,7 @@ def get_info_auth(info: dict[str, Any]) -> bool: def get_info_gen(info: dict[str, Any]) -> int: """Return the device generation from shelly info.""" - return int(info.get("gen", 1)) + return int(info.get(CONF_GEN, 1)) def get_model_name(info: dict[str, Any]) -> str: @@ -325,7 +326,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get("gen", 1) + return entry.data.get(CONF_GEN, 1) def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]: From 01d0031e09df8563dc8fa525d01829d813faeeb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:47:49 -1000 Subject: [PATCH 0209/1544] Fix ESPHome service removal when the device name contains a dash (#107015) * Fix ESPHome service removal when the device name contains a dash If the device name contains a dash the service name is mutated to replace the dash with an underscore, but the remove function did not do the same mutation so it would fail to remove the service * add more coverage * more cover --- homeassistant/components/esphome/manager.py | 79 ++++--- tests/components/esphome/test_manager.py | 228 +++++++++++++++++++- 2 files changed, 275 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b4ae1a1d0ad..1c0f82de4ae 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine +from functools import partial import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -456,12 +457,10 @@ class ESPHomeManager: self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state(hass) - await asyncio.gather( - entry_data.async_update_static_infos( - hass, entry, entity_infos, device_info.mac_address - ), - _setup_services(hass, entry_data, services), + await entry_data.async_update_static_infos( + hass, entry, entity_infos, device_info.mac_address ) + _setup_services(hass, entry_data, services) setup_coros_with_disconnect_callbacks: list[ Coroutine[Any, Any, CALLBACK_TYPE] @@ -586,7 +585,7 @@ class ESPHomeManager: await entry_data.async_update_static_infos( hass, entry, infos, entry.unique_id.upper() ) - await _setup_services(hass, entry_data, services) + _setup_services(hass, entry_data, services) if entry_data.device_info is not None and entry_data.device_info.name: reconnect_logic.name = entry_data.device_info.name @@ -708,12 +707,27 @@ ARG_TYPE_METADATA = { } -async def _register_service( - hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService +async def execute_service( + entry_data: RuntimeEntryData, service: UserService, call: ServiceCall ) -> None: - if entry_data.device_info is None: - raise ValueError("Device Info needs to be fetched first") - service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" + """Execute a service on a node.""" + await entry_data.client.execute_service(service, call.data) + + +def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str: + """Build a service name for a node.""" + return f"{device_info.name.replace('-', '_')}_{service.name}" + + +@callback +def _async_register_service( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + device_info: EsphomeDeviceInfo, + service: UserService, +) -> None: + """Register a service on a node.""" + service_name = build_service_name(device_info, service) schema = {} fields = {} @@ -736,33 +750,36 @@ async def _register_service( "selector": metadata.selector, } - async def execute_service(call: ServiceCall) -> None: - await entry_data.client.execute_service(service, call.data) - hass.services.async_register( - DOMAIN, service_name, execute_service, vol.Schema(schema) + DOMAIN, + service_name, + partial(execute_service, entry_data, service), + vol.Schema(schema), + ) + async_set_service_schema( + hass, + DOMAIN, + service_name, + { + "description": ( + f"Calls the service {service.name} of the node {device_info.name}" + ), + "fields": fields, + }, ) - service_desc = { - "description": ( - f"Calls the service {service.name} of the node" - f" {entry_data.device_info.name}" - ), - "fields": fields, - } - async_set_service_schema(hass, DOMAIN, service_name, service_desc) - - -async def _setup_services( +@callback +def _setup_services( hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] ) -> None: - if entry_data.device_info is None: + device_info = entry_data.device_info + if device_info is None: # Can happen if device has never connected or .storage cleared return old_services = entry_data.services.copy() - to_unregister = [] - to_register = [] + to_unregister: list[UserService] = [] + to_register: list[UserService] = [] for service in services: if service.key in old_services: # Already exists @@ -780,11 +797,11 @@ async def _setup_services( entry_data.services = {serv.key: serv for serv in services} for service in to_unregister: - service_name = f"{entry_data.device_info.name}_{service.name}" + service_name = build_service_name(device_info, service) hass.services.async_remove(DOMAIN, service_name) for service in to_register: - await _register_service(hass, entry_data, service) + _async_register_service(hass, entry_data, device_info, service) async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEntryData: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 69ed653d75b..94820a03fc6 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,15 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, call -from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService +from aioesphomeapi import ( + APIClient, + DeviceInfo, + EntityInfo, + EntityState, + UserService, + UserServiceArg, + UserServiceArgType, +) import pytest from homeassistant import config_entries @@ -374,3 +382,221 @@ async def test_debug_logging( ) await hass.async_block_till_done() mock_client.set_debug.assert_has_calls([call(False)]) + + +async def test_esphome_device_with_dash_in_name_user_services( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services and a dash in the name.""" + entity_info = [] + states = [] + service1 = UserService( + name="my_service", + key=1, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + UserServiceArg(name="arg2", type=UserServiceArgType.INT), + UserServiceArg(name="arg3", type=UserServiceArgType.FLOAT), + UserServiceArg(name="arg4", type=UserServiceArgType.STRING), + UserServiceArg(name="arg5", type=UserServiceArgType.BOOL_ARRAY), + UserServiceArg(name="arg6", type=UserServiceArgType.INT_ARRAY), + UserServiceArg(name="arg7", type=UserServiceArgType.FLOAT_ARRAY), + UserServiceArg(name="arg8", type=UserServiceArgType.STRING_ARRAY), + ], + ) + service2 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1, service2], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_my_service") + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + # Verify the service can be removed + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [service1]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_my_service") + assert not hass.services.has_service(DOMAIN, "with_dash_simple_service") + + +async def test_esphome_user_services_ignores_invalid_arg_types( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services and a dash in the name.""" + entity_info = [] + states = [] + service1 = UserService( + name="bad_service", + key=1, + args=[ + UserServiceArg(name="arg1", type="wrong"), + ], + ) + service2 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1, service2], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + # Verify the service can be removed + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [service2]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + assert not hass.services.has_service(DOMAIN, "with_dash_bad_service") + + +async def test_esphome_user_services_changes( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with user services that change arguments.""" + entity_info = [] + states = [] + service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.BOOL), + ], + ) + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[service1], + device_info={"name": "with-dash"}, + states=states, + ) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": True}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.BOOL)], + ), + {"arg1": True}, + ) + ] + ) + mock_client.execute_service.reset_mock() + + new_service1 = UserService( + name="simple_service", + key=2, + args=[ + UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT), + ], + ) + + # Verify the service can be updated + mock_client.list_entities_services = AsyncMock( + return_value=(entity_info, [new_service1]) + ) + await device.mock_disconnect(True) + await hass.async_block_till_done() + await device.mock_connect() + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, "with_dash_simple_service") + + await hass.services.async_call(DOMAIN, "with_dash_simple_service", {"arg1": 4.5}) + await hass.async_block_till_done() + + mock_client.execute_service.assert_has_calls( + [ + call( + UserService( + name="simple_service", + key=2, + args=[UserServiceArg(name="arg1", type=UserServiceArgType.FLOAT)], + ), + {"arg1": 4.5}, + ) + ] + ) + mock_client.execute_service.reset_mock() From d535409349553166379d6bdf2109bb901064e73a Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Wed, 3 Jan 2024 19:48:57 -0500 Subject: [PATCH 0210/1544] Bump pyinsteon (#107010) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 1d4eee4a058..cf210963841 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.5.2", + "pyinsteon==1.5.3", "insteon-frontend-home-assistant==0.4.0" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index cc475d44173..a9264a28efe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1819,7 +1819,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.2 +pyinsteon==1.5.3 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af9f10cdd40..5e1f618095c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1385,7 +1385,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.2 +pyinsteon==1.5.3 # homeassistant.components.ipma pyipma==3.0.7 From 8d2ddb6a04dc01ddaaa11aba90980e446436e690 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:53:48 -1000 Subject: [PATCH 0211/1544] Small cleanups to ESPHome light platform (#107003) - Remove unreachable code - Cache filtering when possible - Add missing coverage --- homeassistant/components/esphome/light.py | 48 ++++-- tests/components/esphome/test_light.py | 199 ++++++++++++++++++++++ 2 files changed, 230 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index e170d8b3948..f9fb8b8fb6d 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,7 +1,8 @@ """Support for ESPHome lights.""" from __future__ import annotations -from typing import Any, cast +from functools import lru_cache +from typing import TYPE_CHECKING, Any, cast from aioesphomeapi import ( APIVersion, @@ -111,6 +112,7 @@ def _mired_to_kelvin(mired_temperature: float) -> int: return round(1000000 / mired_temperature) +@lru_cache def _color_mode_to_ha(mode: int) -> str: """Convert an esphome color mode to a HA color mode constant. @@ -134,20 +136,34 @@ def _color_mode_to_ha(mode: int) -> str: return candidates[-1][0] +@lru_cache def _filter_color_modes( supported: list[int], features: LightColorCapability -) -> list[int]: +) -> tuple[int, ...]: """Filter the given supported color modes. Excluding all values that don't have the requested features. """ - return [mode for mode in supported if (mode & features) == features] + features_value = features.value + return tuple( + mode for mode in supported if (mode & features_value) == features_value + ) + + +@lru_cache +def _least_complex_color_mode(color_modes: tuple[int, ...]) -> int: + """Return the color mode with the least complexity.""" + # popcount with bin() function because it appears + # to be the best way: https://stackoverflow.com/a/9831671 + color_modes_list = list(color_modes) + color_modes_list.sort(key=lambda mode: bin(mode).count("1")) + return color_modes_list[0] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): """A light implementation for ESPHome.""" - _native_supported_color_modes: list[int] + _native_supported_color_modes: tuple[int, ...] _supports_color_mode = False @property @@ -231,10 +247,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None: # Do not use kelvin_to_mired here to prevent precision loss data["color_temperature"] = 1000000.0 / color_temp_k - if _filter_color_modes(color_modes, LightColorCapability.COLOR_TEMPERATURE): - color_modes = _filter_color_modes( - color_modes, LightColorCapability.COLOR_TEMPERATURE - ) + if color_temp_modes := _filter_color_modes( + color_modes, LightColorCapability.COLOR_TEMPERATURE + ): + color_modes = color_temp_modes else: color_modes = _filter_color_modes( color_modes, LightColorCapability.COLD_WARM_WHITE @@ -267,10 +283,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): else: # otherwise try the color mode with the least complexity # (fewest capabilities set) - # popcount with bin() function because it appears - # to be the best way: https://stackoverflow.com/a/9831671 - color_modes.sort(key=lambda mode: bin(mode).count("1")) - data["color_mode"] = color_modes[0] + data["color_mode"] = _least_complex_color_mode(color_modes) await self._client.light_command(**data) @@ -294,9 +307,10 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): def color_mode(self) -> str | None: """Return the color mode of the light.""" if not self._supports_color_mode: - if not (supported := self.supported_color_modes): - return None - return next(iter(supported)) + supported_color_modes = self.supported_color_modes + if TYPE_CHECKING: + assert supported_color_modes is not None + return next(iter(supported_color_modes)) return _color_mode_to_ha(self._state.color_mode) @@ -374,8 +388,8 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): super()._on_static_info_update(static_info) static_info = self._static_info self._supports_color_mode = self._api_version >= APIVersion(1, 6) - self._native_supported_color_modes = static_info.supported_color_modes_compat( - self._api_version + self._native_supported_color_modes = tuple( + static_info.supported_color_modes_compat(self._api_version) ) flags = LightEntityFeature.FLASH diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 99058ad3ed4..3d0c1cc63eb 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_WHITE, DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, @@ -317,6 +318,68 @@ async def test_light_legacy_white_converted_to_brightness( mock_client.light_command.reset_mock() +async def test_light_legacy_white_with_rgb( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with rgb and white.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.WHITE + ) + color_mode_2 = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode, color_mode_2], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.RGB, + ColorMode.WHITE, + ] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_WHITE: 60}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + brightness=pytest.approx(0.23529411764705882), + white=1.0, + color_mode=color_mode, + ) + ] + ) + mock_client.light_command.reset_mock() + + async def test_light_brightness_on_off_with_unknown_color_mode( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: @@ -1676,3 +1739,139 @@ async def test_light_effects( ] ) mock_client.light_command.reset_mock() + + +async def test_only_cold_warm_white_support( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with only cold warm white support.""" + mock_client.api_version = APIVersion(1, 7) + color_modes = ( + LightColorCapability.COLD_WARM_WHITE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_modes], + ) + ] + states = [ + LightState( + key=1, + state=True, + color_brightness=1, + brightness=100, + red=1, + green=1, + blue=1, + warm_white=1, + cold_white=1, + color_mode=color_modes, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 0 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=color_modes)] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + brightness=pytest.approx(0.4980392156862745), + ) + ] + ) + mock_client.light_command.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + blocking=True, + ) + mock_client.light_command.assert_has_calls( + [ + call( + key=1, + state=True, + color_mode=color_modes, + color_temperature=400.0, + ) + ] + ) + mock_client.light_command.reset_mock() + + +async def test_light_no_color_modes( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic light entity with no color modes.""" + mock_client.api_version = APIVersion(1, 7) + color_mode = 0 + entity_info = [ + LightInfo( + object_id="mylight", + key=1, + name="my light", + unique_id="my_light", + min_mireds=153, + max_mireds=400, + supported_color_modes=[color_mode], + ) + ] + states = [LightState(key=1, state=True, brightness=100)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("light.test_mylight") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.UNKNOWN] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_mylight"}, + blocking=True, + ) + mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.reset_mock() From 962c44900958caae8a26dd34f06e999850b04adf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:54:28 -1000 Subject: [PATCH 0212/1544] Add missing coverage for esphome_state_property decorator (#106998) --- tests/components/esphome/test_climate.py | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 065890fd623..cb9a084d094 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -1,6 +1,7 @@ """Test ESPHome climates.""" +import math from unittest.mock import call from aioesphomeapi import ( @@ -16,6 +17,7 @@ from aioesphomeapi import ( from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_MODE, @@ -377,3 +379,56 @@ async def test_climate_entity_with_humidity( ) mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) mock_client.climate_command.reset_mock() + + +async def test_climate_entity_with_inf_value( + hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry +) -> None: + """Test a generic climate entity with infinite temp.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + supports_two_point_target_temperature=True, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supports_current_humidity=True, + supports_target_humidity=True, + visual_min_humidity=10.1, + visual_max_humidity=29.7, + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.AUTO, + action=ClimateAction.COOLING, + current_temperature=math.inf, + target_temperature=math.inf, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + current_humidity=20.1, + target_humidity=25.7, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.state == HVACMode.AUTO + attributes = state.attributes + assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert attributes[ATTR_HUMIDITY] == 26 + assert attributes[ATTR_MAX_HUMIDITY] == 30 + assert attributes[ATTR_MIN_HUMIDITY] == 10 + assert ATTR_TEMPERATURE not in attributes + assert attributes[ATTR_CURRENT_TEMPERATURE] is None From 4f213f6df346a8a07b38f79074799612af614e93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 14:58:04 -1000 Subject: [PATCH 0213/1544] Fix first ESPHome device update entity not offering install feature (#106993) In the case where the user gets their first ESPHome device such as a RATGDO, they will usually add the device first in HA, and than find the dashboard. The install function will be missing because we do not know if the dashboard supports updating devices until the first device is added. We now set the supported features when we learn the version when the first device is added --- homeassistant/components/esphome/dashboard.py | 30 ++++----- homeassistant/components/esphome/update.py | 64 +++++++++++-------- tests/components/esphome/test_dashboard.py | 28 +++++++- tests/components/esphome/test_update.py | 51 ++++++++++++++- 4 files changed, 126 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 41b0617e630..3d7bfef6ddb 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -28,6 +28,8 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 +MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") + async def async_setup(hass: HomeAssistant) -> None: """Set up the ESPHome dashboard.""" @@ -177,22 +179,20 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): self.addon_slug = addon_slug self.url = url self.api = ESPHomeDashboardAPI(url, session) - - @property - def supports_update(self) -> bool: - """Return whether the dashboard supports updates.""" - if self.data is None: - raise RuntimeError("Data needs to be loaded first") - - if len(self.data) == 0: - return False - - esphome_version: str = next(iter(self.data.values()))["current_version"] - - # There is no January release - return AwesomeVersion(esphome_version) > AwesomeVersion("2023.1.0") + self.supports_update: bool | None = None async def _async_update_data(self) -> dict: """Fetch device data.""" devices = await self.api.get_devices() - return {dev["name"]: dev for dev in devices["configured"]} + configured_devices = devices["configured"] + + if ( + self.supports_update is None + and configured_devices + and (current_version := configured_devices[0].get("current_version")) + ): + self.supports_update = ( + AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE + ) + + return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 859b28a53b5..ea052522e76 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, cast +from typing import Any from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo @@ -27,6 +27,7 @@ from .entry_data import RuntimeEntryData KEY_UPDATE_LOCK = "esphome_update_lock" +NO_FEATURES = UpdateEntityFeature(0) _LOGGER = logging.getLogger(__name__) @@ -76,6 +77,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_title = "ESPHome" _attr_name = "Firmware" + _attr_release_url = "https://esphome.io/changelog/" def __init__( self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard @@ -90,15 +92,36 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + self._update_attrs() + @callback + def _update_attrs(self) -> None: + """Update the supported features.""" # If the device has deep sleep, we can't assume we can install updates # as the ESP will not be connectable (by design). + coordinator = self.coordinator + device_info = self._device_info + # Install support can change at run time if ( coordinator.last_update_success and coordinator.supports_update - and not self._device_info.has_deep_sleep + and not device_info.has_deep_sleep ): self._attr_supported_features = UpdateEntityFeature.INSTALL + else: + self._attr_supported_features = NO_FEATURES + self._attr_installed_version = device_info.esphome_version + device = coordinator.data.get(device_info.name) + if device is None: + self._attr_latest_version = None + else: + self._attr_latest_version = device["current_version"] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() @property def _device_info(self) -> ESPHomeDeviceInfo: @@ -119,44 +142,29 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): or self._device_info.has_deep_sleep ) - @property - def installed_version(self) -> str | None: - """Version currently installed and in use.""" - return self._device_info.esphome_version - - @property - def latest_version(self) -> str | None: - """Latest version available for install.""" - device = self.coordinator.data.get(self._device_info.name) - if device is None: - return None - return cast(str, device["current_version"]) - - @property - def release_url(self) -> str | None: - """URL to the full release notes of the latest version available.""" - return "https://esphome.io/changelog/" - @callback - def _async_static_info_updated(self, _: list[EntityInfo]) -> None: - """Handle static info update.""" + def _handle_device_update(self, static_info: EntityInfo | None = None) -> None: + """Handle updated data from the device.""" + self._update_attrs() self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Handle entity added to Home Assistant.""" await super().async_added_to_hass() + hass = self.hass + entry_data = self._entry_data self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_static_info_updated, - self._async_static_info_updated, + hass, + entry_data.signal_static_info_updated, + self._handle_device_update, ) ) self.async_on_remove( async_dispatcher_connect( - self.hass, - self._entry_data.signal_device_updated, - self.async_write_ha_state, + hass, + entry_data.signal_device_updated, + self._handle_device_update, ) ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index d8732ea0453..320b20832c8 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -45,6 +45,25 @@ async def test_restore_dashboard_storage( assert mock_get_or_create.call_count == 1 +async def test_restore_dashboard_storage_end_to_end( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage +) -> MockConfigEntry: + """Restore dashboard url and slug from storage.""" + hass_storage[dashboard.STORAGE_KEY] = { + "version": dashboard.STORAGE_VERSION, + "minor_version": dashboard.STORAGE_VERSION, + "key": dashboard.STORAGE_KEY, + "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, + } + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI" + ) as mock_dashboard_api: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == ConfigEntryState.LOADED + assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052" + + async def test_setup_dashboard_fails( hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage ) -> MockConfigEntry: @@ -168,6 +187,9 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> # No data assert not dash.supports_update + await dash.async_refresh() + assert dash.supports_update is None + # supported version mock_dashboard["configured"].append( { @@ -177,11 +199,11 @@ async def test_dashboard_supports_update(hass: HomeAssistant, mock_dashboard) -> } ) await dash.async_refresh() - - assert dash.supports_update + assert dash.supports_update is True # unsupported version + dash.supports_update = None mock_dashboard["configured"][0]["current_version"] = "2023.1.0" await dash.async_refresh() - assert not dash.supports_update + assert dash.supports_update is False diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 9ab00421cbc..d267a13145f 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard from homeassistant.components.update import UpdateEntityFeature -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -370,3 +376,46 @@ async def test_update_entity_not_present_without_dashboard( state = hass.states.get("update.none_firmware") assert state is None + + +async def test_update_becomes_available_at_runtime( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_dashboard, +) -> None: + """Test ESPHome update entity when the dashboard has no device at startup but gets them later.""" + await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + features = state.attributes[ATTR_SUPPORTED_FEATURES] + # There are no devices on the dashboard so no + # way to tell the version so install is disabled + assert features is UpdateEntityFeature(0) + + # A device gets added to the dashboard + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("update.test_firmware") + assert state is not None + # We now know the version so install is enabled + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features is UpdateEntityFeature.INSTALL From 0183affc7cf9d0dab4fd8893ac9ef4fd429848df Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 4 Jan 2024 01:59:02 +0100 Subject: [PATCH 0214/1544] Use call_soon_threadsafe in token updater of Ring (#106984) Use call_soon_threadsafe in token update of Ring --- homeassistant/components/ring/__init__.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 157a62df05b..cc85bedd632 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta -from functools import partial import logging from typing import Any @@ -16,7 +15,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.async_ import run_callback_threadsafe from .const import ( DEVICES_SCAN_INTERVAL, @@ -41,14 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def token_updater(token): """Handle from sync context when token is updated.""" - run_callback_threadsafe( - hass.loop, - partial( - hass.config_entries.async_update_entry, - entry, - data={**entry.data, CONF_TOKEN: token}, - ), - ).result() + hass.loop.call_soon_threadsafe( + hass.config_entries.async_update_entry, + entry, + data={**entry.data, CONF_TOKEN: token}, + ) auth = ring_doorbell.Auth( f"{APPLICATION_NAME}/{__version__}", entry.data[CONF_TOKEN], token_updater From 890615bb92e8ed43f8f1053d116722ff946c7057 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 3 Jan 2024 20:52:05 -0500 Subject: [PATCH 0215/1544] Ring: Add partial back (#107040) --- homeassistant/components/ring/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index cc85bedd632..8a93d5a7768 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta +from functools import partial import logging from typing import Any @@ -40,9 +41,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def token_updater(token): """Handle from sync context when token is updated.""" hass.loop.call_soon_threadsafe( - hass.config_entries.async_update_entry, - entry, - data={**entry.data, CONF_TOKEN: token}, + partial( + hass.config_entries.async_update_entry, + entry, + data={**entry.data, CONF_TOKEN: token}, + ) ) auth = ring_doorbell.Auth( From 2331f8993615a351ac572be4531feb5c728a2a52 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 4 Jan 2024 06:17:43 +0200 Subject: [PATCH 0216/1544] Issue warning if glances server version is 2 (#105887) * Issue warning if glances server version is 2 * Auto detect api version * Apply suggestions * Add HA version deprecation * Apply suggestions from code review * update config flow tests * Fix breaks in ha version --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/glances/__init__.py | 70 +++++++++++++++++-- .../components/glances/config_flow.py | 20 ++---- homeassistant/components/glances/const.py | 3 - homeassistant/components/glances/strings.json | 7 +- tests/components/glances/__init__.py | 1 - tests/components/glances/test_config_flow.py | 10 +-- tests/components/glances/test_init.py | 29 +++++++- 7 files changed, 106 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index bda1baf797a..1c03f8c1dbf 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -1,13 +1,34 @@ """The Glances component.""" +import logging from typing import Any from glances_api import Glances +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiError, + GlancesApiNoDataAvailable, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN from .coordinator import GlancesDataUpdateCoordinator @@ -16,10 +37,19 @@ PLATFORMS = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Glances from config entry.""" - api = get_api(hass, dict(config_entry.data)) + try: + api = await get_api(hass, dict(config_entry.data)) + except GlancesApiAuthorizationError as err: + raise ConfigEntryAuthFailed from err + except GlancesApiError as err: + raise ConfigEntryNotReady from err + except ServerVersionMismatch as err: + raise ConfigEntryError(err) from err coordinator = GlancesDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() @@ -39,8 +69,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: +async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" - entry_data.pop(CONF_NAME, None) httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) - return Glances(httpx_client=httpx_client, **entry_data) + for version in (3, 2): + api = Glances( + host=entry_data[CONF_HOST], + port=entry_data[CONF_PORT], + version=version, + ssl=entry_data[CONF_SSL], + username=entry_data.get(CONF_USERNAME), + password=entry_data.get(CONF_PASSWORD), + httpx_client=httpx_client, + ) + try: + await api.get_ha_sensor_data() + except GlancesApiNoDataAvailable as err: + _LOGGER.debug("Failed to connect to Glances API v%s: %s", version, err) + continue + if version == 2: + async_create_issue( + hass, + DOMAIN, + "deprecated_version", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_version", + ) + _LOGGER.debug("Connected to Glances API v%s", version) + return api + raise ServerVersionMismatch("Could not connect to Glances API version 2 or 3") + + +class ServerVersionMismatch(HomeAssistantError): + """Raise exception if we fail to connect to Glances API.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 72555b629d7..81d3a118729 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -21,15 +21,8 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import FlowResult -from . import get_api -from .const import ( - CONF_VERSION, - DEFAULT_HOST, - DEFAULT_PORT, - DEFAULT_VERSION, - DOMAIN, - SUPPORTED_VERSIONS, -) +from . import ServerVersionMismatch, get_api +from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN DATA_SCHEMA = vol.Schema( { @@ -37,7 +30,6 @@ DATA_SCHEMA = vol.Schema( vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - vol.Required(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SUPPORTED_VERSIONS), vol.Optional(CONF_SSL, default=False): bool, vol.Optional(CONF_VERIFY_SSL, default=False): bool, } @@ -65,9 +57,8 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert self._reauth_entry if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} - api = get_api(self.hass, user_input) try: - await api.get_ha_sensor_data() + await get_api(self.hass, user_input) except GlancesApiAuthorizationError: errors["base"] = "invalid_auth" except GlancesApiConnectionError: @@ -101,12 +92,11 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) - api = get_api(self.hass, user_input) try: - await api.get_ha_sensor_data() + await get_api(self.hass, user_input) except GlancesApiAuthorizationError: errors["base"] = "invalid_auth" - except GlancesApiConnectionError: + except (GlancesApiConnectionError, ServerVersionMismatch): errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 37da60bdea8..f0477a30463 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -8,9 +8,6 @@ CONF_VERSION = "version" DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 -DEFAULT_VERSION = 3 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -SUPPORTED_VERSIONS = [2, 3] - CPU_ICON = f"mdi:cpu-{64 if sys.maxsize > 2**32 else 32}-bit" diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 1bab098d65f..7e69e7f7912 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -7,7 +7,6 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, @@ -30,5 +29,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "issues": { + "deprecated_version": { + "title": "Glances servers with version 2 is deprecated", + "description": "Glances servers with version 2 is deprecated and will not be supported in future versions of HA. It is recommended to update your server to Glances version 3 then reload the integration." + } } } diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 91f8da92799..f0f1fe01796 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -6,7 +6,6 @@ MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", - "version": 3, "port": 61208, "ssl": False, "verify_ssl": True, diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 87ec80da057..8d590317c61 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, + GlancesApiNoDataAvailable, ) import pytest @@ -47,6 +48,7 @@ async def test_form(hass: HomeAssistant) -> None: [ (GlancesApiAuthorizationError, "invalid_auth"), (GlancesApiConnectionError, "cannot_connect"), + (GlancesApiNoDataAvailable, "cannot_connect"), ], ) async def test_form_fails( @@ -54,7 +56,7 @@ async def test_form_fails( ) -> None: """Test flow fails when api exception is raised.""" - mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] + mock_api.return_value.get_ha_sensor_data.side_effect = error result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -65,12 +67,6 @@ async def test_form_fails( assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": message} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - async def test_form_already_configured(hass: HomeAssistant) -> None: """Test host is already configured.""" diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 61cbc610060..764426c6276 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,17 +1,19 @@ """Tests for Glances integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from glances_api.exceptions import ( GlancesApiAuthorizationError, GlancesApiConnectionError, + GlancesApiNoDataAvailable, ) import pytest from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_USER_INPUT from tests.common import MockConfigEntry @@ -27,11 +29,34 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED +async def test_entry_deprecated_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api: AsyncMock +) -> None: + """Test creating an issue if glances server is version 2.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + mock_api.return_value.get_ha_sensor_data.side_effect = [ + GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), + HA_SENSOR_DATA, + HA_SENSOR_DATA, + ] + + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state == ConfigEntryState.LOADED + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_version") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + + @pytest.mark.parametrize( ("error", "entry_state"), [ (GlancesApiAuthorizationError, ConfigEntryState.SETUP_ERROR), (GlancesApiConnectionError, ConfigEntryState.SETUP_RETRY), + (GlancesApiNoDataAvailable, ConfigEntryState.SETUP_ERROR), ], ) async def test_setup_error( From 4fa76801afc320e82de8a2c4d19de3d6b53d80f7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 3 Jan 2024 22:17:12 -0700 Subject: [PATCH 0217/1544] Re-architect Guardian to use better entity descriptions and properties (#107028) * Re-architect Guardian to use better entity descriptions and properties * Reduce blast area * Code review * Remove mixins --- homeassistant/components/guardian/__init__.py | 23 +---- .../components/guardian/binary_sensor.py | 42 +++++---- homeassistant/components/guardian/button.py | 15 +--- homeassistant/components/guardian/sensor.py | 46 ++++++---- homeassistant/components/guardian/switch.py | 89 +++++++++---------- 5 files changed, 102 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 4a394692dd8..b9f0740ea0c 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -365,27 +365,8 @@ class GuardianEntity(CoordinatorEntity[GuardianDataUpdateCoordinator]): """Initialize.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {} self.entity_description = description - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data. - - This should be extended by Guardian platforms. - """ - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self._async_update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._async_update_from_latest_data() - class PairedSensorEntity(GuardianEntity): """Define a Guardian paired sensor entity.""" @@ -410,14 +391,14 @@ class PairedSensorEntity(GuardianEntity): self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ValveControllerEntityDescriptionMixin: """Define an entity description mixin for valve controller entities.""" api_category: str -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ValveControllerEntityDescription( EntityDescription, ValveControllerEntityDescriptionMixin ): diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 179158ab512..abe005aae33 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -1,7 +1,9 @@ """Binary sensors for the Elexa Guardian integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, @@ -39,24 +41,35 @@ SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) +class PairedSensorBinarySensorDescription(BinarySensorEntityDescription): + """Describe a Guardian paired sensor binary sensor.""" + + is_on_fn: Callable[[dict[str, Any]], bool] + + +@dataclass(frozen=True, kw_only=True) class ValveControllerBinarySensorDescription( BinarySensorEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller binary sensor.""" + is_on_fn: Callable[[dict[str, Any]], bool] + PAIRED_SENSOR_DESCRIPTIONS = ( - BinarySensorEntityDescription( + PairedSensorBinarySensorDescription( key=SENSOR_KIND_LEAK_DETECTED, translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, + is_on_fn=lambda data: data["wet"], ), - BinarySensorEntityDescription( + PairedSensorBinarySensorDescription( key=SENSOR_KIND_MOVED, translation_key="moved", device_class=BinarySensorDeviceClass.MOVING, entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: data["moved"], ), ) @@ -66,6 +79,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, api_category=API_SYSTEM_ONBOARD_SENSOR_STATUS, + is_on_fn=lambda data: data["wet"], ), ) @@ -133,7 +147,7 @@ async def async_setup_entry( class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - entity_description: BinarySensorEntityDescription + entity_description: PairedSensorBinarySensorDescription def __init__( self, @@ -146,13 +160,10 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): self._attr_is_on = True - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: - self._attr_is_on = self.coordinator.data["wet"] - elif self.entity_description.key == SENSOR_KIND_MOVED: - self._attr_is_on = self.coordinator.data["moved"] + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): @@ -171,8 +182,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): self._attr_is_on = True - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity.""" - if self.entity_description.key == SENSOR_KIND_LEAK_DETECTED: - self._attr_is_on = self.coordinator.data["wet"] + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 7a931f35019..485de90f1d8 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -23,21 +23,14 @@ from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescript from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN -@dataclass(frozen=True) -class GuardianButtonEntityDescriptionMixin: - """Define an mixin for button entities.""" - - push_action: Callable[[Client], Awaitable] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ValveControllerButtonDescription( - ButtonEntityDescription, - ValveControllerEntityDescription, - GuardianButtonEntityDescriptionMixin, + ButtonEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller button.""" + push_action: Callable[[Client], Awaitable] + BUTTON_KIND_REBOOT = "reboot" BUTTON_KIND_RESET_VALVE_DIAGNOSTICS = "reset_valve_diagnostics" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 68833234b15..85adaddb7f2 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -1,7 +1,9 @@ """Sensors for the Elexa Guardian integration.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import ( GuardianData, @@ -39,25 +42,36 @@ SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) +class PairedSensorDescription(SensorEntityDescription): + """Describe a Guardian paired sensor.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass(frozen=True, kw_only=True) class ValveControllerSensorDescription( SensorEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller sensor.""" + value_fn: Callable[[dict[str, Any]], StateType] + PAIRED_SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + PairedSensorDescription( key=SENSOR_KIND_BATTERY, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: data["battery"], ), - SensorEntityDescription( + PairedSensorDescription( key=SENSOR_KIND_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["temperature"], ), ) VALVE_CONTROLLER_DESCRIPTIONS = ( @@ -67,6 +81,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, api_category=API_SYSTEM_ONBOARD_SENSOR_STATUS, + value_fn=lambda data: data["temperature"], ), ValveControllerSensorDescription( key=SENSOR_KIND_UPTIME, @@ -75,6 +90,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MINUTES, api_category=API_SYSTEM_DIAGNOSTICS, + value_fn=lambda data: data["uptime"], ), ) @@ -125,15 +141,12 @@ async def async_setup_entry( class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Define a binary sensor related to a Guardian valve controller.""" - entity_description: SensorEntityDescription + entity_description: PairedSensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_BATTERY: - self._attr_native_value = self.coordinator.data["battery"] - elif self.entity_description.key == SENSOR_KIND_TEMPERATURE: - self._attr_native_value = self.coordinator.data["temperature"] + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) class ValveControllerSensor(ValveControllerEntity, SensorEntity): @@ -141,10 +154,7 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): entity_description: ValveControllerSensorDescription - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity's underlying data.""" - if self.entity_description.key == SENSOR_KIND_TEMPERATURE: - self._attr_native_value = self.coordinator.data["temperature"] - elif self.entity_description.key == SENSOR_KIND_UPTIME: - self._attr_native_value = self.coordinator.data["uptime"] + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 98179c1922f..81f06ba4356 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,7 +1,7 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from typing import Any @@ -11,7 +11,7 @@ from aioguardian.errors import GuardianError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,21 +28,25 @@ ATTR_TRAVEL_COUNT = "travel_count" SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" - -@dataclass(frozen=True) -class SwitchDescriptionMixin: - """Define an entity description mixin for Guardian switches.""" - - off_action: Callable[[Client], Awaitable] - on_action: Callable[[Client], Awaitable] +ON_STATES = { + "start_opening", + "opening", + "finish_opening", + "opened", +} -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ValveControllerSwitchDescription( - SwitchEntityDescription, ValveControllerEntityDescription, SwitchDescriptionMixin + SwitchEntityDescription, ValveControllerEntityDescription ): """Describe a Guardian valve controller switch.""" + extra_state_attributes_fn: Callable[[dict[str, Any]], Mapping[str, Any]] + is_on_fn: Callable[[dict[str, Any]], bool] + off_fn: Callable[[Client], Awaitable] + on_fn: Callable[[Client], Awaitable] + async def _async_disable_ap(client: Client) -> None: """Disable the onboard AP.""" @@ -70,17 +74,29 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( translation_key="onboard_access_point", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, + extra_state_attributes_fn=lambda data: { + ATTR_CONNECTED_CLIENTS: data.get("ap_clients"), + ATTR_STATION_CONNECTED: data["station_connected"], + }, api_category=API_WIFI_STATUS, - off_action=_async_disable_ap, - on_action=_async_enable_ap, + is_on_fn=lambda data: data["ap_enabled"], + off_fn=_async_disable_ap, + on_fn=_async_enable_ap, ), ValveControllerSwitchDescription( key=SWITCH_KIND_VALVE, translation_key="valve_controller", icon="mdi:water", api_category=API_VALVE_STATUS, - off_action=_async_close_valve, - on_action=_async_open_valve, + extra_state_attributes_fn=lambda data: { + ATTR_AVG_CURRENT: data["average_current"], + ATTR_INST_CURRENT: data["instantaneous_current"], + ATTR_INST_CURRENT_DDT: data["instantaneous_current_ddt"], + ATTR_TRAVEL_COUNT: data["travel_count"], + }, + is_on_fn=lambda data: data["state"] in ON_STATES, + off_fn=_async_close_valve, + on_fn=_async_open_valve, ), ) @@ -100,13 +116,6 @@ async def async_setup_entry( class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): """Define a switch related to a Guardian valve controller.""" - ON_STATES = { - "start_opening", - "opening", - "finish_opening", - "opened", - } - entity_description: ValveControllerSwitchDescription def __init__( @@ -120,29 +129,15 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._client = data.client - @callback - def _async_update_from_latest_data(self) -> None: - """Update the entity.""" - if self.entity_description.key == SWITCH_KIND_ONBOARD_AP: - self._attr_extra_state_attributes.update( - { - ATTR_CONNECTED_CLIENTS: self.coordinator.data.get("ap_clients"), - ATTR_STATION_CONNECTED: self.coordinator.data["station_connected"], - } - ) - self._attr_is_on = self.coordinator.data["ap_enabled"] - elif self.entity_description.key == SWITCH_KIND_VALVE: - self._attr_is_on = self.coordinator.data["state"] in self.ON_STATES - self._attr_extra_state_attributes.update( - { - ATTR_AVG_CURRENT: self.coordinator.data["average_current"], - ATTR_INST_CURRENT: self.coordinator.data["instantaneous_current"], - ATTR_INST_CURRENT_DDT: self.coordinator.data[ - "instantaneous_current_ddt" - ], - ATTR_TRAVEL_COUNT: self.coordinator.data["travel_count"], - } - ) + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" @@ -151,7 +146,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): try: async with self._client: - await self.entity_description.off_action(self._client) + await self.entity_description.off_fn(self._client) except GuardianError as err: raise HomeAssistantError( f'Error while turning "{self.entity_id}" off: {err}' @@ -167,7 +162,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): try: async with self._client: - await self.entity_description.on_action(self._client) + await self.entity_description.on_fn(self._client) except GuardianError as err: raise HomeAssistantError( f'Error while turning "{self.entity_id}" on: {err}' From 53717523e5d5c1b670729edf3d83413c68cf5c7e Mon Sep 17 00:00:00 2001 From: Joshua Krall Date: Wed, 3 Jan 2024 23:37:24 -0700 Subject: [PATCH 0218/1544] Add button platform to Opengarage (#103569) * Add button entity to reboot OpenGarage device * Addressing code review comments * Another code-review fix * Update homeassistant/components/opengarage/button.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/opengarage/__init__.py | 6 +- homeassistant/components/opengarage/button.py | 79 +++++++++++++++++++ tests/components/opengarage/conftest.py | 59 ++++++++++++++ tests/components/opengarage/test_button.py | 33 ++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/opengarage/button.py create mode 100644 tests/components/opengarage/conftest.py create mode 100644 tests/components/opengarage/test_button.py diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index c269ee53cf3..b825cace83a 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -18,13 +18,11 @@ from .const import CONF_DEVICE_KEY, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenGarage from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - open_garage_connection = opengarage.OpenGarage( f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", entry.data[CONF_DEVICE_KEY], @@ -36,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: open_garage_connection=open_garage_connection, ) await open_garage_data_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = open_garage_data_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py new file mode 100644 index 00000000000..9f676919098 --- /dev/null +++ b/homeassistant/components/opengarage/button.py @@ -0,0 +1,79 @@ +"""OpenGarage button.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast + +from opengarage import OpenGarage + +from homeassistant.components.button import ( + ButtonDeviceClass, + 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 OpenGarageDataUpdateCoordinator +from .const import DOMAIN +from .entity import OpenGarageEntity + + +@dataclass(frozen=True, kw_only=True) +class OpenGarageButtonEntityDescription(ButtonEntityDescription): + """OpenGarage Browser button description.""" + + press_action: Callable[[OpenGarage], Any] + + +BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = ( + OpenGarageButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda opengarage: opengarage.reboot(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenGarage button entities.""" + coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + OpenGarageButtonEntity( + coordinator, cast(str, config_entry.unique_id), description + ) + for description in BUTTONS + ) + + +class OpenGarageButtonEntity(OpenGarageEntity, ButtonEntity): + """Representation of an OpenGarage button.""" + + entity_description: OpenGarageButtonEntityDescription + + def __init__( + self, + coordinator: OpenGarageDataUpdateCoordinator, + device_id: str, + description: OpenGarageButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator, device_id, description) + + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.press_action( + self.coordinator.open_garage_connection + ) + await self.coordinator.async_refresh() diff --git a/tests/components/opengarage/conftest.py b/tests/components/opengarage/conftest.py new file mode 100644 index 00000000000..189c3a877ff --- /dev/null +++ b/tests/components/opengarage/conftest.py @@ -0,0 +1,59 @@ +"""Fixtures for the OpenGarage integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.opengarage.const import CONF_DEVICE_KEY, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Test device", + domain=DOMAIN, + data={ + CONF_HOST: "http://1.1.1.1", + CONF_PORT: "80", + CONF_DEVICE_KEY: "abc123", + CONF_VERIFY_SSL: False, + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_opengarage() -> Generator[MagicMock, None, None]: + """Return a mocked OpenGarage client.""" + with patch( + "homeassistant.components.opengarage.opengarage.OpenGarage", + autospec=True, + ) as client_mock: + client = client_mock.return_value + client.device_url = "http://1.1.1.1:80" + client.update_state.return_value = { + "name": "abcdef", + "mac": "aa:bb:cc:dd:ee:ff", + "fwv": "1.2.0", + } + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_opengarage: MagicMock +) -> MockConfigEntry: + """Set up the OpenGarage integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/opengarage/test_button.py b/tests/components/opengarage/test_button.py new file mode 100644 index 00000000000..b4557a116e8 --- /dev/null +++ b/tests/components/opengarage/test_button.py @@ -0,0 +1,33 @@ +"""Test the OpenGarage Browser buttons.""" +from unittest.mock import MagicMock + +import homeassistant.components.button as button +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_buttons( + hass: HomeAssistant, + mock_opengarage: MagicMock, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test standard OpenGarage buttons.""" + entry = entity_registry.async_get("button.abcdef_restart") + assert entry + assert entry.unique_id == "12345_restart" + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.abcdef_restart"}, + blocking=True, + ) + assert len(mock_opengarage.reboot.mock_calls) == 1 + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry From 85a1d8b34c72638c4e1db19298ab24e04d2597e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 09:11:44 +0100 Subject: [PATCH 0219/1544] Use async_register in streamlabswater (#107060) --- homeassistant/components/streamlabswater/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 986b5de8049..82e8777a7e1 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -112,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) client.update_location(location_id, away_mode) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA ) From dd6c5e89efdd38279cacd6bf875fd2cb5b085bc5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 09:13:34 +0100 Subject: [PATCH 0220/1544] Fix data access in streamlabs water (#107062) * Fix data access in streamlabs water * Fix data access in streamlabs water --- homeassistant/components/streamlabswater/coordinator.py | 2 +- homeassistant/components/streamlabswater/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index a11eced5a6e..dc57ae78810 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -44,7 +44,7 @@ class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): def _update_data(self) -> dict[str, StreamlabsData]: locations = self.client.get_locations() res = {} - for location in locations: + for location in locations["locations"]: location_id = location["locationId"] water_usage = self.client.get_water_usage_summary(location_id) res[location_id] = StreamlabsData( diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 0b249b7c4e5..6c869a6d1bc 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry( entities = [] - for location_id in coordinator.data.values(): + for location_id in coordinator.data: entities.extend( [ StreamLabsDailyUsage(coordinator, location_id), From a83ab403c1c6820c37cdc789cdebcb9d95b79085 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 22:35:13 -1000 Subject: [PATCH 0221/1544] Small cleanups to denonavr (#107050) --- .../components/denonavr/media_player.py | 49 +++---------------- 1 file changed, 7 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 8b6907a60f7..b0454784ca1 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -368,11 +368,6 @@ class DenonDevice(MediaPlayerEntity): return self._supported_features_base | SUPPORT_MEDIA_MODES return self._supported_features_base - @property - def media_content_id(self): - """Content ID of current playing media.""" - return None - @property def media_content_type(self) -> MediaType: """Content type of current playing media.""" @@ -380,11 +375,6 @@ class DenonDevice(MediaPlayerEntity): return MediaType.MUSIC return MediaType.CHANNEL - @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return None - @property def media_image_url(self): """Image url of current playing media.""" @@ -415,44 +405,19 @@ class DenonDevice(MediaPlayerEntity): return self._receiver.album return self._receiver.station - @property - def media_album_artist(self): - """Album artist of current playing media, music track only.""" - return None - - @property - def media_track(self): - """Track number of current playing media, music track only.""" - return None - - @property - def media_series_title(self): - """Title of series of current playing media, TV show only.""" - return None - - @property - def media_season(self): - """Season of current playing media, TV show only.""" - return None - - @property - def media_episode(self): - """Episode of current playing media, TV show only.""" - return None - @property def extra_state_attributes(self): """Return device specific state attributes.""" - if self._receiver.power != POWER_ON: + receiver = self._receiver + if receiver.power != POWER_ON: return {} state_attributes = {} if ( - self._receiver.sound_mode_raw is not None - and self._receiver.support_sound_mode - ): - state_attributes[ATTR_SOUND_MODE_RAW] = self._receiver.sound_mode_raw - if self._receiver.dynamic_eq is not None: - state_attributes[ATTR_DYNAMIC_EQ] = self._receiver.dynamic_eq + sound_mode_raw := receiver.sound_mode_raw + ) is not None and receiver.support_sound_mode: + state_attributes[ATTR_SOUND_MODE_RAW] = sound_mode_raw + if (dynamic_eq := receiver.dynamic_eq) is not None: + state_attributes[ATTR_DYNAMIC_EQ] = dynamic_eq return state_attributes @property From 4b3a1b5d2dcf9094904257274ce96291b45b2544 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Thu, 4 Jan 2024 00:36:24 -0800 Subject: [PATCH 0222/1544] Update pydrawise to 2024.1.0 (#107032) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 054d084eb76..0bfe1dff001 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==2023.11.0"] + "requirements": ["pydrawise==2024.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9264a28efe..169b5527292 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1723,7 +1723,7 @@ pydiscovergy==2.0.5 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.11.0 +pydrawise==2024.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e1f618095c..e7a8458d312 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ pydexcom==0.2.3 pydiscovergy==2.0.5 # homeassistant.components.hydrawise -pydrawise==2023.11.0 +pydrawise==2024.1.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 9c69212ad57c8738201f3c126c672f2150803c53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 3 Jan 2024 22:37:56 -1000 Subject: [PATCH 0223/1544] Add test coverage for ESPHome service calls (#107042) --- homeassistant/components/esphome/manager.py | 9 +- tests/components/esphome/conftest.py | 19 ++ tests/components/esphome/test_manager.py | 189 +++++++++++++++++++- 3 files changed, 213 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 1c0f82de4ae..f0263bdc48b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -203,14 +203,19 @@ class ESPHomeManager: template.render_complex(data_template, service.variables) ) except TemplateError as ex: - _LOGGER.error("Error rendering data template for %s: %s", self.host, ex) + _LOGGER.error( + "Error rendering data template %s for %s: %s", + service.data_template, + self.host, + ex, + ) return if service.is_event: device_id = self.device_id # ESPHome uses service call packet for both events and service calls # Ensure the user can only send events of form 'esphome.xyz' - if domain != "esphome": + if domain != DOMAIN: _LOGGER.error( "Can only generate events under esphome domain! (%s)", self.host ) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 9182e021a65..3acc5112720 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -14,6 +14,7 @@ from aioesphomeapi import ( DeviceInfo, EntityInfo, EntityState, + HomeassistantServiceCall, ReconnectLogic, UserService, ) @@ -176,6 +177,7 @@ class MockESPHomeDevice: """Init the mock.""" self.entry = entry self.state_callback: Callable[[EntityState], None] + self.service_call_callback: Callable[[HomeassistantServiceCall], None] self.on_disconnect: Callable[[bool], None] self.on_connect: Callable[[bool], None] @@ -183,6 +185,16 @@ class MockESPHomeDevice: """Set the state callback.""" self.state_callback = state_callback + def set_service_call_callback( + self, callback: Callable[[HomeassistantServiceCall], None] + ) -> None: + """Set the service call callback.""" + self.service_call_callback = callback + + def mock_service_call(self, service_call: HomeassistantServiceCall) -> None: + """Mock a service call.""" + self.service_call_callback(service_call) + def set_state(self, state: EntityState) -> None: """Mock setting state.""" self.state_callback(state) @@ -242,12 +254,19 @@ async def _mock_generic_device_entry( for state in states: callback(state) + async def _subscribe_service_calls( + callback: Callable[[HomeassistantServiceCall], None], + ) -> None: + """Subscribe to service calls.""" + mock_device.set_service_call_callback(callback) + mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) mock_client.list_entities_services = AsyncMock( return_value=mock_list_entities_services ) mock_client.subscribe_states = _subscribe_states + mock_client.subscribe_service_calls = _subscribe_service_calls try_connect_done = Event() diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 94820a03fc6..1376e8bd41d 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -7,6 +7,7 @@ from aioesphomeapi import ( DeviceInfo, EntityInfo, EntityState, + HomeassistantServiceCall, UserService, UserServiceArg, UserServiceArgType, @@ -16,19 +17,203 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.esphome.const import ( + CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, DOMAIN, STABLE_BLE_VERSION_STR, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_capture_events, async_mock_service + + +async def test_esphome_device_service_calls_not_allowed( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with service calls not allowed.""" + entity_info = [] + states = [] + user_service = [] + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"esphome_version": "2023.3.0"}, + ) + await hass.async_block_till_done() + mock_esphome_test = async_mock_service(hass, "esphome", "test") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data={}, + ) + ) + await hass.async_block_till_done() + assert len(mock_esphome_test) == 0 + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" + ) + assert issue is not None + assert ( + "If you trust this device and want to allow access " + "for it to make Home Assistant service calls, you can " + "enable this functionality in the options flow" + ) in caplog.text + + +async def test_esphome_device_service_calls_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with service calls are allowed.""" + entity_info = [] + states = [] + user_service = [] + mock_config_entry.options = {CONF_ALLOW_SERVICE_CALLS: True} + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"esphome_version": "2023.3.0"}, + entry=mock_config_entry, + ) + await hass.async_block_till_done() + mock_calls: list[ServiceCall] = [] + + async def _mock_service(call: ServiceCall) -> None: + mock_calls.append(call) + + hass.services.async_register(DOMAIN, "test", _mock_service) + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data={"raw": "data"}, + ) + ) + await hass.async_block_till_done() + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" + ) + assert issue is None + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "data"} + mock_calls.clear() + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{invalid}}"}, + ) + ) + await hass.async_block_till_done() + assert ( + "Template variable warning: 'invalid' is undefined when rendering '{{invalid}}'" + in caplog.text + ) + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": ""} + mock_calls.clear() + caplog.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{-- invalid --}}"}, + ) + ) + await hass.async_block_till_done() + assert "TemplateSyntaxError" in caplog.text + assert "{{-- invalid --}}" in caplog.text + assert len(mock_calls) == 0 + mock_calls.clear() + caplog.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "{{var}}"}, + variables={"var": "value"}, + ) + ) + await hass.async_block_till_done() + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "value"} + mock_calls.clear() + + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + data_template={"raw": "valid"}, + ) + ) + await hass.async_block_till_done() + assert len(mock_calls) == 1 + service_call = mock_calls[0] + assert service_call.domain == DOMAIN + assert service_call.service == "test" + assert service_call.data == {"raw": "valid"} + mock_calls.clear() + + # Try firing events + events = async_capture_events(hass, "esphome.test") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.test", + is_event=True, + data={"raw": "event"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 1 + event = events[0] + assert event.data["raw"] == "event" + assert event.event_type == "esphome.test" + events.clear() + caplog.clear() + + # Try firing events for disallowed domain + events = async_capture_events(hass, "wrong.test") + device.mock_service_call( + HomeassistantServiceCall( + service="wrong.test", + is_event=True, + data={"raw": "event"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 0 + assert "Can only generate events under esphome domain" in caplog.text + events.clear() async def test_esphome_device_with_old_bluetooth( From 254abeeb4fa1cf871fdb232eb94eb74a1824849d Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 4 Jan 2024 09:45:08 +0100 Subject: [PATCH 0224/1544] Remove dead code in fibaro light (#106890) --- homeassistant/components/fibaro/light.py | 35 ++++++------------------ 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 981b81fdd43..17de9a6636a 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -1,9 +1,7 @@ """Support for Fibaro lights.""" from __future__ import annotations -import asyncio from contextlib import suppress -from functools import partial from typing import Any from pyfibaro.fibaro_device import DeviceModel @@ -68,8 +66,6 @@ class FibaroLight(FibaroDevice, LightEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the light.""" - self._update_lock = asyncio.Lock() - supports_color = ( "color" in fibaro_device.properties or "colorComponents" in fibaro_device.properties @@ -106,13 +102,8 @@ class FibaroLight(FibaroDevice, LightEntity): super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - async def async_turn_on(self, **kwargs: Any) -> None: + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - async with self._update_lock: - await self.hass.async_add_executor_job(partial(self._turn_on, **kwargs)) - - def _turn_on(self, **kwargs): - """Really turn the light on.""" if ATTR_BRIGHTNESS in kwargs: self._attr_brightness = kwargs[ATTR_BRIGHTNESS] self.set_level(scaleto99(self._attr_brightness)) @@ -120,26 +111,23 @@ class FibaroLight(FibaroDevice, LightEntity): if ATTR_RGB_COLOR in kwargs: # Update based on parameters - self._attr_rgb_color = kwargs[ATTR_RGB_COLOR] - self.call_set_color(*self._attr_rgb_color, 0) + rgb = kwargs[ATTR_RGB_COLOR] + self._attr_rgb_color = rgb + self.call_set_color(int(rgb[0]), int(rgb[1]), int(rgb[2]), 0) return if ATTR_RGBW_COLOR in kwargs: # Update based on parameters - self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] - self.call_set_color(*self._attr_rgbw_color) + rgbw = kwargs[ATTR_RGBW_COLOR] + self._attr_rgbw_color = rgbw + self.call_set_color(int(rgbw[0]), int(rgbw[1]), int(rgbw[2]), int(rgbw[3])) return # The simplest case is left for last. No dimming, just switch on self.call_turn_on() - async def async_turn_off(self, **kwargs: Any) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - async with self._update_lock: - await self.hass.async_add_executor_job(partial(self._turn_off, **kwargs)) - - def _turn_off(self, **kwargs): - """Really turn the light off.""" self.call_turn_off() @property @@ -165,13 +153,8 @@ class FibaroLight(FibaroDevice, LightEntity): return False - async def async_update(self) -> None: + def update(self) -> None: """Update the state.""" - async with self._update_lock: - await self.hass.async_add_executor_job(self._update) - - def _update(self): - """Really update the state.""" super().update() # Brightness handling if brightness_supported(self.supported_color_modes): From 333711d9517c3c8cb09fa287f184ad50d0b60388 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 09:57:00 +0100 Subject: [PATCH 0225/1544] Add sensor tests to Streamlabs water (#107065) --- .../components/streamlabswater/sensor.py | 6 + tests/components/streamlabswater/__init__.py | 14 ++ tests/components/streamlabswater/conftest.py | 35 +++++ .../fixtures/get_locations.json | 24 ++++ .../streamlabswater/fixtures/water_usage.json | 6 + .../snapshots/test_sensor.ambr | 136 ++++++++++++++++++ .../components/streamlabswater/test_sensor.py | 33 +++++ 7 files changed, 254 insertions(+) create mode 100644 tests/components/streamlabswater/fixtures/get_locations.json create mode 100644 tests/components/streamlabswater/fixtures/water_usage.json create mode 100644 tests/components/streamlabswater/snapshots/test_sensor.ambr create mode 100644 tests/components/streamlabswater/test_sensor.py diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 6c869a6d1bc..6f17c23b4f5 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -44,11 +44,13 @@ class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntit _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS + _key = "daily_usage" def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the daily water usage device.""" super().__init__(coordinator) self._location_id = location_id + self._attr_unique_id = f"{location_id}-{self._key}" @property def location_data(self) -> StreamlabsData: @@ -69,6 +71,8 @@ class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntit class StreamLabsMonthlyUsage(StreamLabsDailyUsage): """Monitors the monthly water usage.""" + _key = "monthly_usage" + @property def name(self) -> str: """Return the name for monthly usage.""" @@ -83,6 +87,8 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): class StreamLabsYearlyUsage(StreamLabsDailyUsage): """Monitors the yearly water usage.""" + _key = "yearly_usage" + @property def name(self) -> str: """Return the name for yearly usage.""" diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py index 16b2e5f0974..a467c9553de 100644 --- a/tests/components/streamlabswater/__init__.py +++ b/tests/components/streamlabswater/__init__.py @@ -1 +1,15 @@ """Tests for the StreamLabs integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + hass.config.units = IMPERIAL_SYSTEM + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/streamlabswater/conftest.py b/tests/components/streamlabswater/conftest.py index f871332e5f6..64fbed63520 100644 --- a/tests/components/streamlabswater/conftest.py +++ b/tests/components/streamlabswater/conftest.py @@ -3,6 +3,12 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from streamlabswater.streamlabswater import StreamlabsClient + +from homeassistant.components.streamlabswater import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -12,3 +18,32 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.streamlabswater.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock StreamLabs config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="StreamLabs", + data={CONF_API_KEY: "abc"}, + ) + + +@pytest.fixture(name="streamlabswater") +def mock_streamlabswater() -> Generator[AsyncMock, None, None]: + """Mock the StreamLabs client.""" + + locations = load_json_object_fixture("streamlabswater/get_locations.json") + + water_usage = load_json_object_fixture("streamlabswater/water_usage.json") + + mock = AsyncMock(spec=StreamlabsClient) + mock.get_locations.return_value = locations + mock.get_water_usage_summary.return_value = water_usage + + with patch( + "homeassistant.components.streamlabswater.StreamlabsClient", + return_value=mock, + ) as mock_client: + yield mock_client diff --git a/tests/components/streamlabswater/fixtures/get_locations.json b/tests/components/streamlabswater/fixtures/get_locations.json new file mode 100644 index 00000000000..bdf4deb1d1b --- /dev/null +++ b/tests/components/streamlabswater/fixtures/get_locations.json @@ -0,0 +1,24 @@ +{ + "pageCount": 1, + "perPage": 50, + "page": 1, + "total": 1, + "locations": [ + { + "locationId": "945e7c52-854a-41e1-8524-50c6993277e1", + "name": "Water Monitor", + "homeAway": "home", + "devices": [ + { + "deviceId": "09bec87a-fff2-4b8a-bc00-86d5928f19f3", + "type": "monitor", + "calibrated": true, + "connected": true + } + ], + "alerts": [], + "subscriptionIds": [], + "active": true + } + ] +} diff --git a/tests/components/streamlabswater/fixtures/water_usage.json b/tests/components/streamlabswater/fixtures/water_usage.json new file mode 100644 index 00000000000..1e902371f73 --- /dev/null +++ b/tests/components/streamlabswater/fixtures/water_usage.json @@ -0,0 +1,6 @@ +{ + "thisYear": 65432.389256934, + "today": 200.44691536, + "units": "gallons", + "thisMonth": 420.514099294 +} diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9a4c1cdc97e --- /dev/null +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_all_entities[sensor.water_monitor_daily_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_daily_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water Monitor Daily Water', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-daily_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_daily_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Daily Water', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_daily_water', + 'last_changed': , + 'last_updated': , + 'state': '200.4', + }) +# --- +# name: test_all_entities[sensor.water_monitor_monthly_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_monthly_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water Monitor Monthly Water', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-monthly_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_monthly_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Monthly Water', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_monthly_water', + 'last_changed': , + 'last_updated': , + 'state': '420.5', + }) +# --- +# name: test_all_entities[sensor.water_monitor_yearly_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_monitor_yearly_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water Monitor Yearly Water', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-yearly_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.water_monitor_yearly_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water Monitor Yearly Water', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_monitor_yearly_water', + 'last_changed': , + 'last_updated': , + 'state': '65432.4', + }) +# --- diff --git a/tests/components/streamlabswater/test_sensor.py b/tests/components/streamlabswater/test_sensor.py new file mode 100644 index 00000000000..a78d4129abb --- /dev/null +++ b/tests/components/streamlabswater/test_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the Streamlabs Water sensor platform.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.streamlabswater import setup_integration + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + streamlabswater: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.streamlabswater.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) From 26055ee636acbf01025dc759d61d6d3646e63648 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 Jan 2024 10:19:45 +0100 Subject: [PATCH 0226/1544] Update home-assistant/builder to 2024.01.0 (#107069) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 51b8fb286ef..4a767d234b5 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.12.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ @@ -274,7 +274,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.12.0 + uses: home-assistant/builder@2024.01.0 with: args: | $BUILD_ARGS \ From 5508bb3ef95fffdc5fd729ff1eee1b08a6aae8fe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jan 2024 10:43:04 +0100 Subject: [PATCH 0227/1544] Refactor drop sensor tests (#106965) * Refactor drop sensor tests * Setup the config entry instead of the component --- tests/components/drop_connect/common.py | 167 ++++++++++++++++ tests/components/drop_connect/test_sensor.py | 188 +++++++++---------- 2 files changed, 261 insertions(+), 94 deletions(-) diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py index ea96af03617..2e4d59fe7b2 100644 --- a/tests/components/drop_connect/common.py +++ b/tests/components/drop_connect/common.py @@ -1,5 +1,20 @@ """Define common test values.""" +from homeassistant.components.drop_connect.const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry + +from tests.common import MockConfigEntry + TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" TEST_DATA_HUB = ( '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' @@ -49,3 +64,155 @@ TEST_DATA_RO_FILTER = ( TEST_DATA_RO_FILTER_RESET = ( '{"leak":0,"tdsIn":0,"tdsOut":0,"cart1":0,"cart2":0,"cart3":0}' ) + + +def config_entry_hub() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", + CONF_DEVICE_DESC: "Hub", + CONF_DEVICE_ID: 255, + CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", + CONF_DEVICE_TYPE: "hub", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_salt() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_8", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", + CONF_DEVICE_DESC: "Salt Sensor", + CONF_DEVICE_ID: 8, + CONF_DEVICE_NAME: "Salt Sensor", + CONF_DEVICE_TYPE: "salt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_leak() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_20", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", + CONF_DEVICE_DESC: "Leak Detector", + CONF_DEVICE_ID: 20, + CONF_DEVICE_NAME: "Leak Detector", + CONF_DEVICE_TYPE: "leak", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_softener() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_0", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", + CONF_DEVICE_DESC: "Softener", + CONF_DEVICE_ID: 0, + CONF_DEVICE_NAME: "Softener", + CONF_DEVICE_TYPE: "soft", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_filter() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_4", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", + CONF_DEVICE_DESC: "Filter", + CONF_DEVICE_ID: 4, + CONF_DEVICE_NAME: "Filter", + CONF_DEVICE_TYPE: "filt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_protection_valve() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_78", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", + CONF_DEVICE_DESC: "Protection Valve", + CONF_DEVICE_ID: 78, + CONF_DEVICE_NAME: "Protection Valve", + CONF_DEVICE_TYPE: "pv", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_pump_controller() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_83", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", + CONF_DEVICE_DESC: "Pump Controller", + CONF_DEVICE_ID: 83, + CONF_DEVICE_NAME: "Pump Controller", + CONF_DEVICE_TYPE: "pc", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +def config_entry_ro_filter() -> ConfigEntry: + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", + CONF_DEVICE_DESC: "RO Filter", + CONF_DEVICE_ID: 95, + CONF_DEVICE_NAME: "RO Filter", + CONF_DEVICE_TYPE: "ro", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py index 589fd08488c..43da49af884 100644 --- a/tests/components/drop_connect/test_sensor.py +++ b/tests/components/drop_connect/test_sensor.py @@ -1,8 +1,7 @@ """Test DROP sensor entities.""" -from homeassistant.components.drop_connect.const import DOMAIN + from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( TEST_DATA_FILTER, @@ -26,36 +25,41 @@ from .common import ( TEST_DATA_SOFTENER, TEST_DATA_SOFTENER_RESET, TEST_DATA_SOFTENER_TOPIC, + config_entry_filter, + config_entry_hub, + config_entry_leak, + config_entry_protection_valve, + config_entry_pump_controller, + config_entry_ro_filter, + config_entry_softener, ) from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_sensors_hub( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN peak_flow_sensor_name = "sensor.hub_drop_1_c0ffee_peak_water_flow_rate_today" - hass.states.async_set(peak_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(peak_flow_sensor_name).state == STATE_UNKNOWN used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" - hass.states.async_set(used_today_sensor_name, STATE_UNKNOWN) + assert hass.states.get(used_today_sensor_name).state == STATE_UNKNOWN average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" - hass.states.async_set(average_usage_sensor_name, STATE_UNKNOWN) + assert hass.states.get(average_usage_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" - hass.states.async_set(psi_high_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_high_sensor_name).state == STATE_UNKNOWN psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" - hass.states.async_set(psi_low_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_low_sensor_name).state == STATE_UNKNOWN battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() @@ -64,49 +68,47 @@ async def test_sensors_hub( current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 5.8 + assert current_flow_sensor.state == "5.77" peak_flow_sensor = hass.states.get(peak_flow_sensor_name) assert peak_flow_sensor - assert round(float(peak_flow_sensor.state), 1) == 13.8 + assert peak_flow_sensor.state == "13.8" used_today_sensor = hass.states.get(used_today_sensor_name) assert used_today_sensor - assert round(float(used_today_sensor.state), 1) == 881.1 # liters + assert used_today_sensor.state == "881.13030096168" # liters average_usage_sensor = hass.states.get(average_usage_sensor_name) assert average_usage_sensor - assert round(float(average_usage_sensor.state), 1) == 287.7 # liters + assert average_usage_sensor.state == "287.691295584" # liters psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 428.9 # centibars + assert psi_sensor.state == "428.8538854" # centibars psi_high_sensor = hass.states.get(psi_high_sensor_name) assert psi_high_sensor - assert round(float(psi_high_sensor.state), 1) == 427.5 # centibars + assert psi_high_sensor.state == "427.474934" # centibars psi_low_sensor = hass.states.get(psi_low_sensor_name) assert psi_low_sensor - assert round(float(psi_low_sensor.state), 1) == 420.6 # centibars + assert psi_low_sensor.state == "420.580177" # centibars battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 50 + assert battery_sensor.state == "50" -async def test_sensors_leak( - hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_leak(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for leak detectors.""" - config_entry_leak.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_leak() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) battery_sensor_name = "sensor.leak_detector_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.leak_detector_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) await hass.async_block_till_done() @@ -115,29 +117,29 @@ async def test_sensors_leak( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 100 + assert battery_sensor.state == "100" temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 20.1 # C + assert temp_sensor.state == "20.1111111111111" # °C async def test_sensors_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) battery_sensor_name = "sensor.softener_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.softener_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.softener_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN capacity_sensor_name = "sensor.softener_capacity_remaining" - hass.states.async_set(capacity_sensor_name, STATE_UNKNOWN) + assert hass.states.get(capacity_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) await hass.async_block_till_done() @@ -146,35 +148,33 @@ async def test_sensors_softener( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 20 + assert battery_sensor.state == "20" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 5.0 + assert current_flow_sensor.state == "5.0" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 348.2 # centibars + assert psi_sensor.state == "348.1852285" # centibars capacity_sensor = hass.states.get(capacity_sensor_name) assert capacity_sensor - assert round(float(capacity_sensor.state), 1) == 3785.4 # liters + assert capacity_sensor.state == "3785.411784" # liters -async def test_sensors_filter( - hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient -) -> None: +async def test_sensors_filter(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP sensors for filters.""" - config_entry_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) battery_sensor_name = "sensor.filter_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.filter_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.filter_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) await hass.async_block_till_done() @@ -183,33 +183,33 @@ async def test_sensors_filter( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert round(float(battery_sensor.state), 1) == 12.0 + assert battery_sensor.state == "12" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 19.8 + assert current_flow_sensor.state == "19.84" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 263.4 # centibars + assert psi_sensor.state == "263.3797174" # centibars async def test_sensors_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) battery_sensor_name = "sensor.protection_valve_battery" - hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + assert hass.states.get(battery_sensor_name).state == STATE_UNKNOWN current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.protection_valve_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.protection_valve_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET @@ -222,35 +222,35 @@ async def test_sensors_protection_valve( battery_sensor = hass.states.get(battery_sensor_name) assert battery_sensor - assert int(battery_sensor.state) == 0 + assert battery_sensor.state == "0" current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 7.1 + assert current_flow_sensor.state == "7.1" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 422.6 # centibars + assert psi_sensor.state == "422.6486041" # centibars temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 21.4 # C + assert temp_sensor.state == "21.3888888888889" # °C async def test_sensors_pump_controller( - hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for pump controllers.""" - config_entry_pump_controller.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_pump_controller() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) current_flow_sensor_name = "sensor.pump_controller_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN psi_sensor_name = "sensor.pump_controller_current_water_pressure" - hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + assert hass.states.get(psi_sensor_name).state == STATE_UNKNOWN temp_sensor_name = "sensor.pump_controller_temperature" - hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + assert hass.states.get(temp_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message( hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET @@ -263,35 +263,35 @@ async def test_sensors_pump_controller( current_flow_sensor = hass.states.get(current_flow_sensor_name) assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 2.2 + assert current_flow_sensor.state == "2.2" psi_sensor = hass.states.get(psi_sensor_name) assert psi_sensor - assert round(float(psi_sensor.state), 1) == 428.9 # centibars + assert psi_sensor.state == "428.8538854" # centibars temp_sensor = hass.states.get(temp_sensor_name) assert temp_sensor - assert round(float(temp_sensor.state), 1) == 20.4 # C + assert temp_sensor.state == "20.4444444444444" # °C async def test_sensors_ro_filter( - hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP sensors for RO filters.""" - config_entry_ro_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_ro_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) tds_in_sensor_name = "sensor.ro_filter_inlet_tds" - hass.states.async_set(tds_in_sensor_name, STATE_UNKNOWN) + assert hass.states.get(tds_in_sensor_name).state == STATE_UNKNOWN tds_out_sensor_name = "sensor.ro_filter_outlet_tds" - hass.states.async_set(tds_out_sensor_name, STATE_UNKNOWN) + assert hass.states.get(tds_out_sensor_name).state == STATE_UNKNOWN cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" - hass.states.async_set(cart1_sensor_name, STATE_UNKNOWN) + assert hass.states.get(cart1_sensor_name).state == STATE_UNKNOWN cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" - hass.states.async_set(cart2_sensor_name, STATE_UNKNOWN) + assert hass.states.get(cart2_sensor_name).state == STATE_UNKNOWN cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" - hass.states.async_set(cart3_sensor_name, STATE_UNKNOWN) + assert hass.states.get(cart3_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) await hass.async_block_till_done() @@ -300,20 +300,20 @@ async def test_sensors_ro_filter( tds_in_sensor = hass.states.get(tds_in_sensor_name) assert tds_in_sensor - assert int(tds_in_sensor.state) == 164 + assert tds_in_sensor.state == "164" tds_out_sensor = hass.states.get(tds_out_sensor_name) assert tds_out_sensor - assert int(tds_out_sensor.state) == 9 + assert tds_out_sensor.state == "9" cart1_sensor = hass.states.get(cart1_sensor_name) assert cart1_sensor - assert int(cart1_sensor.state) == 59 + assert cart1_sensor.state == "59" cart2_sensor = hass.states.get(cart2_sensor_name) assert cart2_sensor - assert int(cart2_sensor.state) == 80 + assert cart2_sensor.state == "80" cart3_sensor = hass.states.get(cart3_sensor_name) assert cart3_sensor - assert int(cart3_sensor.state) == 59 + assert cart3_sensor.state == "59" From 5ae8b6bc02581fd70dc7fe8069260a64f9c5d2e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 11:53:04 +0100 Subject: [PATCH 0228/1544] Add entity descriptions to Streamlabs water (#107071) * Add sensor tests to Streamlabs water * Add sensor tests to Streamlabs water * Use entity descriptions in streamlabs water * Use entity descriptions in streamlabs water * Use entity descriptions in streamlabs water * Add translations --- .../components/streamlabswater/sensor.py | 124 +++++++++--------- .../components/streamlabswater/strings.json | 13 ++ .../snapshots/test_sensor.ambr | 48 +++---- 3 files changed, 100 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 6f17c23b4f5..e49668208af 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,20 +1,57 @@ """Support for Streamlabs Water Monitor Usage.""" from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import StreamlabsCoordinator from .const import DOMAIN from .coordinator import StreamlabsData -NAME_DAILY_USAGE = "Daily Water" -NAME_MONTHLY_USAGE = "Monthly Water" -NAME_YEARLY_USAGE = "Yearly Water" + +@dataclass(frozen=True, kw_only=True) +class StreamlabsWaterSensorEntityDescription(SensorEntityDescription): + """Streamlabs sensor entity description.""" + + value_fn: Callable[[StreamlabsData], StateType] + + +SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( + StreamlabsWaterSensorEntityDescription( + key="daily_usage", + translation_key="daily_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda data: data.daily_usage, + ), + StreamlabsWaterSensorEntityDescription( + key="monthly_usage", + translation_key="monthly_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda data: data.monthly_usage, + ), + StreamlabsWaterSensorEntityDescription( + key="yearly_usage", + translation_key="yearly_usage", + native_unit_of_measurement=UnitOfVolume.GALLONS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda data: data.yearly_usage, + ), +) async def async_setup_entry( @@ -25,32 +62,34 @@ async def async_setup_entry( """Set up Streamlabs water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - entities = [] - - for location_id in coordinator.data: - entities.extend( - [ - StreamLabsDailyUsage(coordinator, location_id), - StreamLabsMonthlyUsage(coordinator, location_id), - StreamLabsYearlyUsage(coordinator, location_id), - ] - ) - - async_add_entities(entities) + async_add_entities( + StreamLabsSensor(coordinator, location_id, entity_description) + for location_id in coordinator.data + for entity_description in SENSORS + ) -class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): +class StreamLabsSensor(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): """Monitors the daily water usage.""" - _attr_device_class = SensorDeviceClass.WATER - _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - _key = "daily_usage" + _attr_has_entity_name = True - def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: + entity_description: StreamlabsWaterSensorEntityDescription + + def __init__( + self, + coordinator: StreamlabsCoordinator, + location_id: str, + entity_description: StreamlabsWaterSensorEntityDescription, + ) -> None: """Initialize the daily water usage device.""" super().__init__(coordinator) self._location_id = location_id - self._attr_unique_id = f"{location_id}-{self._key}" + self._attr_unique_id = f"{location_id}-{entity_description.key}" + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, location_id)}, name=self.location_data.name + ) @property def location_data(self) -> StreamlabsData: @@ -58,43 +97,6 @@ class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntit return self.coordinator.data[self._location_id] @property - def name(self) -> str: - """Return the name for daily usage.""" - return f"{self.location_data.name} {NAME_DAILY_USAGE}" - - @property - def native_value(self) -> float: + def native_value(self) -> StateType: """Return the current daily usage.""" - return self.location_data.daily_usage - - -class StreamLabsMonthlyUsage(StreamLabsDailyUsage): - """Monitors the monthly water usage.""" - - _key = "monthly_usage" - - @property - def name(self) -> str: - """Return the name for monthly usage.""" - return f"{self.location_data.name} {NAME_MONTHLY_USAGE}" - - @property - def native_value(self) -> float: - """Return the current monthly usage.""" - return self.location_data.monthly_usage - - -class StreamLabsYearlyUsage(StreamLabsDailyUsage): - """Monitors the yearly water usage.""" - - _key = "yearly_usage" - - @property - def name(self) -> str: - """Return the name for yearly usage.""" - return f"{self.location_data.name} {NAME_YEARLY_USAGE}" - - @property - def native_value(self) -> float: - """Return the current yearly usage.""" - return self.location_data.yearly_usage + return self.entity_description.value_fn(self.location_data) diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index e6b5dd7465b..393c2119501 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -31,6 +31,19 @@ } } }, + "entity": { + "sensor": { + "daily_usage": { + "name": "Daily usage" + }, + "monthly_usage": { + "name": "Monthly usage" + }, + "yearly_usage": { + "name": "Yearly usage" + } + } + }, "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The Streamlabs water YAML configuration import failed", diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr index 9a4c1cdc97e..5cd2479903a 100644 --- a/tests/components/streamlabswater/snapshots/test_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.water_monitor_daily_water-entry] +# name: test_all_entities[sensor.water_monitor_daily_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -11,8 +11,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_monitor_daily_water', - 'has_entity_name': False, + 'entity_id': 'sensor.water_monitor_daily_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -21,30 +21,30 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Water Monitor Daily Water', + 'original_name': 'Daily usage', 'platform': 'streamlabswater', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'daily_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-daily_usage', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.water_monitor_daily_water-state] +# name: test_all_entities[sensor.water_monitor_daily_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water Monitor Daily Water', + 'friendly_name': 'Water Monitor Daily usage', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_monitor_daily_water', + 'entity_id': 'sensor.water_monitor_daily_usage', 'last_changed': , 'last_updated': , 'state': '200.4', }) # --- -# name: test_all_entities[sensor.water_monitor_monthly_water-entry] +# name: test_all_entities[sensor.water_monitor_monthly_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -56,8 +56,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_monitor_monthly_water', - 'has_entity_name': False, + 'entity_id': 'sensor.water_monitor_monthly_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -66,30 +66,30 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Water Monitor Monthly Water', + 'original_name': 'Monthly usage', 'platform': 'streamlabswater', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'monthly_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-monthly_usage', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.water_monitor_monthly_water-state] +# name: test_all_entities[sensor.water_monitor_monthly_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water Monitor Monthly Water', + 'friendly_name': 'Water Monitor Monthly usage', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_monitor_monthly_water', + 'entity_id': 'sensor.water_monitor_monthly_usage', 'last_changed': , 'last_updated': , 'state': '420.5', }) # --- -# name: test_all_entities[sensor.water_monitor_yearly_water-entry] +# name: test_all_entities[sensor.water_monitor_yearly_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -101,8 +101,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_monitor_yearly_water', - 'has_entity_name': False, + 'entity_id': 'sensor.water_monitor_yearly_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -111,24 +111,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Water Monitor Yearly Water', + 'original_name': 'Yearly usage', 'platform': 'streamlabswater', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'yearly_usage', 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-yearly_usage', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.water_monitor_yearly_water-state] +# name: test_all_entities[sensor.water_monitor_yearly_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water Monitor Yearly Water', + 'friendly_name': 'Water Monitor Yearly usage', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_monitor_yearly_water', + 'entity_id': 'sensor.water_monitor_yearly_usage', 'last_changed': , 'last_updated': , 'state': '65432.4', From 10f5ce2dc083b21773e027c918d2bb499aff5ed6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jan 2024 13:12:11 +0100 Subject: [PATCH 0229/1544] Refactor drop tests for binary_sensor (#107090) --- .../drop_connect/test_binary_sensor.py | 136 +++++++++--------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py index ca94faeec5e..2f54e8fb791 100644 --- a/tests/components/drop_connect/test_binary_sensor.py +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -1,9 +1,7 @@ """Test DROP binary sensor entities.""" -from homeassistant.components.drop_connect.const import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( TEST_DATA_HUB, @@ -27,6 +25,13 @@ from .common import ( TEST_DATA_SOFTENER, TEST_DATA_SOFTENER_RESET, TEST_DATA_SOFTENER_TOPIC, + config_entry_hub, + config_entry_leak, + config_entry_protection_valve, + config_entry_pump_controller, + config_entry_ro_filter, + config_entry_salt, + config_entry_softener, ) from tests.common import async_fire_mqtt_message @@ -34,159 +39,158 @@ from tests.typing import MqttMockHAClient async def test_binary_sensors_hub( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) pending_notifications_sensor_name = ( "binary_sensor.hub_drop_1_c0ffee_notification_unread" ) - hass.states.async_set(pending_notifications_sensor_name, STATE_UNKNOWN) + assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + assert hass.states.get(leak_sensor_name).state == STATE_OFF async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() + assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF + assert hass.states.get(leak_sensor_name).state == STATE_OFF + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() - - pending_notifications = hass.states.get(pending_notifications_sensor_name) - assert pending_notifications.state == STATE_ON - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_OFF + assert hass.states.get(pending_notifications_sensor_name).state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_OFF async def test_binary_sensors_salt( - hass: HomeAssistant, config_entry_salt, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for salt sensors.""" - config_entry_salt.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_salt() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) salt_sensor_name = "binary_sensor.salt_sensor_salt_low" - hass.states.async_set(salt_sensor_name, STATE_UNKNOWN) + assert hass.states.get(salt_sensor_name).state == STATE_OFF async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) await hass.async_block_till_done() + assert hass.states.get(salt_sensor_name).state == STATE_OFF + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) await hass.async_block_till_done() - - salt = hass.states.get(salt_sensor_name) - assert salt.state == STATE_ON + assert hass.states.get(salt_sensor_name).state == STATE_ON async def test_binary_sensors_leak( - hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for leak detectors.""" - config_entry_leak.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_leak() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) leak_sensor_name = "binary_sensor.leak_detector_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + assert hass.states.get(leak_sensor_name).state == STATE_OFF async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) await hass.async_block_till_done() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_ON async def test_binary_sensors_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" - hass.states.async_set(reserve_in_use_sensor_name, STATE_UNKNOWN) + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) await hass.async_block_till_done() + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) await hass.async_block_till_done() - - reserve_in_use = hass.states.get(reserve_in_use_sensor_name) - assert reserve_in_use.state == STATE_ON + assert hass.states.get(reserve_in_use_sensor_name).state == STATE_ON async def test_binary_sensors_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) leak_sensor_name = "binary_sensor.protection_valve_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + assert hass.states.get(leak_sensor_name).state == STATE_OFF async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET ) await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE ) await hass.async_block_till_done() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_ON async def test_binary_sensors_pump_controller( - hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for pump controllers.""" - config_entry_pump_controller.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_pump_controller() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) leak_sensor_name = "binary_sensor.pump_controller_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + assert hass.states.get(leak_sensor_name).state == STATE_OFF pump_sensor_name = "binary_sensor.pump_controller_pump_status" - hass.states.async_set(pump_sensor_name, STATE_UNKNOWN) + assert hass.states.get(pump_sensor_name).state == STATE_OFF async_fire_mqtt_message( hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET ) await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + assert hass.states.get(pump_sensor_name).state == STATE_OFF + async_fire_mqtt_message( hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER ) await hass.async_block_till_done() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON - pump = hass.states.get(pump_sensor_name) - assert pump.state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_ON + assert hass.states.get(pump_sensor_name).state == STATE_ON async def test_binary_sensors_ro_filter( - hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP binary sensors for RO filters.""" - config_entry_ro_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_ro_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) leak_sensor_name = "binary_sensor.ro_filter_leak_detected" - hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + assert hass.states.get(leak_sensor_name).state == STATE_OFF async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) await hass.async_block_till_done() + assert hass.states.get(leak_sensor_name).state == STATE_OFF + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) await hass.async_block_till_done() - - leak = hass.states.get(leak_sensor_name) - assert leak.state == STATE_ON + assert hass.states.get(leak_sensor_name).state == STATE_ON From 1a08bcce778fdd6e9f59eb2d20b63bf6720e560b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 02:21:52 -1000 Subject: [PATCH 0230/1544] Fix missing backwards compatibility layer for water_heater supported_features (#107091) --- .../components/water_heater/__init__.py | 4 ++-- tests/components/water_heater/test_init.py | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index f2744416900..e5cf2cc2d3c 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -241,7 +241,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features_compat: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -277,7 +277,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ), } - supported_features = self.supported_features + supported_features = self.supported_features_compat if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 0d33f3a9e93..861be192340 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -8,10 +8,13 @@ import voluptuous as vol from homeassistant.components import water_heater from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityFeature, ) +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from tests.common import async_mock_service, import_and_test_deprecated_constant_enum @@ -117,21 +120,26 @@ def test_deprecated_constants( ) -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +def test_deprecated_supported_features_ints( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated supported features ints.""" class MockWaterHeaterEntity(WaterHeaterEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 + _attr_operation_list = ["mode1", "mode2"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_current_operation = "mode1" + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE.value entity = MockWaterHeaterEntity() - assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + entity.hass = hass + assert entity.supported_features_compat is WaterHeaterEntityFeature(2) assert "MockWaterHeaterEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text assert "Instead it should use" in caplog.text - assert "WaterHeaterEntityFeature.TARGET_TEMPERATURE" in caplog.text + assert "WaterHeaterEntityFeature.OPERATION_MODE" in caplog.text caplog.clear() - assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert entity.supported_features_compat is WaterHeaterEntityFeature(2) assert "is using deprecated supported features values" not in caplog.text + assert entity.state_attributes[ATTR_OPERATION_MODE] == "mode1" + assert entity.capability_attributes[ATTR_OPERATION_LIST] == ["mode1", "mode2"] From 9eefd95e91f5c71f804f263122eef6ff2e912ffe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 4 Jan 2024 13:25:09 +0100 Subject: [PATCH 0231/1544] Deduplicate handling of duplicated constants (#107074) * Deduplicate handling of duplicated constants * Use DeprecatedConstant + DeprecatedConstantEnum * Fixup * Remove test cases with unnamed tuples --- homeassistant/const.py | 383 +++++++++++++++------------ homeassistant/core.py | 46 +--- homeassistant/helpers/deprecation.py | 25 +- tests/helpers/test_deprecation.py | 32 --- 4 files changed, 236 insertions(+), 250 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 002b9b873c2..3aa0a75729e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,15 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, Final +from functools import partial +from typing import Final + +from .helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 @@ -307,146 +315,135 @@ EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the SensorDeviceClass enum instead. -_DEPRECATED_DEVICE_CLASS_AQI: Final = ("aqi", "SensorDeviceClass.AQI", "2025.1") -_DEPRECATED_DEVICE_CLASS_BATTERY: Final = ( +_DEPRECATED_DEVICE_CLASS_AQI: Final = DeprecatedConstant( + "aqi", "SensorDeviceClass.AQI", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY: Final = DeprecatedConstant( "battery", "SensorDeviceClass.BATTERY", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_CO: Final = ( +_DEPRECATED_DEVICE_CLASS_CO: Final = DeprecatedConstant( "carbon_monoxide", "SensorDeviceClass.CO", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_CO2: Final = ( +_DEPRECATED_DEVICE_CLASS_CO2: Final = DeprecatedConstant( "carbon_dioxide", "SensorDeviceClass.CO2", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_CURRENT: Final = ( +_DEPRECATED_DEVICE_CLASS_CURRENT: Final = DeprecatedConstant( "current", "SensorDeviceClass.CURRENT", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_DATE: Final = ("date", "SensorDeviceClass.DATE", "2025.1") -_DEPRECATED_DEVICE_CLASS_ENERGY: Final = ( +_DEPRECATED_DEVICE_CLASS_DATE: Final = DeprecatedConstant( + "date", "SensorDeviceClass.DATE", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_ENERGY: Final = DeprecatedConstant( "energy", "SensorDeviceClass.ENERGY", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = ( +_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = DeprecatedConstant( "frequency", "SensorDeviceClass.FREQUENCY", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_GAS: Final = ("gas", "SensorDeviceClass.GAS", "2025.1") -_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = ( +_DEPRECATED_DEVICE_CLASS_GAS: Final = DeprecatedConstant( + "gas", "SensorDeviceClass.GAS", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = DeprecatedConstant( "humidity", "SensorDeviceClass.HUMIDITY", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = ( +_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = DeprecatedConstant( "illuminance", "SensorDeviceClass.ILLUMINANCE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_MONETARY: Final = ( +_DEPRECATED_DEVICE_CLASS_MONETARY: Final = DeprecatedConstant( "monetary", "SensorDeviceClass.MONETARY", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE = ( +_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE: Final = DeprecatedConstant( "nitrogen_dioxide", "SensorDeviceClass.NITROGEN_DIOXIDE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE = ( +_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE: Final = DeprecatedConstant( "nitrogen_monoxide", "SensorDeviceClass.NITROGEN_MONOXIDE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE = ( +_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE: Final = DeprecatedConstant( "nitrous_oxide", "SensorDeviceClass.NITROUS_OXIDE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_OZONE: Final = ("ozone", "SensorDeviceClass.OZONE", "2025.1") -_DEPRECATED_DEVICE_CLASS_PM1: Final = ("pm1", "SensorDeviceClass.PM1", "2025.1") -_DEPRECATED_DEVICE_CLASS_PM10: Final = ("pm10", "SensorDeviceClass.PM10", "2025.1") -_DEPRECATED_DEVICE_CLASS_PM25: Final = ("pm25", "SensorDeviceClass.PM25", "2025.1") -_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = ( +_DEPRECATED_DEVICE_CLASS_OZONE: Final = DeprecatedConstant( + "ozone", "SensorDeviceClass.OZONE", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM1: Final = DeprecatedConstant( + "pm1", "SensorDeviceClass.PM1", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM10: Final = DeprecatedConstant( + "pm10", "SensorDeviceClass.PM10", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PM25: Final = DeprecatedConstant( + "pm25", "SensorDeviceClass.PM25", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = DeprecatedConstant( "power_factor", "SensorDeviceClass.POWER_FACTOR", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_POWER: Final = ("power", "SensorDeviceClass.POWER", "2025.1") -_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = ( +_DEPRECATED_DEVICE_CLASS_POWER: Final = DeprecatedConstant( + "power", "SensorDeviceClass.POWER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = DeprecatedConstant( "pressure", "SensorDeviceClass.PRESSURE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = ( +_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = DeprecatedConstant( "signal_strength", "SensorDeviceClass.SIGNAL_STRENGTH", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE = ( +_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE: Final = DeprecatedConstant( "sulphur_dioxide", "SensorDeviceClass.SULPHUR_DIOXIDE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = ( +_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = DeprecatedConstant( "temperature", "SensorDeviceClass.TEMPERATURE", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = ( +_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = DeprecatedConstant( "timestamp", "SensorDeviceClass.TIMESTAMP", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = ( +_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: Final = DeprecatedConstant( "volatile_organic_compounds", "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS", "2025.1", ) -_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = ( +_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant( "voltage", "SensorDeviceClass.VOLTAGE", "2025.1", ) -# Can be removed if no deprecated constant are in this module anymore -def __getattr__(name: str) -> Any: - """Check if the not found name is a deprecated constant. - - If it is, print a deprecation warning and return the value of the constant. - Otherwise raise AttributeError. - """ - module_globals = globals() - if f"_DEPRECATED_{name}" not in module_globals: - raise AttributeError(f"Module {__name__} has no attribute {name!r}") - - # Avoid circular import - from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel - check_if_deprecated_constant, - ) - - return check_if_deprecated_constant(name, module_globals) - - -# Can be removed if no deprecated constant are in this module anymore -def __dir__() -> list[str]: - """Return dir() with deprecated constants.""" - # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle - module_globals = globals() - - return list(module_globals) + [ - name.removeprefix("_DEPRECATED_") - for name in module_globals - if name.startswith("_DEPRECATED_") - ] +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # #### STATES #### @@ -621,7 +618,7 @@ class UnitOfApparentPower(StrEnum): VOLT_AMPERE = "VA" -_DEPRECATED_POWER_VOLT_AMPERE: Final = ( +_DEPRECATED_POWER_VOLT_AMPERE: Final = DeprecatedConstantEnum( UnitOfApparentPower.VOLT_AMPERE, "2025.1", ) @@ -637,17 +634,17 @@ class UnitOfPower(StrEnum): BTU_PER_HOUR = "BTU/h" -_DEPRECATED_POWER_WATT: Final = ( +_DEPRECATED_POWER_WATT: Final = DeprecatedConstantEnum( UnitOfPower.WATT, "2025.1", ) """Deprecated: please use UnitOfPower.WATT.""" -_DEPRECATED_POWER_KILO_WATT: Final = ( +_DEPRECATED_POWER_KILO_WATT: Final = DeprecatedConstantEnum( UnitOfPower.KILO_WATT, "2025.1", ) """Deprecated: please use UnitOfPower.KILO_WATT.""" -_DEPRECATED_POWER_BTU_PER_HOUR: Final = ( +_DEPRECATED_POWER_BTU_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfPower.BTU_PER_HOUR, "2025.1", ) @@ -668,17 +665,17 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" -_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = ( +_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum( UnitOfEnergy.KILO_WATT_HOUR, "2025.1", ) """Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" -_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = ( +_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = DeprecatedConstantEnum( UnitOfEnergy.MEGA_WATT_HOUR, "2025.1", ) """Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" -_DEPRECATED_ENERGY_WATT_HOUR: Final = ( +_DEPRECATED_ENERGY_WATT_HOUR: Final = DeprecatedConstantEnum( UnitOfEnergy.WATT_HOUR, "2025.1", ) @@ -693,12 +690,12 @@ class UnitOfElectricCurrent(StrEnum): AMPERE = "A" -_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = ( +_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = DeprecatedConstantEnum( UnitOfElectricCurrent.MILLIAMPERE, "2025.1", ) """Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE.""" -_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = ( +_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = DeprecatedConstantEnum( UnitOfElectricCurrent.AMPERE, "2025.1", ) @@ -713,12 +710,12 @@ class UnitOfElectricPotential(StrEnum): VOLT = "V" -_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = ( +_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = DeprecatedConstantEnum( UnitOfElectricPotential.MILLIVOLT, "2025.1", ) """Deprecated: please use UnitOfElectricPotential.MILLIVOLT.""" -_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = ( +_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = DeprecatedConstantEnum( UnitOfElectricPotential.VOLT, "2025.1", ) @@ -742,17 +739,17 @@ class UnitOfTemperature(StrEnum): KELVIN = "K" -_DEPRECATED_TEMP_CELSIUS: Final = ( +_DEPRECATED_TEMP_CELSIUS: Final = DeprecatedConstantEnum( UnitOfTemperature.CELSIUS, "2025.1", ) """Deprecated: please use UnitOfTemperature.CELSIUS""" -_DEPRECATED_TEMP_FAHRENHEIT: Final = ( +_DEPRECATED_TEMP_FAHRENHEIT: Final = DeprecatedConstantEnum( UnitOfTemperature.FAHRENHEIT, "2025.1", ) """Deprecated: please use UnitOfTemperature.FAHRENHEIT""" -_DEPRECATED_TEMP_KELVIN: Final = ( +_DEPRECATED_TEMP_KELVIN: Final = DeprecatedConstantEnum( UnitOfTemperature.KELVIN, "2025.1", ) @@ -774,47 +771,47 @@ class UnitOfTime(StrEnum): YEARS = "y" -_DEPRECATED_TIME_MICROSECONDS: Final = ( +_DEPRECATED_TIME_MICROSECONDS: Final = DeprecatedConstantEnum( UnitOfTime.MICROSECONDS, "2025.1", ) """Deprecated: please use UnitOfTime.MICROSECONDS.""" -_DEPRECATED_TIME_MILLISECONDS: Final = ( +_DEPRECATED_TIME_MILLISECONDS: Final = DeprecatedConstantEnum( UnitOfTime.MILLISECONDS, "2025.1", ) """Deprecated: please use UnitOfTime.MILLISECONDS.""" -_DEPRECATED_TIME_SECONDS: Final = ( +_DEPRECATED_TIME_SECONDS: Final = DeprecatedConstantEnum( UnitOfTime.SECONDS, "2025.1", ) """Deprecated: please use UnitOfTime.SECONDS.""" -_DEPRECATED_TIME_MINUTES: Final = ( +_DEPRECATED_TIME_MINUTES: Final = DeprecatedConstantEnum( UnitOfTime.MINUTES, "2025.1", ) """Deprecated: please use UnitOfTime.MINUTES.""" -_DEPRECATED_TIME_HOURS: Final = ( +_DEPRECATED_TIME_HOURS: Final = DeprecatedConstantEnum( UnitOfTime.HOURS, "2025.1", ) """Deprecated: please use UnitOfTime.HOURS.""" -_DEPRECATED_TIME_DAYS: Final = ( +_DEPRECATED_TIME_DAYS: Final = DeprecatedConstantEnum( UnitOfTime.DAYS, "2025.1", ) """Deprecated: please use UnitOfTime.DAYS.""" -_DEPRECATED_TIME_WEEKS: Final = ( +_DEPRECATED_TIME_WEEKS: Final = DeprecatedConstantEnum( UnitOfTime.WEEKS, "2025.1", ) """Deprecated: please use UnitOfTime.WEEKS.""" -_DEPRECATED_TIME_MONTHS: Final = ( +_DEPRECATED_TIME_MONTHS: Final = DeprecatedConstantEnum( UnitOfTime.MONTHS, "2025.1", ) """Deprecated: please use UnitOfTime.MONTHS.""" -_DEPRECATED_TIME_YEARS: Final = ( +_DEPRECATED_TIME_YEARS: Final = DeprecatedConstantEnum( UnitOfTime.YEARS, "2025.1", ) @@ -835,42 +832,42 @@ class UnitOfLength(StrEnum): MILES = "mi" -_DEPRECATED_LENGTH_MILLIMETERS: Final = ( +_DEPRECATED_LENGTH_MILLIMETERS: Final = DeprecatedConstantEnum( UnitOfLength.MILLIMETERS, "2025.1", ) """Deprecated: please use UnitOfLength.MILLIMETERS.""" -_DEPRECATED_LENGTH_CENTIMETERS: Final = ( +_DEPRECATED_LENGTH_CENTIMETERS: Final = DeprecatedConstantEnum( UnitOfLength.CENTIMETERS, "2025.1", ) """Deprecated: please use UnitOfLength.CENTIMETERS.""" -_DEPRECATED_LENGTH_METERS: Final = ( +_DEPRECATED_LENGTH_METERS: Final = DeprecatedConstantEnum( UnitOfLength.METERS, "2025.1", ) """Deprecated: please use UnitOfLength.METERS.""" -_DEPRECATED_LENGTH_KILOMETERS: Final = ( +_DEPRECATED_LENGTH_KILOMETERS: Final = DeprecatedConstantEnum( UnitOfLength.KILOMETERS, "2025.1", ) """Deprecated: please use UnitOfLength.KILOMETERS.""" -_DEPRECATED_LENGTH_INCHES: Final = ( +_DEPRECATED_LENGTH_INCHES: Final = DeprecatedConstantEnum( UnitOfLength.INCHES, "2025.1", ) """Deprecated: please use UnitOfLength.INCHES.""" -_DEPRECATED_LENGTH_FEET: Final = ( +_DEPRECATED_LENGTH_FEET: Final = DeprecatedConstantEnum( UnitOfLength.FEET, "2025.1", ) """Deprecated: please use UnitOfLength.FEET.""" -_DEPRECATED_LENGTH_YARD: Final = ( +_DEPRECATED_LENGTH_YARD: Final = DeprecatedConstantEnum( UnitOfLength.YARDS, "2025.1", ) """Deprecated: please use UnitOfLength.YARDS.""" -_DEPRECATED_LENGTH_MILES: Final = ( +_DEPRECATED_LENGTH_MILES: Final = DeprecatedConstantEnum( UnitOfLength.MILES, "2025.1", ) @@ -887,22 +884,22 @@ class UnitOfFrequency(StrEnum): GIGAHERTZ = "GHz" -_DEPRECATED_FREQUENCY_HERTZ: Final = ( +_DEPRECATED_FREQUENCY_HERTZ: Final = DeprecatedConstantEnum( UnitOfFrequency.HERTZ, "2025.1", ) """Deprecated: please use UnitOfFrequency.HERTZ""" -_DEPRECATED_FREQUENCY_KILOHERTZ: Final = ( +_DEPRECATED_FREQUENCY_KILOHERTZ: Final = DeprecatedConstantEnum( UnitOfFrequency.KILOHERTZ, "2025.1", ) """Deprecated: please use UnitOfFrequency.KILOHERTZ""" -_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = ( +_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = DeprecatedConstantEnum( UnitOfFrequency.MEGAHERTZ, "2025.1", ) """Deprecated: please use UnitOfFrequency.MEGAHERTZ""" -_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = ( +_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = DeprecatedConstantEnum( UnitOfFrequency.GIGAHERTZ, "2025.1", ) @@ -924,47 +921,47 @@ class UnitOfPressure(StrEnum): PSI = "psi" -_DEPRECATED_PRESSURE_PA: Final = ( +_DEPRECATED_PRESSURE_PA: Final = DeprecatedConstantEnum( UnitOfPressure.PA, "2025.1", ) """Deprecated: please use UnitOfPressure.PA""" -_DEPRECATED_PRESSURE_HPA: Final = ( +_DEPRECATED_PRESSURE_HPA: Final = DeprecatedConstantEnum( UnitOfPressure.HPA, "2025.1", ) """Deprecated: please use UnitOfPressure.HPA""" -_DEPRECATED_PRESSURE_KPA: Final = ( +_DEPRECATED_PRESSURE_KPA: Final = DeprecatedConstantEnum( UnitOfPressure.KPA, "2025.1", ) """Deprecated: please use UnitOfPressure.KPA""" -_DEPRECATED_PRESSURE_BAR: Final = ( +_DEPRECATED_PRESSURE_BAR: Final = DeprecatedConstantEnum( UnitOfPressure.BAR, "2025.1", ) """Deprecated: please use UnitOfPressure.BAR""" -_DEPRECATED_PRESSURE_CBAR: Final = ( +_DEPRECATED_PRESSURE_CBAR: Final = DeprecatedConstantEnum( UnitOfPressure.CBAR, "2025.1", ) """Deprecated: please use UnitOfPressure.CBAR""" -_DEPRECATED_PRESSURE_MBAR: Final = ( +_DEPRECATED_PRESSURE_MBAR: Final = DeprecatedConstantEnum( UnitOfPressure.MBAR, "2025.1", ) """Deprecated: please use UnitOfPressure.MBAR""" -_DEPRECATED_PRESSURE_MMHG: Final = ( +_DEPRECATED_PRESSURE_MMHG: Final = DeprecatedConstantEnum( UnitOfPressure.MMHG, "2025.1", ) """Deprecated: please use UnitOfPressure.MMHG""" -_DEPRECATED_PRESSURE_INHG: Final = ( +_DEPRECATED_PRESSURE_INHG: Final = DeprecatedConstantEnum( UnitOfPressure.INHG, "2025.1", ) """Deprecated: please use UnitOfPressure.INHG""" -_DEPRECATED_PRESSURE_PSI: Final = ( +_DEPRECATED_PRESSURE_PSI: Final = DeprecatedConstantEnum( UnitOfPressure.PSI, "2025.1", ) @@ -979,12 +976,12 @@ class UnitOfSoundPressure(StrEnum): WEIGHTED_DECIBEL_A = "dBA" -_DEPRECATED_SOUND_PRESSURE_DB: Final = ( +_DEPRECATED_SOUND_PRESSURE_DB: Final = DeprecatedConstantEnum( UnitOfSoundPressure.DECIBEL, "2025.1", ) """Deprecated: please use UnitOfSoundPressure.DECIBEL""" -_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = ( +_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = DeprecatedConstantEnum( UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "2025.1", ) @@ -1010,33 +1007,33 @@ class UnitOfVolume(StrEnum): British/Imperial fluid ounces are not yet supported""" -_DEPRECATED_VOLUME_LITERS: Final = ( +_DEPRECATED_VOLUME_LITERS: Final = DeprecatedConstantEnum( UnitOfVolume.LITERS, "2025.1", ) """Deprecated: please use UnitOfVolume.LITERS""" -_DEPRECATED_VOLUME_MILLILITERS: Final = ( +_DEPRECATED_VOLUME_MILLILITERS: Final = DeprecatedConstantEnum( UnitOfVolume.MILLILITERS, "2025.1", ) """Deprecated: please use UnitOfVolume.MILLILITERS""" -_DEPRECATED_VOLUME_CUBIC_METERS: Final = ( +_DEPRECATED_VOLUME_CUBIC_METERS: Final = DeprecatedConstantEnum( UnitOfVolume.CUBIC_METERS, "2025.1", ) """Deprecated: please use UnitOfVolume.CUBIC_METERS""" -_DEPRECATED_VOLUME_CUBIC_FEET: Final = ( +_DEPRECATED_VOLUME_CUBIC_FEET: Final = DeprecatedConstantEnum( UnitOfVolume.CUBIC_FEET, "2025.1", ) """Deprecated: please use UnitOfVolume.CUBIC_FEET""" -_DEPRECATED_VOLUME_GALLONS: Final = ( +_DEPRECATED_VOLUME_GALLONS: Final = DeprecatedConstantEnum( UnitOfVolume.GALLONS, "2025.1", ) """Deprecated: please use UnitOfVolume.GALLONS""" -_DEPRECATED_VOLUME_FLUID_OUNCE: Final = ( +_DEPRECATED_VOLUME_FLUID_OUNCE: Final = DeprecatedConstantEnum( UnitOfVolume.FLUID_OUNCES, "2025.1", ) @@ -1051,12 +1048,12 @@ class UnitOfVolumeFlowRate(StrEnum): CUBIC_FEET_PER_MINUTE = "ft³/m" -_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = ( +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, "2025.1", ) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR""" -_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = ( +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = DeprecatedConstantEnum( UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, "2025.1", ) @@ -1079,32 +1076,32 @@ class UnitOfMass(StrEnum): STONES = "st" -_DEPRECATED_MASS_GRAMS: Final = ( +_DEPRECATED_MASS_GRAMS: Final = DeprecatedConstantEnum( UnitOfMass.GRAMS, "2025.1", ) """Deprecated: please use UnitOfMass.GRAMS""" -_DEPRECATED_MASS_KILOGRAMS: Final = ( +_DEPRECATED_MASS_KILOGRAMS: Final = DeprecatedConstantEnum( UnitOfMass.KILOGRAMS, "2025.1", ) """Deprecated: please use UnitOfMass.KILOGRAMS""" -_DEPRECATED_MASS_MILLIGRAMS: Final = ( +_DEPRECATED_MASS_MILLIGRAMS: Final = DeprecatedConstantEnum( UnitOfMass.MILLIGRAMS, "2025.1", ) """Deprecated: please use UnitOfMass.MILLIGRAMS""" -_DEPRECATED_MASS_MICROGRAMS: Final = ( +_DEPRECATED_MASS_MICROGRAMS: Final = DeprecatedConstantEnum( UnitOfMass.MICROGRAMS, "2025.1", ) """Deprecated: please use UnitOfMass.MICROGRAMS""" -_DEPRECATED_MASS_OUNCES: Final = ( +_DEPRECATED_MASS_OUNCES: Final = DeprecatedConstantEnum( UnitOfMass.OUNCES, "2025.1", ) """Deprecated: please use UnitOfMass.OUNCES""" -_DEPRECATED_MASS_POUNDS: Final = ( +_DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum( UnitOfMass.POUNDS, "2025.1", ) @@ -1135,12 +1132,12 @@ class UnitOfIrradiance(StrEnum): # Irradiation units -_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = ( +_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = DeprecatedConstantEnum( UnitOfIrradiance.WATTS_PER_SQUARE_METER, "2025.1", ) """Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER""" -_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = ( +_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = DeprecatedConstantEnum( UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, "2025.1", ) @@ -1185,19 +1182,21 @@ class UnitOfPrecipitationDepth(StrEnum): # Precipitation units -_DEPRECATED_PRECIPITATION_INCHES: Final = (UnitOfPrecipitationDepth.INCHES, "2025.1") +_DEPRECATED_PRECIPITATION_INCHES: Final = DeprecatedConstantEnum( + UnitOfPrecipitationDepth.INCHES, "2025.1" +) """Deprecated: please use UnitOfPrecipitationDepth.INCHES""" -_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = ( +_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = DeprecatedConstantEnum( UnitOfPrecipitationDepth.MILLIMETERS, "2025.1", ) """Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS""" -_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = ( +_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, "2025.1", ) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" -_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = ( +_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfVolumetricFlux.INCHES_PER_HOUR, "2025.1", ) @@ -1223,33 +1222,39 @@ class UnitOfSpeed(StrEnum): MILES_PER_HOUR = "mph" -_DEPRECATED_SPEED_FEET_PER_SECOND: Final = (UnitOfSpeed.FEET_PER_SECOND, "2025.1") +_DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfSpeed.FEET_PER_SECOND, "2025.1" +) """Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" -_DEPRECATED_SPEED_METERS_PER_SECOND: Final = (UnitOfSpeed.METERS_PER_SECOND, "2025.1") +_DEPRECATED_SPEED_METERS_PER_SECOND: Final = DeprecatedConstantEnum( + UnitOfSpeed.METERS_PER_SECOND, "2025.1" +) """Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" -_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = ( +_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfSpeed.KILOMETERS_PER_HOUR, "2025.1", ) """Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" -_DEPRECATED_SPEED_KNOTS: Final = (UnitOfSpeed.KNOTS, "2025.1") +_DEPRECATED_SPEED_KNOTS: Final = DeprecatedConstantEnum(UnitOfSpeed.KNOTS, "2025.1") """Deprecated: please use UnitOfSpeed.KNOTS""" -_DEPRECATED_SPEED_MILES_PER_HOUR: Final = (UnitOfSpeed.MILES_PER_HOUR, "2025.1") +_DEPRECATED_SPEED_MILES_PER_HOUR: Final = DeprecatedConstantEnum( + UnitOfSpeed.MILES_PER_HOUR, "2025.1" +) """Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" -_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = ( +_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = DeprecatedConstantEnum( UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, "2025.1", ) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" -_DEPRECATED_SPEED_INCHES_PER_DAY: Final = ( +_DEPRECATED_SPEED_INCHES_PER_DAY: Final = DeprecatedConstantEnum( UnitOfVolumetricFlux.INCHES_PER_DAY, "2025.1", ) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" -_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = ( +_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = DeprecatedConstantEnum( UnitOfVolumetricFlux.INCHES_PER_HOUR, "2025.1", ) @@ -1288,47 +1293,87 @@ class UnitOfInformation(StrEnum): YOBIBYTES = "YiB" -_DEPRECATED_DATA_BITS: Final = (UnitOfInformation.BITS, "2025.1") +_DEPRECATED_DATA_BITS: Final = DeprecatedConstantEnum(UnitOfInformation.BITS, "2025.1") """Deprecated: please use UnitOfInformation.BITS""" -_DEPRECATED_DATA_KILOBITS: Final = (UnitOfInformation.KILOBITS, "2025.1") +_DEPRECATED_DATA_KILOBITS: Final = DeprecatedConstantEnum( + UnitOfInformation.KILOBITS, "2025.1" +) """Deprecated: please use UnitOfInformation.KILOBITS""" -_DEPRECATED_DATA_MEGABITS: Final = (UnitOfInformation.MEGABITS, "2025.1") +_DEPRECATED_DATA_MEGABITS: Final = DeprecatedConstantEnum( + UnitOfInformation.MEGABITS, "2025.1" +) """Deprecated: please use UnitOfInformation.MEGABITS""" -_DEPRECATED_DATA_GIGABITS: Final = (UnitOfInformation.GIGABITS, "2025.1") +_DEPRECATED_DATA_GIGABITS: Final = DeprecatedConstantEnum( + UnitOfInformation.GIGABITS, "2025.1" +) """Deprecated: please use UnitOfInformation.GIGABITS""" -_DEPRECATED_DATA_BYTES: Final = (UnitOfInformation.BYTES, "2025.1") +_DEPRECATED_DATA_BYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.BYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.BYTES""" -_DEPRECATED_DATA_KILOBYTES: Final = (UnitOfInformation.KILOBYTES, "2025.1") +_DEPRECATED_DATA_KILOBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.KILOBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.KILOBYTES""" -_DEPRECATED_DATA_MEGABYTES: Final = (UnitOfInformation.MEGABYTES, "2025.1") +_DEPRECATED_DATA_MEGABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.MEGABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.MEGABYTES""" -_DEPRECATED_DATA_GIGABYTES: Final = (UnitOfInformation.GIGABYTES, "2025.1") +_DEPRECATED_DATA_GIGABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.GIGABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.GIGABYTES""" -_DEPRECATED_DATA_TERABYTES: Final = (UnitOfInformation.TERABYTES, "2025.1") +_DEPRECATED_DATA_TERABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.TERABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.TERABYTES""" -_DEPRECATED_DATA_PETABYTES: Final = (UnitOfInformation.PETABYTES, "2025.1") +_DEPRECATED_DATA_PETABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.PETABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.PETABYTES""" -_DEPRECATED_DATA_EXABYTES: Final = (UnitOfInformation.EXABYTES, "2025.1") +_DEPRECATED_DATA_EXABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.EXABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.EXABYTES""" -_DEPRECATED_DATA_ZETTABYTES: Final = (UnitOfInformation.ZETTABYTES, "2025.1") +_DEPRECATED_DATA_ZETTABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.ZETTABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.ZETTABYTES""" -_DEPRECATED_DATA_YOTTABYTES: Final = (UnitOfInformation.YOTTABYTES, "2025.1") +_DEPRECATED_DATA_YOTTABYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.YOTTABYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.YOTTABYTES""" -_DEPRECATED_DATA_KIBIBYTES: Final = (UnitOfInformation.KIBIBYTES, "2025.1") +_DEPRECATED_DATA_KIBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.KIBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.KIBIBYTES""" -_DEPRECATED_DATA_MEBIBYTES: Final = (UnitOfInformation.MEBIBYTES, "2025.1") +_DEPRECATED_DATA_MEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.MEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.MEBIBYTES""" -_DEPRECATED_DATA_GIBIBYTES: Final = (UnitOfInformation.GIBIBYTES, "2025.1") +_DEPRECATED_DATA_GIBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.GIBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.GIBIBYTES""" -_DEPRECATED_DATA_TEBIBYTES: Final = (UnitOfInformation.TEBIBYTES, "2025.1") +_DEPRECATED_DATA_TEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.TEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.TEBIBYTES""" -_DEPRECATED_DATA_PEBIBYTES: Final = (UnitOfInformation.PEBIBYTES, "2025.1") +_DEPRECATED_DATA_PEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.PEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.PEBIBYTES""" -_DEPRECATED_DATA_EXBIBYTES: Final = (UnitOfInformation.EXBIBYTES, "2025.1") +_DEPRECATED_DATA_EXBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.EXBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.EXBIBYTES""" -_DEPRECATED_DATA_ZEBIBYTES: Final = (UnitOfInformation.ZEBIBYTES, "2025.1") +_DEPRECATED_DATA_ZEBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.ZEBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.ZEBIBYTES""" -_DEPRECATED_DATA_YOBIBYTES: Final = (UnitOfInformation.YOBIBYTES, "2025.1") +_DEPRECATED_DATA_YOBIBYTES: Final = DeprecatedConstantEnum( + UnitOfInformation.YOBIBYTES, "2025.1" +) """Deprecated: please use UnitOfInformation.YOBIBYTES""" @@ -1349,57 +1394,57 @@ class UnitOfDataRate(StrEnum): GIBIBYTES_PER_SECOND = "GiB/s" -_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.BITS_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.BITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.KILOBITS_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.MEGABITS_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.GIGABITS_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND""" -_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.BYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.KILOBYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.MEGABYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.GIGABYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.KIBIBYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.MEBIBYTES_PER_SECOND, "2025.1", ) """Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND""" -_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = ( +_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum( UnitOfDataRate.GIBIBYTES_PER_SECOND, "2025.1", ) @@ -1540,8 +1585,12 @@ class EntityCategory(StrEnum): # ENTITY_CATEGOR* below are deprecated as of 2021.12 # use the EntityCategory enum instead. -_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = (EntityCategory.CONFIG, "2025.1") -_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = (EntityCategory.DIAGNOSTIC, "2025.1") +_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = DeprecatedConstantEnum( + EntityCategory.CONFIG, "2025.1" +) +_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = DeprecatedConstantEnum( + EntityCategory.DIAGNOSTIC, "2025.1" +) ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory] # The ID of the Home Assistant Media Player Cast App diff --git a/homeassistant/core.py b/homeassistant/core.py index c8d01309767..4fdaa662e71 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -81,6 +81,11 @@ from .exceptions import ( ServiceNotFound, Unauthorized, ) +from .helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -147,41 +152,16 @@ class ConfigSource(enum.StrEnum): # SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead -_DEPRECATED_SOURCE_DISCOVERED = (ConfigSource.DISCOVERED, "2025.1") -_DEPRECATED_SOURCE_STORAGE = (ConfigSource.STORAGE, "2025.1") -_DEPRECATED_SOURCE_YAML = (ConfigSource.YAML, "2025.1") +_DEPRECATED_SOURCE_DISCOVERED = DeprecatedConstantEnum( + ConfigSource.DISCOVERED, "2025.1" +) +_DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025.1") +_DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") -# Can be removed if no deprecated constant are in this module anymore -def __getattr__(name: str) -> Any: - """Check if the not found name is a deprecated constant. - - If it is, print a deprecation warning and return the value of the constant. - Otherwise raise AttributeError. - """ - module_globals = globals() - if f"_DEPRECATED_{name}" not in module_globals: - raise AttributeError(f"Module {__name__} has no attribute {name!r}") - - # Avoid circular import - from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel - check_if_deprecated_constant, - ) - - return check_if_deprecated_constant(name, module_globals) - - -# Can be removed if no deprecated constant are in this module anymore -def __dir__() -> list[str]: - """Return dir() with deprecated constants.""" - # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle - module_globals = globals() - - return list(module_globals) + [ - name.removeprefix("_DEPRECATED_") - for name in module_globals - if name.startswith("_DEPRECATED_") - ] +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = functools.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = functools.partial(dir_with_deprecated_constants, module_globals=globals()) # How long to wait until things that run on startup have to finish. diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index efd0363732a..72b26e90b84 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -9,12 +9,6 @@ import inspect import logging from typing import Any, NamedTuple, ParamSpec, TypeVar -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import async_suggest_report_issue - -from .frame import MissingIntegrationFrame, get_integration_frame - _ObjectT = TypeVar("_ObjectT", bound=object) _R = TypeVar("_R") _P = ParamSpec("_P") @@ -175,6 +169,13 @@ def _print_deprecation_warning_internal( *, log_when_no_integration_is_found: bool, ) -> None: + # pylint: disable=import-outside-toplevel + from homeassistant.core import HomeAssistant, async_get_hass + from homeassistant.exceptions import HomeAssistantError + from homeassistant.loader import async_suggest_report_issue + + from .frame import MissingIntegrationFrame, get_integration_frame + logger = logging.getLogger(module_name) if breaks_in_ha_version: breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" @@ -265,18 +266,6 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) breaks_in_ha_version = deprecated_const.breaks_in_ha_version - elif isinstance(deprecated_const, tuple): - # Use DeprecatedConstant and DeprecatedConstant instead, where possible - # Used to avoid import cycles. - if len(deprecated_const) == 3: - value = deprecated_const[0] - replacement = deprecated_const[1] - breaks_in_ha_version = deprecated_const[2] - elif len(deprecated_const) == 2 and isinstance(deprecated_const[0], Enum): - enum = deprecated_const[0] - value = enum.value - replacement = f"{enum.__class__.__name__}.{enum.name}" - breaks_in_ha_version = deprecated_const[1] if value is None or replacement is None: msg = ( diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index bd3546afb12..017e541bb08 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -299,22 +299,6 @@ def _get_value(obj: DeprecatedConstant | DeprecatedConstantEnum | tuple) -> Any: DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", ), - ( - ("value", "NEW_CONSTANT", None), - ". Use NEW_CONSTANT instead", - ), - ( - (1, "NEW_CONSTANT", "2099.1"), - " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", - ), - ( - (TestDeprecatedConstantEnum.TEST, None), - ". Use TestDeprecatedConstantEnum.TEST instead", - ), - ( - (TestDeprecatedConstantEnum.TEST, "2099.1"), - " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", - ), ], ) @pytest.mark.parametrize( @@ -391,22 +375,6 @@ def test_check_if_deprecated_constant( DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", ), - ( - ("value", "NEW_CONSTANT", None), - ". Use NEW_CONSTANT instead", - ), - ( - (1, "NEW_CONSTANT", "2099.1"), - " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", - ), - ( - (TestDeprecatedConstantEnum.TEST, None), - ". Use TestDeprecatedConstantEnum.TEST instead", - ), - ( - (TestDeprecatedConstantEnum.TEST, "2099.1"), - " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", - ), ], ) @pytest.mark.parametrize( From 0ccf8ffbc6b41e3b37c5a218575551d8767bc29e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 02:45:47 -1000 Subject: [PATCH 0232/1544] Bump habluetooth to 2.0.2 (#107097) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_wrappers.py | 32 +++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c5dec12fe40..7308f3a83ff 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.0.1" + "habluetooth==2.0.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9038f448529..6d55edc5163 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.0.1 +habluetooth==2.0.2 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 169b5527292..76501f18f54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.1 +habluetooth==2.0.2 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7a8458d312..e293d61e75e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -803,7 +803,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.1 +habluetooth==2.0.2 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index cc837f381d4..e3531a57447 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -367,6 +367,38 @@ async def test_we_switch_adapters_on_failure( cancel_hci1() +async def test_passing_subclassed_str_as_address( + hass: HomeAssistant, + two_adapters: None, + enable_bluetooth: None, + install_bleak_catcher, +) -> None: + """Ensure the client wrapper can handle a subclassed str as the address.""" + _, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices(hass) + + class SubclassedStr(str): + pass + + address = SubclassedStr("00:00:00:00:00:01") + client = bleak.BleakClient(address) + + class FakeBleakClient(BaseFakeBleakClient): + """Fake bleak client.""" + + async def connect(self, *args, **kwargs): + """Connect.""" + return True + + with patch( + "habluetooth.wrappers.get_platform_client_backend_type", + return_value=FakeBleakClient, + ): + assert await client.connect() is True + + cancel_hci0() + cancel_hci1() + + async def test_raise_after_shutdown( hass: HomeAssistant, two_adapters: None, From 80a616d23761b860bd6d030c6f9756f36a2f500a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 4 Jan 2024 13:49:15 +0100 Subject: [PATCH 0233/1544] Remove zwave_js numeric sensor rounding (#107100) --- homeassistant/components/zwave_js/sensor.py | 2 +- tests/components/zwave_js/test_sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 56ed3f010b8..798d4bf92bc 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -661,7 +661,7 @@ class ZWaveNumericSensor(ZwaveSensor): """Return state of the sensor.""" if self.info.primary_value.value is None: return 0 - return round(float(self.info.primary_value.value), 2) + return float(self.info.primary_value.value) class ZWaveMeterSensor(ZWaveNumericSensor): diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index f00413b0d80..390d9631f23 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -179,7 +179,7 @@ async def test_energy_sensors( state = hass.states.get(ENERGY_SENSOR) assert state - assert state.state == "0.16" + assert state.state == "0.164" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING @@ -187,7 +187,7 @@ async def test_energy_sensors( state = hass.states.get(VOLTAGE_SENSOR) assert state - assert state.state == "122.96" + assert state.state == "122.963" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfElectricPotential.VOLT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.VOLTAGE From 40d034cd8cb9f17dee290a732cfdd8d83202a51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 04:34:43 -1000 Subject: [PATCH 0234/1544] Revert "Bump aiohttp-zlib-ng to 0.2.0 (#106691)" (#107109) --- .github/workflows/wheels.yml | 8 ++++---- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index de25640b9b6..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -106,7 +106,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev;nasm" + apk: "libffi-dev;openssl-dev;yaml-dev" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -214,7 +214,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +228,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +242,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 15c84974c13..399cbf70ad7 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.2.0" + "aiohttp-zlib-ng==0.1.3" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d55edc5163..1ed8f31bcc9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.2.0 +aiohttp-zlib-ng==0.1.3 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 1d8dc736aaa..f611cc73f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.2.0", + "aiohttp-zlib-ng==0.1.3", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index f845883b331..55cbdc31730 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.2.0 +aiohttp-zlib-ng==0.1.3 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 76501f18f54..01dea0313ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.1 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.2.0 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e293d61e75e..dd12c1762b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.1 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.2.0 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http From 6a02cadc1348d849f220ee7ecf7c00a3536a8176 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jan 2024 16:17:48 +0100 Subject: [PATCH 0235/1544] Rework drop_connect switch, select and coordinator tests and cleanup fixtures (#107119) * Refactor drop_connect switch and select tests * Update coordinator tests, cleanup fixtures --- tests/components/drop_connect/conftest.py | 177 ------------------ .../drop_connect/test_coordinator.py | 65 +++---- tests/components/drop_connect/test_select.py | 30 ++- tests/components/drop_connect/test_switch.py | 154 ++++++++------- 4 files changed, 124 insertions(+), 302 deletions(-) delete mode 100644 tests/components/drop_connect/conftest.py diff --git a/tests/components/drop_connect/conftest.py b/tests/components/drop_connect/conftest.py deleted file mode 100644 index ce68a6f0c13..00000000000 --- a/tests/components/drop_connect/conftest.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Define fixtures available for all tests.""" -import pytest - -from homeassistant.components.drop_connect.const import ( - CONF_COMMAND_TOPIC, - CONF_DATA_TOPIC, - CONF_DEVICE_DESC, - CONF_DEVICE_ID, - CONF_DEVICE_NAME, - CONF_DEVICE_OWNER_ID, - CONF_DEVICE_TYPE, - CONF_HUB_ID, - DOMAIN, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.fixture -def config_entry_hub(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_255", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", - CONF_DEVICE_DESC: "Hub", - CONF_DEVICE_ID: 255, - CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", - CONF_DEVICE_TYPE: "hub", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_salt(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_8", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", - CONF_DEVICE_DESC: "Salt Sensor", - CONF_DEVICE_ID: 8, - CONF_DEVICE_NAME: "Salt Sensor", - CONF_DEVICE_TYPE: "salt", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_leak(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_20", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", - CONF_DEVICE_DESC: "Leak Detector", - CONF_DEVICE_ID: 20, - CONF_DEVICE_NAME: "Leak Detector", - CONF_DEVICE_TYPE: "leak", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_softener(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_0", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", - CONF_DEVICE_DESC: "Softener", - CONF_DEVICE_ID: 0, - CONF_DEVICE_NAME: "Softener", - CONF_DEVICE_TYPE: "soft", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_filter(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_4", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", - CONF_DEVICE_DESC: "Filter", - CONF_DEVICE_ID: 4, - CONF_DEVICE_NAME: "Filter", - CONF_DEVICE_TYPE: "filt", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_protection_valve(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_78", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", - CONF_DEVICE_DESC: "Protection Valve", - CONF_DEVICE_ID: 78, - CONF_DEVICE_NAME: "Protection Valve", - CONF_DEVICE_TYPE: "pv", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_pump_controller(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_83", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", - CONF_DEVICE_DESC: "Pump Controller", - CONF_DEVICE_ID: 83, - CONF_DEVICE_NAME: "Pump Controller", - CONF_DEVICE_TYPE: "pc", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) - - -@pytest.fixture -def config_entry_ro_filter(hass: HomeAssistant): - """Config entry version 1 fixture.""" - return MockConfigEntry( - domain=DOMAIN, - unique_id="DROP-1_C0FFEE_255", - data={ - CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", - CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", - CONF_DEVICE_DESC: "RO Filter", - CONF_DEVICE_ID: 95, - CONF_DEVICE_NAME: "RO Filter", - CONF_DEVICE_TYPE: "ro", - CONF_HUB_ID: "DROP-1_C0FFEE", - CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", - }, - version=1, - ) diff --git a/tests/components/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_coordinator.py index 50f2633e241..c45bb92189f 100644 --- a/tests/components/drop_connect/test_coordinator.py +++ b/tests/components/drop_connect/test_coordinator.py @@ -1,74 +1,65 @@ """Test DROP coordinator.""" -from homeassistant.components.drop_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + config_entry_hub, +) from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_bad_json( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_bad_json(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test bad JSON.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") await hass.async_block_till_done() - - current_flow_sensor = hass.states.get(current_flow_sensor_name) - assert current_flow_sensor - assert current_flow_sensor.state == STATE_UNKNOWN + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN -async def test_unload( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_unload(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test entity unload.""" # Load the hub device - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" - hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + assert hass.states.get(current_flow_sensor_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() + assert hass.states.get(current_flow_sensor_name).state == "0.0" + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() - current_flow_sensor = hass.states.get(current_flow_sensor_name) - assert current_flow_sensor - assert round(float(current_flow_sensor.state), 1) == 5.8 + assert hass.states.get(current_flow_sensor_name).state == "5.77" # Unload the device - await hass.config_entries.async_unload(config_entry_hub.entry_id) - await hass.async_block_till_done() - - assert config_entry_hub.state is ConfigEntryState.NOT_LOADED + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED # Verify sensor is unavailable - current_flow_sensor = hass.states.get(current_flow_sensor_name) - assert current_flow_sensor - assert current_flow_sensor.state == STATE_UNAVAILABLE + assert hass.states.get(current_flow_sensor_name).state == STATE_UNAVAILABLE -async def test_no_mqtt(hass: HomeAssistant, config_entry_hub) -> None: +async def test_no_mqtt(hass: HomeAssistant) -> None: """Test no MQTT.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is False protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" - protect_mode_select = hass.states.get(protect_mode_select_name) - assert protect_mode_select is None + assert hass.states.get(protect_mode_select_name) is None diff --git a/tests/components/drop_connect/test_select.py b/tests/components/drop_connect/test_select.py index 24877069367..1e00f6031d4 100644 --- a/tests/components/drop_connect/test_select.py +++ b/tests/components/drop_connect/test_select.py @@ -1,6 +1,5 @@ """Test DROP select entities.""" -from homeassistant.components.drop_connect.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -9,21 +8,23 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + config_entry_hub, +) from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_selects_hub( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_selects_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP binary sensors for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" protect_mode_select = hass.states.get(protect_mode_select_name) @@ -36,6 +37,14 @@ async def test_selects_hub( async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.attributes.get(ATTR_OPTIONS) == [ + "away", + "home", + "schedule", + ] + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() @@ -43,6 +52,7 @@ async def test_selects_hub( assert protect_mode_select assert protect_mode_select.state == "home" + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -50,7 +60,9 @@ async def test_selects_hub( blocking=True, ) await hass.async_block_till_done() + assert len(mqtt_mock.async_publish.mock_calls) == 1 + # Simulate response of the device async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() diff --git a/tests/components/drop_connect/test_switch.py b/tests/components/drop_connect/test_switch.py index d7d954915c6..0e244e9ab59 100644 --- a/tests/components/drop_connect/test_switch.py +++ b/tests/components/drop_connect/test_switch.py @@ -1,6 +1,5 @@ """Test DROP switch entities.""" -from homeassistant.components.drop_connect.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -8,7 +7,6 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import ( TEST_DATA_FILTER, @@ -23,253 +21,251 @@ from .common import ( TEST_DATA_SOFTENER, TEST_DATA_SOFTENER_RESET, TEST_DATA_SOFTENER_TOPIC, + config_entry_filter, + config_entry_hub, + config_entry_protection_valve, + config_entry_softener, ) from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_switches_hub( - hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient -) -> None: +async def test_switches_hub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test DROP switches for hubs.""" - config_entry_hub.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_hub() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) water_supply_switch_name = "switch.hub_drop_1_c0ffee_water_supply" - hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + assert hass.states.get(water_supply_switch_name).state == STATE_UNKNOWN bypass_switch_name = "switch.hub_drop_1_c0ffee_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_ON + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON + assert hass.states.get(water_supply_switch_name).state == STATE_ON + assert hass.states.get(bypass_switch_name).state == STATE_OFF # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: water_supply_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the hub async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_OFF + assert hass.states.get(water_supply_switch_name).state == STATE_OFF # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: water_supply_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the hub async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(water_supply_switch_name).state == STATE_ON # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + assert hass.states.get(bypass_switch_name).state == STATE_ON # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF async def test_switches_protection_valve( - hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for protection valves.""" - config_entry_protection_valve.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_protection_valve() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + water_supply_switch_name = "switch.protection_valve_water_supply" + assert hass.states.get(water_supply_switch_name).state == STATE_UNKNOWN async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET ) await hass.async_block_till_done() + assert hass.states.get(water_supply_switch_name).state == STATE_OFF + async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE ) await hass.async_block_till_done() - - water_supply_switch_name = "switch.protection_valve_water_supply" - hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + assert hass.states.get(water_supply_switch_name).state == STATE_ON # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: water_supply_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET ) await hass.async_block_till_done() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_OFF + assert hass.states.get(water_supply_switch_name).state == STATE_OFF # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: water_supply_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message( hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE ) await hass.async_block_till_done() - - water_supply_switch = hass.states.get(water_supply_switch_name) - assert water_supply_switch - assert water_supply_switch.state == STATE_ON + assert hass.states.get(water_supply_switch_name).state == STATE_ON async def test_switches_softener( - hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for softeners.""" - config_entry_softener.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_softener() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + bypass_switch_name = "switch.softener_treatment_bypass" + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) await hass.async_block_till_done() - - bypass_switch_name = "switch.softener_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + assert hass.states.get(bypass_switch_name).state == STATE_OFF # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + assert hass.states.get(bypass_switch_name).state == STATE_ON # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF async def test_switches_filter( - hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test DROP switches for filters.""" - config_entry_filter.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = config_entry_filter() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + bypass_switch_name = "switch.filter_treatment_bypass" + assert hass.states.get(bypass_switch_name).state == STATE_UNKNOWN async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) await hass.async_block_till_done() + assert hass.states.get(bypass_switch_name).state == STATE_ON + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) await hass.async_block_till_done() - - bypass_switch_name = "switch.filter_treatment_bypass" - hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + assert hass.states.get(bypass_switch_name).state == STATE_OFF # Test switch turn on method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_ON + assert hass.states.get(bypass_switch_name).state == STATE_ON # Test switch turn off method. + mqtt_mock.async_publish.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: bypass_switch_name}, blocking=True, ) + assert len(mqtt_mock.async_publish.mock_calls) == 1 # Simulate response from the device async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) await hass.async_block_till_done() - - bypass_switch = hass.states.get(bypass_switch_name) - assert bypass_switch - assert bypass_switch.state == STATE_OFF + assert hass.states.get(bypass_switch_name).state == STATE_OFF From 0695bf8988c1c78849a1bb2b3429174875f085c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 06:34:56 -1000 Subject: [PATCH 0236/1544] Move group helpers into their own module (#106924) This gets rid of the legacy need to use bind_hass, and the expand function no longer looses typing. --- homeassistant/components/__init__.py | 3 +- homeassistant/components/group/__init__.py | 65 ++--------- homeassistant/components/zwave_js/helpers.py | 2 +- homeassistant/components/zwave_js/services.py | 2 +- homeassistant/helpers/group.py | 58 ++++++++++ homeassistant/helpers/service.py | 5 +- tests/helpers/test_group.py | 107 ++++++++++++++++++ 7 files changed, 181 insertions(+), 61 deletions(-) create mode 100644 homeassistant/helpers/group.py create mode 100644 tests/helpers/test_group.py diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 690b38b4871..839a66af25d 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers.group import expand_entity_ids _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool: If there is no entity id given we will check all. """ if entity_id: - entity_ids = hass.components.group.expand_entity_ids([entity_id]) + entity_ids = expand_entity_ids(hass, [entity_id]) else: entity_ids = hass.states.entity_ids() diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index a2a61b3016a..894a20629ee 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -3,10 +3,10 @@ from __future__ import annotations from abc import abstractmethod import asyncio -from collections.abc import Callable, Collection, Iterable, Mapping +from collections.abc import Callable, Collection, Mapping from contextvars import ContextVar import logging -from typing import Any, Protocol, cast +from typing import Any, Protocol import voluptuous as vol @@ -19,8 +19,6 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_ICON, CONF_NAME, - ENTITY_MATCH_ALL, - ENTITY_MATCH_NONE, SERVICE_RELOAD, STATE_OFF, STATE_ON, @@ -41,6 +39,10 @@ from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, ) +from homeassistant.helpers.group import ( + expand_entity_ids as _expand_entity_ids, + get_entity_ids as _get_entity_ids, +) from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) @@ -167,58 +169,9 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: return False -@bind_hass -def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]: - """Return entity_ids with group entity ids replaced by their members. - - Async friendly. - """ - found_ids: list[str] = [] - for entity_id in entity_ids: - if not isinstance(entity_id, str) or entity_id in ( - ENTITY_MATCH_NONE, - ENTITY_MATCH_ALL, - ): - continue - - entity_id = entity_id.lower() - # If entity_id points at a group, expand it - if entity_id.startswith(ENTITY_PREFIX): - child_entities = get_entity_ids(hass, entity_id) - if entity_id in child_entities: - child_entities = list(child_entities) - child_entities.remove(entity_id) - found_ids.extend( - ent_id - for ent_id in expand_entity_ids(hass, child_entities) - if ent_id not in found_ids - ) - elif entity_id not in found_ids: - found_ids.append(entity_id) - - return found_ids - - -@bind_hass -def get_entity_ids( - hass: HomeAssistant, entity_id: str, domain_filter: str | None = None -) -> list[str]: - """Get members of this group. - - Async friendly. - """ - group = hass.states.get(entity_id) - - if not group or ATTR_ENTITY_ID not in group.attributes: - return [] - - entity_ids = group.attributes[ATTR_ENTITY_ID] - if not domain_filter: - return cast(list[str], entity_ids) - - domain_filter = f"{domain_filter.lower()}." - - return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] +# expand_entity_ids and get_entity_ids are for backwards compatibility only +expand_entity_ids = bind_hass(_expand_entity_ids) +get_entity_ids = bind_hass(_get_entity_ids) @bind_hass diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index a211832039b..c8eb02ad6cb 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -25,7 +25,6 @@ from zwave_js_server.model.value import ( get_value_id_str, ) -from homeassistant.components.group import expand_entity_ids from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( @@ -39,6 +38,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.group import expand_entity_ids from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 9b4f9827c1d..e8ef1df4b96 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -25,13 +25,13 @@ from zwave_js_server.util.node import ( async_set_config_parameter, ) -from homeassistant.components.group import expand_entity_ids from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.group import expand_entity_ids from . import const from .config_validation import BITMASK_SCHEMA, VALUE_SCHEMA diff --git a/homeassistant/helpers/group.py b/homeassistant/helpers/group.py new file mode 100644 index 00000000000..437df226118 --- /dev/null +++ b/homeassistant/helpers/group.py @@ -0,0 +1,58 @@ +"""Helper for groups.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE +from homeassistant.core import HomeAssistant + +ENTITY_PREFIX = "group." + + +def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]: + """Return entity_ids with group entity ids replaced by their members. + + Async friendly. + """ + found_ids: list[str] = [] + for entity_id in entity_ids: + if not isinstance(entity_id, str) or entity_id in ( + ENTITY_MATCH_NONE, + ENTITY_MATCH_ALL, + ): + continue + + entity_id = entity_id.lower() + # If entity_id points at a group, expand it + if entity_id.startswith(ENTITY_PREFIX): + child_entities = get_entity_ids(hass, entity_id) + if entity_id in child_entities: + child_entities = list(child_entities) + child_entities.remove(entity_id) + found_ids.extend( + ent_id + for ent_id in expand_entity_ids(hass, child_entities) + if ent_id not in found_ids + ) + elif entity_id not in found_ids: + found_ids.append(entity_id) + + return found_ids + + +def get_entity_ids( + hass: HomeAssistant, entity_id: str, domain_filter: str | None = None +) -> list[str]: + """Get members of this group. + + Async friendly. + """ + group = hass.states.get(entity_id) + if not group or ATTR_ENTITY_ID not in group.attributes: + return [] + entity_ids: list[str] = group.attributes[ATTR_ENTITY_ID] + if not domain_filter: + return entity_ids + domain_filter = f"{domain_filter.lower()}." + return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 59fd061d8c9..4813a54ac8b 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -53,6 +53,7 @@ from . import ( template, translation, ) +from .group import expand_entity_ids from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType @@ -459,9 +460,9 @@ def async_extract_referenced_entity_ids( if not selector.has_any_selector: return selected - entity_ids = selector.entity_ids + entity_ids: set[str] | list[str] = selector.entity_ids if expand_group: - entity_ids = hass.components.group.expand_entity_ids(entity_ids) + entity_ids = expand_entity_ids(hass, entity_ids) selected.referenced.update(entity_ids) diff --git a/tests/helpers/test_group.py b/tests/helpers/test_group.py new file mode 100644 index 00000000000..b1300009607 --- /dev/null +++ b/tests/helpers/test_group.py @@ -0,0 +1,107 @@ +"""Test the group helper.""" + + +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import group + + +async def test_expand_entity_ids(hass: HomeAssistant) -> None: + """Test expand_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + state = hass.states.get("group.init_group") + assert state is not None + assert state.attributes[ATTR_ENTITY_ID] == ["light.bowl", "light.ceiling"] + + assert sorted(group.expand_entity_ids(hass, ["group.init_group"])) == [ + "light.bowl", + "light.ceiling", + ] + assert sorted(group.expand_entity_ids(hass, ["group.INIT_group"])) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_expand_entity_ids_does_not_return_duplicates( + hass: HomeAssistant, +) -> None: + """Test that expand_entity_ids does not return duplicates.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + assert sorted( + group.expand_entity_ids(hass, ["group.init_group", "light.Ceiling"]) + ) == ["light.bowl", "light.ceiling"] + + assert sorted( + group.expand_entity_ids(hass, ["light.bowl", "group.init_group"]) + ) == ["light.bowl", "light.ceiling"] + + +async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: + """Test expand_entity_ids method with a group that contains itself.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + hass.states.async_set( + "group.rec_group", + STATE_ON, + {ATTR_ENTITY_ID: ["group.init_group", "light.ceiling"]}, + ) + + assert sorted(group.expand_entity_ids(hass, ["group.rec_group"])) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_expand_entity_ids_ignores_non_strings(hass: HomeAssistant) -> None: + """Test that non string elements in lists are ignored.""" + assert group.expand_entity_ids(hass, [5, True]) == [] + + +async def test_get_entity_ids(hass: HomeAssistant) -> None: + """Test get_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set( + "group.init_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "light.ceiling"]} + ) + + assert sorted(group.get_entity_ids(hass, "group.init_group")) == [ + "light.bowl", + "light.ceiling", + ] + + +async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: + """Test if get_entity_ids works with a domain_filter.""" + hass.states.async_set("switch.AC", STATE_OFF) + hass.states.async_set( + "group.mixed_group", STATE_ON, {ATTR_ENTITY_ID: ["light.bowl", "switch.ac"]} + ) + + assert group.get_entity_ids(hass, "group.mixed_group", domain_filter="switch") == [ + "switch.ac" + ] + + +async def test_get_entity_ids_with_non_existing_group_name(hass: HomeAssistant) -> None: + """Test get_entity_ids with a non existing group.""" + assert group.get_entity_ids(hass, "non_existing") == [] + + +async def test_get_entity_ids_with_non_group_state(hass: HomeAssistant) -> None: + """Test get_entity_ids with a non group state.""" + assert group.get_entity_ids(hass, "switch.AC") == [] From 5c82c39936a315a503425e3ff60fa66ab50bb9c7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jan 2024 17:48:31 +0100 Subject: [PATCH 0237/1544] Reorganize drop_connect tests (#107148) --- .../drop_connect/{test_coordinator.py => test_init.py} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename tests/components/drop_connect/{test_coordinator.py => test_init.py} (98%) diff --git a/tests/components/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_init.py similarity index 98% rename from tests/components/drop_connect/test_coordinator.py rename to tests/components/drop_connect/test_init.py index c45bb92189f..4963119b349 100644 --- a/tests/components/drop_connect/test_coordinator.py +++ b/tests/components/drop_connect/test_init.py @@ -1,4 +1,5 @@ -"""Test DROP coordinator.""" +"""Test DROP initialisation.""" + from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant From afb5f3c031d2d2aa67ba1167bff804ed9bf7c015 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 4 Jan 2024 19:45:18 +0100 Subject: [PATCH 0238/1544] Update frontend to 20240104.0 (#107155) --- 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 52f3932237b..ad24f6bb12d 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==20240103.3"] + "requirements": ["home-assistant-frontend==20240104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1ed8f31bcc9..ac82851adc1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.0.2 hass-nabucasa==0.75.1 hassil==1.5.1 home-assistant-bluetooth==1.11.0 -home-assistant-frontend==20240103.3 +home-assistant-frontend==20240104.0 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 01dea0313ce..66249b385bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.3 +home-assistant-frontend==20240104.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd12c1762b0..45155df3996 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240103.3 +home-assistant-frontend==20240104.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From eb320b69bc618808c899c71d9b44a504555cc647 Mon Sep 17 00:00:00 2001 From: Matt Emerick-Law Date: Thu, 4 Jan 2024 19:41:12 +0000 Subject: [PATCH 0239/1544] Bump Orvibo to 1.1.2 (#107162) * Bump python-orvibo version Fixes https://github.com/home-assistant/core/issues/106923 * Add version number * Remove version * Bump python-orvibo version --- homeassistant/components/orvibo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json index 72cdc4118df..05ce5edd8bd 100644 --- a/homeassistant/components/orvibo/manifest.json +++ b/homeassistant/components/orvibo/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/orvibo", "iot_class": "local_push", "loggers": ["orvibo"], - "requirements": ["orvibo==1.1.1"] + "requirements": ["orvibo==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 66249b385bb..7958c081730 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1443,7 +1443,7 @@ oralb-ble==0.17.6 oru==0.1.11 # homeassistant.components.orvibo -orvibo==1.1.1 +orvibo==1.1.2 # homeassistant.components.ourgroceries ourgroceries==1.5.4 From bf229be7bb0ddd42f6faec60e79f73c98886d56c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 21:17:23 +0100 Subject: [PATCH 0240/1544] Migrate Emonitor to has entity name (#107153) --- homeassistant/components/emonitor/sensor.py | 11 +++++++---- homeassistant/components/emonitor/strings.json | 10 ++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 6e196eebeb0..5600cca308e 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -27,10 +27,12 @@ from .const import DOMAIN SENSORS = ( SensorEntityDescription(key="inst_power"), SensorEntityDescription( - key="avg_power", name="Average", entity_registry_enabled_default=False + key="avg_power", + translation_key="average", + entity_registry_enabled_default=False, ), SensorEntityDescription( - key="max_power", name="Max", entity_registry_enabled_default=False + key="max_power", translation_key="max", entity_registry_enabled_default=False ), ) @@ -66,6 +68,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): _attr_device_class = SensorDeviceClass.POWER _attr_native_unit_of_measurement = UnitOfPower.WATT _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True def __init__( self, @@ -79,9 +82,9 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) mac_address = self.emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) - label = self.channel_data.label or f"{device_name} {channel_number}" + label = self.channel_data.label or str(channel_number) if description.name is not UNDEFINED: - self._attr_name = f"{label} {description.name}" + self._attr_translation_placeholders = {"label": label} self._attr_unique_id = f"{mac_address}_{channel_number}_{description.key}" else: self._attr_name = label diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 08ffe030890..95f7f65bb98 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -22,5 +22,15 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "average": { + "name": "{label} average" + }, + "max": { + "name": "{label} max" + } + } } } From f2514c0bdeb67be25d3308936449d59affde07f4 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 4 Jan 2024 21:26:12 +0100 Subject: [PATCH 0241/1544] Migrate AVM FRITZ!Box Call monitor to has entity name (#99752) * Migrate AVM FRITZ!Box Call monitor to has entity name * Update homeassistant/components/fritzbox_callmonitor/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fritzbox_callmonitor/strings.json Co-authored-by: Joost Lekkerkerker * Update sensor.py * Update sensor.py * Update strings.json * Use translation placeholders --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fritzbox_callmonitor/sensor.py | 9 ++++----- .../components/fritzbox_callmonitor/strings.json | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index cc239895c38..03ac98419c1 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -56,18 +56,16 @@ async def async_setup_entry( FRITZBOX_PHONEBOOK ] - phonebook_name: str = config_entry.title phonebook_id: int = config_entry.data[CONF_PHONEBOOK] prefixes: list[str] | None = config_entry.options.get(CONF_PREFIXES) serial_number: str = config_entry.data[SERIAL_NUMBER] host: str = config_entry.data[CONF_HOST] port: int = config_entry.data[CONF_PORT] - name = f"{fritzbox_phonebook.fph.modelname} Call Monitor {phonebook_name}" unique_id = f"{serial_number}-{phonebook_id}" sensor = FritzBoxCallSensor( - name=name, + phonebook_name=config_entry.title, unique_id=unique_id, fritzbox_phonebook=fritzbox_phonebook, prefixes=prefixes, @@ -82,13 +80,14 @@ class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" _attr_icon = ICON_PHONE + _attr_has_entity_name = True _attr_translation_key = DOMAIN _attr_device_class = SensorDeviceClass.ENUM _attr_options = list(CallState) def __init__( self, - name: str, + phonebook_name: str, unique_id: str, fritzbox_phonebook: FritzBoxPhonebook, prefixes: list[str] | None, @@ -103,7 +102,7 @@ class FritzBoxCallSensor(SensorEntity): self._monitor: FritzBoxCallMonitor | None = None self._attributes: dict[str, str | list[str]] = {} - self._attr_name = name.title() + self._attr_translation_placeholders = {"phonebook_name": phonebook_name} self._attr_unique_id = unique_id self._attr_native_value = CallState.IDLE self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index ac36942eec2..9bfb1a6a7a0 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -44,6 +44,7 @@ "entity": { "sensor": { "fritzbox_callmonitor": { + "name": "Call monitor {phonebook_name}", "state": { "ringing": "Ringing", "dialing": "Dialing", From bc26377c167debb9446552f9120b157aedbde4ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 10:31:09 -1000 Subject: [PATCH 0242/1544] Cache homekit_controller supported features (#106702) --- .../components/homekit_controller/climate.py | 57 +- .../components/homekit_controller/cover.py | 16 +- .../components/homekit_controller/entity.py | 11 + .../components/homekit_controller/fan.py | 34 +- .../homekit_controller/humidifier.py | 222 +- .../components/homekit_controller/light.py | 21 +- .../home_assistant_bridge_basic_cover.json | 323 +++ ..._assistant_bridge_basic_heater_cooler.json | 229 ++ .../home_assistant_bridge_basic_light.json | 183 ++ .../fixtures/home_assistant_bridge_cover.json | 330 +++ .../home_assistant_bridge_heater_cooler.json | 237 ++ .../home_assistant_bridge_humidifier.json | 173 ++ ...assistant_bridge_humidifier_new_range.json | 173 ++ .../fixtures/home_assistant_bridge_light.json | 205 ++ .../snapshots/test_init.ambr | 2378 +++++++++++++++++ .../test_cover_that_changes_features.py | 54 + ...est_heater_cooler_that_changes_features.py | 48 + ...est_humidifier_that_changes_value_range.py | 44 + .../test_light_that_changes_features.py | 42 + 19 files changed, 4588 insertions(+), 192 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json create mode 100644 tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py create mode 100644 tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py create mode 100644 tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py create mode 100644 tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index d3e9a0f13a6..1548c23a543 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from aiohomekit.model.characteristics import ( ActivationStateValues, @@ -48,6 +48,12 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) # Map of Homekit operation modes to hass modes @@ -134,6 +140,12 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("supported_features", "fan_modes")) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -146,7 +158,7 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """Return the current temperature.""" return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) - @property + @cached_property def fan_modes(self) -> list[str] | None: """Return the available fan modes.""" if self.service.has(CharacteristicsTypes.FAN_STATE_TARGET): @@ -165,7 +177,7 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): {CharacteristicsTypes.FAN_STATE_TARGET: int(fan_mode == FAN_AUTO)} ) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = ClimateEntityFeature(0) @@ -179,6 +191,12 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): """Representation of a Homekit climate device.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("hvac_modes", "swing_modes")) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return super().get_characteristic_types() + [ @@ -197,7 +215,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): rotation_speed.maxValue or 100 ) - @property + @cached_property def fan_modes(self) -> list[str]: """Return the available fan modes.""" return [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] @@ -388,7 +406,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): value = self.service.value(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE) return TARGET_HEATER_COOLER_STATE_HOMEKIT_TO_HASS[value] - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" valid_values = clamp_enum_to_char( @@ -410,7 +428,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): value = self.service.value(CharacteristicsTypes.SWING_MODE) return SWING_MODE_HOMEKIT_TO_HASS[value] - @property + @cached_property def swing_modes(self) -> list[str]: """Return the list of available swing modes. @@ -428,7 +446,7 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): {CharacteristicsTypes.SWING_MODE: SWING_MODE_HASS_TO_HOMEKIT[swing_mode]} ) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = super().supported_features @@ -451,6 +469,12 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Representation of a Homekit climate device.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("hvac_modes",)) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return super().get_characteristic_types() + [ @@ -483,7 +507,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): if ( (mode == HVACMode.HEAT_COOL) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ) and heat_temp and cool_temp @@ -524,9 +548,8 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT, HVACMode.COOL}) or ( (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) - and not ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features - ) + and ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + not in self.supported_features ): return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET) return None @@ -536,7 +559,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Return the highbound target temperature we try to reach.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): return self.service.value( CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD @@ -548,7 +571,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Return the lowbound target temperature we try to reach.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): return self.service.value( CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD @@ -560,7 +583,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Return the minimum target temp.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): min_temp = self.service[ CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD @@ -582,7 +605,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): """Return the maximum target temp.""" value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) if (MODE_HOMEKIT_TO_HASS.get(value) in {HVACMode.HEAT_COOL}) and ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE & self.supported_features + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in self.supported_features ): max_temp = self.service[ CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD @@ -656,7 +679,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) return MODE_HOMEKIT_TO_HASS[value] - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" valid_values = clamp_enum_to_char( @@ -665,7 +688,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity): ) return [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values] - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" features = super().supported_features diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index f94e1145627..f99563843c7 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,7 +1,7 @@ """Support for Homekit covers.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -28,6 +28,12 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + STATE_STOPPED = "stopped" CURRENT_GARAGE_STATE_MAP = { @@ -128,6 +134,12 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverEntity): class HomeKitWindowCover(HomeKitEntity, CoverEntity): """Representation of a HomeKit Window or Window Covering.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("supported_features",)) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -142,7 +154,7 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): CharacteristicsTypes.OBSTRUCTION_DETECTED, ] - @property + @cached_property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" features = ( diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index ceb1505518b..ba0cad8d666 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -1,6 +1,7 @@ """Homekit Controller entities.""" from __future__ import annotations +import contextlib from typing import Any from aiohomekit.model.characteristics import ( @@ -74,6 +75,16 @@ class HomeKitEntity(Entity): if not self._async_remove_entity_if_accessory_or_service_disappeared(): self._async_reconfigure() + @callback + def _async_clear_property_cache(self, properties: tuple[str, ...]) -> None: + """Clear the cache of properties.""" + for prop in properties: + # suppress is slower than try-except-pass, but + # we do not expect to have many properties to clear + # or this to be called often. + with contextlib.suppress(AttributeError): + delattr(self, prop) + @callback def _async_reconfigure(self) -> None: """Reconfigure the entity.""" diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 550f86ddbe4..d87b6ab3e39 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -1,7 +1,7 @@ """Support for Homekit fans.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -25,6 +25,12 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + # 0 is clockwise, 1 is counter-clockwise. The match to forward and reverse is so that # its consistent with homeassistant.components.homekit. DIRECTION_TO_HK = { @@ -41,6 +47,20 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): # that controls whether the fan is on or off. on_characteristic: str + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache( + ( + "_speed_range", + "_min_speed", + "_max_speed", + "speed_count", + "supported_features", + ) + ) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -55,19 +75,19 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): """Return true if device is on.""" return self.service.value(self.on_characteristic) == 1 - @property + @cached_property def _speed_range(self) -> tuple[int, int]: """Return the speed range.""" return (self._min_speed, self._max_speed) - @property + @cached_property def _min_speed(self) -> int: """Return the minimum speed.""" return ( round(self.service[CharacteristicsTypes.ROTATION_SPEED].minValue or 0) + 1 ) - @property + @cached_property def _max_speed(self) -> int: """Return the minimum speed.""" return round(self.service[CharacteristicsTypes.ROTATION_SPEED].maxValue or 100) @@ -94,7 +114,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): oscillating = self.service.value(CharacteristicsTypes.SWING_MODE) return oscillating == 1 - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" features = FanEntityFeature(0) @@ -110,7 +130,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): return features - @property + @cached_property def speed_count(self) -> int: """Speed count for the fan.""" return round( @@ -157,7 +177,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if ( percentage is not None - and self.supported_features & FanEntityFeature.SET_SPEED + and FanEntityFeature.SET_SPEED in self.supported_features ): characteristics[CharacteristicsTypes.ROTATION_SPEED] = round( percentage_to_ranged_value(self._speed_range, percentage) diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index 57e4e7e73d8..b5e67e7f1a4 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -1,7 +1,7 @@ """Support for HomeKit Controller humidifier.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -25,6 +25,12 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + HK_MODE_TO_HA = { 0: "off", 1: MODE_AUTO, @@ -39,46 +45,25 @@ HA_MODE_TO_HK = { } -class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): +class HomeKitBaseHumidifier(HomeKitEntity, HumidifierEntity): """Representation of a HomeKit Controller Humidifier.""" - _attr_device_class = HumidifierDeviceClass.HUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = [MODE_NORMAL, MODE_AUTO] + _humidity_char = CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD + _on_mode_value = 1 - def get_characteristic_types(self) -> list[str]: - """Define the homekit characteristics the entity cares about.""" - return [ - CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, - ] + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache(("max_humidity", "min_humidity")) + super()._async_reconfigure() @property def is_on(self) -> bool: """Return true if device is on.""" return self.service.value(CharacteristicsTypes.ACTIVE) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified valve on.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the specified valve off.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) - - @property - def target_humidity(self) -> int | None: - """Return the humidity we try to reach.""" - return self.service.value( - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ) - - @property - def current_humidity(self) -> int | None: - """Return the current humidity.""" - return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - @property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -91,23 +76,36 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): return MODE_AUTO if mode == 1 else MODE_NORMAL @property - def available_modes(self) -> list[str] | None: - """Return a list of available modes. + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - Requires HumidifierEntityFeature.MODES. - """ - available_modes = [ - MODE_NORMAL, - MODE_AUTO, - ] + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self.service.value(self._humidity_char) - return available_modes + @cached_property + def min_humidity(self) -> int: + """Return the minimum humidity.""" + return int(self.service[self._humidity_char].minValue or DEFAULT_MIN_HUMIDITY) + + @cached_property + def max_humidity(self) -> int: + """Return the maximum humidity.""" + return int(self.service[self._humidity_char].maxValue or DEFAULT_MAX_HUMIDITY) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self.async_put_characteristics( - {CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: humidity} - ) + await self.async_put_characteristics({self._humidity_char: humidity}) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the specified valve on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the specified valve off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) async def async_set_mode(self, mode: str) -> None: """Set new mode.""" @@ -121,37 +119,33 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): else: await self.async_put_characteristics( { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 1, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: self._on_mode_value, CharacteristicsTypes.ACTIVE: True, } ) - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ].minValue - or DEFAULT_MIN_HUMIDITY - ) - - @property - def max_humidity(self) -> int: - """Return the maximum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD - ].maxValue - or DEFAULT_MAX_HUMIDITY - ) + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, + CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, + ] -class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): +class HomeKitHumidifier(HomeKitBaseHumidifier): + """Representation of a HomeKit Controller Humidifier.""" + + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + + +class HomeKitDehumidifier(HomeKitBaseHumidifier): """Representation of a HomeKit Controller Humidifier.""" _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER - _attr_supported_features = HumidifierEntityFeature.MODES + _humidity_char = CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD + _on_mode_value = 2 def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise the dehumidifier.""" @@ -160,106 +154,10 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" - return [ - CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, - CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD, - ] - - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.service.value(CharacteristicsTypes.ACTIVE) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the specified valve on.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the specified valve off.""" - await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) - - @property - def target_humidity(self) -> int | None: - """Return the humidity we try to reach.""" - return self.service.value( + return super().get_characteristic_types() + [ CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ) - - @property - def current_humidity(self) -> int | None: - """Return the current humidity.""" - return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) - - @property - def mode(self) -> str | None: - """Return the current mode, e.g., home, auto, baby. - - Requires HumidifierEntityFeature.MODES. - """ - mode = self.service.value( - CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE - ) - return MODE_AUTO if mode == 1 else MODE_NORMAL - - @property - def available_modes(self) -> list[str] | None: - """Return a list of available modes. - - Requires HumidifierEntityFeature.MODES. - """ - available_modes = [ - MODE_NORMAL, - MODE_AUTO, ] - return available_modes - - async def async_set_humidity(self, humidity: int) -> None: - """Set new target humidity.""" - await self.async_put_characteristics( - {CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: humidity} - ) - - async def async_set_mode(self, mode: str) -> None: - """Set new mode.""" - if mode == MODE_AUTO: - await self.async_put_characteristics( - { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 0, - CharacteristicsTypes.ACTIVE: True, - } - ) - else: - await self.async_put_characteristics( - { - CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE: 2, - CharacteristicsTypes.ACTIVE: True, - } - ) - - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ].minValue - or DEFAULT_MIN_HUMIDITY - ) - - @property - def max_humidity(self) -> int: - """Return the maximum humidity.""" - return int( - self.service[ - CharacteristicsTypes.RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD - ].maxValue - or DEFAULT_MAX_HUMIDITY - ) - @property def old_unique_id(self) -> str: """Return the old ID of this device.""" diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 5bf810a89db..f1d36c02933 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,7 +1,7 @@ """Support for Homekit lights.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes @@ -22,6 +22,11 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + async def async_setup_entry( hass: HomeAssistant, @@ -50,6 +55,14 @@ async def async_setup_entry( class HomeKitLight(HomeKitEntity, LightEntity): """Representation of a Homekit light.""" + @callback + def _async_reconfigure(self) -> None: + """Reconfigure entity.""" + self._async_clear_property_cache( + ("supported_features", "min_mireds", "max_mireds", "supported_color_modes") + ) + super()._async_reconfigure() + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -78,13 +91,13 @@ class HomeKitLight(HomeKitEntity, LightEntity): self.service.value(CharacteristicsTypes.SATURATION), ) - @property + @cached_property def min_mireds(self) -> int: """Return minimum supported color temperature.""" min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue return int(min_value) if min_value else super().min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the maximum color temperature.""" max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue @@ -113,7 +126,7 @@ class HomeKitLight(HomeKitEntity, LightEntity): return ColorMode.ONOFF - @property + @cached_property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" color_modes: set[ColorMode] = set() diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json new file mode 100644 index 00000000000..cfb94b104b0 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_cover.json @@ -0,0 +1,323 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 878448248, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Kitchen Window" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.kitchen_window" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 8, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 9, + "type": "96", + "characteristics": [ + { + "iid": 10, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 11, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 12, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 13, + "type": "8C", + "characteristics": [ + { + "iid": 14, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 15, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 16, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + }, + { + "aid": 123016423, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 155, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 156, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 157, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 158, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Family Room North" + }, + { + "iid": 159, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.family_door_north" + }, + { + "iid": 160, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 161, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 162, + "type": "96", + "characteristics": [ + { + "iid": 163, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 164, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 165, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 166, + "type": "8C", + "characteristics": [ + { + "iid": 167, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 168, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 169, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json new file mode 100644 index 00000000000..4526179b4da --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_heater_cooler.json @@ -0,0 +1,229 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 1233851541, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 163, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 164, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Lookin" + }, + { + "iid": 165, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Climate Control" + }, + { + "iid": 166, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "89 Living Room" + }, + { + "iid": 167, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "climate.89_living_room" + }, + { + "iid": 168, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ], + "primary": false + }, + { + "iid": 169, + "type": "BC", + "characteristics": [ + { + "iid": 170, + "type": "B2", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 171, + "type": "B1", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2, 3], + "value": 2 + }, + { + "iid": 172, + "type": "11", + "perms": ["pr", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 1000, + "minValue": -273.1, + "value": 22.8 + }, + { + "iid": 173, + "type": "35", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 30.0, + "minValue": 16.0, + "value": 20.0 + }, + { + "iid": 174, + "type": "36", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 180, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 60 + } + ], + "primary": true, + "linked": [175] + }, + { + "iid": 175, + "type": "B7", + "characteristics": [ + { + "iid": 176, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 177, + "type": "29", + "perms": ["pr", "pw", "ev"], + "format": "float", + "description": "Fan Mode", + "unit": "percentage", + "minStep": 33.333333333333336, + "maxValue": 100, + "minValue": 0, + "value": 33.33333333333334 + }, + { + "iid": 178, + "type": "BF", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Fan Auto", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 179, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Swing Mode", + "valid-values": [0, 1], + "value": 0 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json new file mode 100644 index 00000000000..2e5c8719876 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_light.json @@ -0,0 +1,183 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 3982136094, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 597, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 598, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "FirstAlert" + }, + { + "iid": 599, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "1039102" + }, + { + "iid": 600, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Laundry Smoke ED78" + }, + { + "iid": 601, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "light.laundry_smoke_ed78" + }, + { + "iid": 602, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "1.4.84" + }, + { + "iid": 603, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "9.0.0" + } + ] + }, + { + "iid": 604, + "type": "96", + "characteristics": [ + { + "iid": 605, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 606, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 607, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 608, + "type": "43", + "characteristics": [ + { + "iid": 609, + "type": "25", + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": false + }, + { + "iid": 610, + "type": "8", + "perms": ["pr", "pw", "ev"], + "format": "int", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json new file mode 100644 index 00000000000..d58de1d2b98 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_cover.json @@ -0,0 +1,330 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 878448248, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Kitchen Window" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.kitchen_window" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 8, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 9, + "type": "96", + "characteristics": [ + { + "iid": 10, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 11, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 12, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 13, + "type": "8C", + "characteristics": [ + { + "iid": 14, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 15, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 16, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + } + ] + } + ] + }, + { + "aid": 123016423, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 155, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 156, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "RYSE Inc." + }, + { + "iid": 157, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "RYSE Shade" + }, + { + "iid": 158, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Family Room North" + }, + { + "iid": 159, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "cover.family_door_north" + }, + { + "iid": 160, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "3.6.2" + }, + { + "iid": 161, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "1.0.0" + } + ] + }, + { + "iid": 162, + "type": "96", + "characteristics": [ + { + "iid": 163, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 100 + }, + { + "iid": 164, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 165, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 166, + "type": "8C", + "characteristics": [ + { + "iid": 167, + "type": "6D", + "perms": ["pr", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 168, + "type": "7C", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "maxValue": 100, + "value": 98 + }, + { + "iid": 169, + "type": "72", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 170, + "type": "6F", + "perms": ["pw", "pr", "ev"], + "format": "bool", + "value": false + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json new file mode 100644 index 00000000000..f96d168fc5f --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_heater_cooler.json @@ -0,0 +1,237 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 1233851541, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 163, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 164, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Lookin" + }, + { + "iid": 165, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Climate Control" + }, + { + "iid": 166, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "89 Living Room" + }, + { + "iid": 167, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "climate.89_living_room" + }, + { + "iid": 168, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ], + "primary": false + }, + { + "iid": 169, + "type": "BC", + "characteristics": [ + { + "iid": 170, + "type": "B2", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 171, + "type": "B1", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2, 3], + "value": 2 + }, + { + "iid": 172, + "type": "11", + "perms": ["pr", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 1000, + "minValue": -273.1, + "value": 22.8 + }, + { + "iid": 173, + "type": "35", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "celsius", + "minStep": 0.1, + "maxValue": 30.0, + "minValue": 16.0, + "value": 20.0 + }, + { + "iid": 174, + "type": "36", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 180, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 60 + }, + { + "iid": 290, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + } + ], + "primary": true, + "linked": [175] + }, + { + "iid": 175, + "type": "B7", + "characteristics": [ + { + "iid": 176, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 1 + }, + { + "iid": 177, + "type": "29", + "perms": ["pr", "pw", "ev"], + "format": "float", + "description": "Fan Mode", + "unit": "percentage", + "minStep": 33.333333333333336, + "maxValue": 100, + "minValue": 0, + "value": 33.33333333333334 + }, + { + "iid": 178, + "type": "BF", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Fan Auto", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 179, + "type": "B6", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "description": "Swing Mode", + "valid-values": [0, 1], + "value": 0 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json new file mode 100644 index 00000000000..8dd33639190 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier.json @@ -0,0 +1,173 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 293334836, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "switchbot" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "WoHumi" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Humidifier 182A" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "humidifier.humidifier_182a" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "BD", + "characteristics": [ + { + "iid": 9, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 0 + }, + { + "iid": 10, + "type": "B3", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 11, + "type": "B4", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "maxValue": 1, + "minValue": 1, + "valid-values": [1], + "value": 1 + }, + { + "iid": 12, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 13, + "type": "CA", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 45 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json new file mode 100644 index 00000000000..28ef6c91d25 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_humidifier_new_range.json @@ -0,0 +1,173 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 293334836, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "switchbot" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "WoHumi" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Humidifier 182A" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "humidifier.humidifier_182a" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "BD", + "characteristics": [ + { + "iid": 9, + "type": "10", + "perms": ["pr", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 100, + "minValue": 0, + "value": 0 + }, + { + "iid": 10, + "type": "B3", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 0 + }, + { + "iid": 11, + "type": "B4", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "maxValue": 1, + "minValue": 1, + "valid-values": [1], + "value": 1 + }, + { + "iid": 12, + "type": "B0", + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + }, + { + "iid": 13, + "type": "CA", + "perms": ["pr", "pw", "ev"], + "format": "float", + "unit": "percentage", + "minStep": 1, + "maxValue": 80, + "minValue": 20, + "value": 45 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json new file mode 100644 index 00000000000..b5614184fae --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_light.json @@ -0,0 +1,205 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 2, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 3, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "Home Assistant" + }, + { + "iid": 4, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "Bridge" + }, + { + "iid": 5, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "HASS Bridge S6" + }, + { + "iid": 6, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "homekit.bridge" + }, + { + "iid": 7, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "2024.2.0" + } + ] + }, + { + "iid": 8, + "type": "A2", + "characteristics": [ + { + "iid": 9, + "type": "37", + "perms": ["pr", "ev"], + "format": "string", + "value": "01.01.00" + } + ] + } + ] + }, + { + "aid": 3982136094, + "services": [ + { + "iid": 1, + "type": "3E", + "characteristics": [ + { + "iid": 597, + "type": "14", + "perms": ["pw"], + "format": "bool" + }, + { + "iid": 598, + "type": "20", + "perms": ["pr"], + "format": "string", + "value": "FirstAlert" + }, + { + "iid": 599, + "type": "21", + "perms": ["pr"], + "format": "string", + "value": "1039102" + }, + { + "iid": 600, + "type": "23", + "perms": ["pr"], + "format": "string", + "value": "Laundry Smoke ED78" + }, + { + "iid": 601, + "type": "30", + "perms": ["pr"], + "format": "string", + "value": "light.laundry_smoke_ed78" + }, + { + "iid": 602, + "type": "52", + "perms": ["pr"], + "format": "string", + "value": "1.4.84" + }, + { + "iid": 603, + "type": "53", + "perms": ["pr"], + "format": "string", + "value": "9.0.0" + } + ] + }, + { + "iid": 604, + "type": "96", + "characteristics": [ + { + "iid": 605, + "type": "68", + "perms": ["pr", "ev"], + "format": "uint8", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 606, + "type": "8F", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1, 2], + "value": 2 + }, + { + "iid": 607, + "type": "79", + "perms": ["pr", "ev"], + "format": "uint8", + "valid-values": [0, 1], + "value": 0 + } + ] + }, + { + "iid": 608, + "type": "43", + "characteristics": [ + { + "iid": 609, + "type": "25", + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": false + }, + { + "iid": 610, + "type": "8", + "perms": ["pr", "pw", "ev"], + "format": "int", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 100 + }, + { + "iid": 611, + "type": "13", + "perms": ["pr", "pw", "ev"], + "format": "float", + "maxValue": 360, + "minStep": 1, + "minValue": 0, + "unit": "arcdegrees", + "value": 0 + }, + { + "iid": 612, + "type": "2F", + "perms": ["pr", "pw", "ev"], + "format": "float", + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "unit": "percentage", + "value": 75 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a0c6fd00ee6..4b4ffeb9aa3 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -6230,6 +6230,368 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_cover] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:123016423', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Family Room North', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.family_room_north_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_1_155', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Family Room North Identify', + }), + 'entity_id': 'button.family_room_north_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.family_room_north', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_166', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 98, + 'friendly_name': 'Family Room North', + 'supported_features': , + }), + 'entity_id': 'cover.family_room_north', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.family_room_north_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Family Room North Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_162', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Family Room North Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.family_room_north_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:878448248', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Kitchen Window', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Window Identify', + }), + 'entity_id': 'button.kitchen_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'Kitchen Window', + 'supported_features': , + }), + 'entity_id': 'cover.kitchen_window', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Kitchen Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.kitchen_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[home_assistant_bridge_basic_fan] list([ dict({ @@ -6519,6 +6881,958 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_heater_cooler] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1233851541', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lookin', + 'model': 'Climate Control', + 'name': '89 Living Room', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.89_living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_1_163', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Identify', + }), + 'entity_id': 'button.89_living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'target_temp_step': 1.0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 22.8, + 'friendly_name': '89 Living Room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 1.0, + }), + 'entity_id': 'climate.89_living_room', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_175', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room', + 'oscillating': False, + 'percentage': 33, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.89_living_room', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.89_living_room_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': '89 Living Room Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1233851541_169_174', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.89_living_room_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_180', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': '89 Living Room Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.89_living_room_current_humidity', + 'state': '60', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_172', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': '89 Living Room Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.89_living_room_current_temperature', + 'state': '22.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_basic_light] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '9.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3982136094', + ]), + ]), + 'is_new': False, + 'manufacturer': 'FirstAlert', + 'model': '1039102', + 'name': 'Laundry Smoke ED78', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.4.84', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_1_597', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Laundry Smoke ED78 Identify', + }), + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.laundry_smoke_ed78', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_608', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Laundry Smoke ED78', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.laundry_smoke_ed78', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Laundry Smoke ED78 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_604', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Laundry Smoke ED78 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_cover] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:123016423', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Family Room North', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.family_room_north_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_1_155', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Family Room North Identify', + }), + 'entity_id': 'button.family_room_north_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.family_room_north', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Family Room North', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_166', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 98, + 'friendly_name': 'Family Room North', + 'supported_features': , + }), + 'entity_id': 'cover.family_room_north', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.family_room_north_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Family Room North Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_123016423_162', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Family Room North Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.family_room_north_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:878448248', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Kitchen Window', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.6.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Window Identify', + }), + 'entity_id': 'button.kitchen_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Window', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'Kitchen Window', + 'supported_features': , + }), + 'entity_id': 'cover.kitchen_window', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_window_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Kitchen Window Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_878448248_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen Window Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.kitchen_window_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[home_assistant_bridge_fan] list([ dict({ @@ -6990,6 +8304,1070 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_heater_cooler] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1233851541', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lookin', + 'model': 'Climate Control', + 'name': '89 Living Room', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.89_living_room_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_1_163', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Identify', + }), + 'entity_id': 'button.89_living_room_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'vertical', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 22.8, + 'friendly_name': '89 Living Room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'vertical', + 'swing_modes': list([ + 'off', + 'vertical', + ]), + 'target_temp_step': 1.0, + }), + 'entity_id': 'climate.89_living_room', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.89_living_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '89 Living Room', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_175', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room', + 'oscillating': False, + 'percentage': 33, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.89_living_room', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.89_living_room_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': '89 Living Room Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1233851541_169_174', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': '89 Living Room Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.89_living_room_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_180', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': '89 Living Room Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.89_living_room_current_humidity', + 'state': '60', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.89_living_room_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '89 Living Room Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1233851541_169_172', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': '89 Living Room Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.89_living_room_current_temperature', + 'state': '22.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_humidifier] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:293334836', + ]), + ]), + 'is_new': False, + 'manufacturer': 'switchbot', + 'model': 'WoHumi', + 'name': 'Humidifier 182A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.humidifier_182a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidifier 182A Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Humidifier 182A Identify', + }), + 'entity_id': 'button.humidifier_182a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_182a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 0, + 'device_class': 'humidifier', + 'friendly_name': 'Humidifier 182A', + 'humidity': 45, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.humidifier_182a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 182A Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_humidifier_new_range] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:293334836', + ]), + ]), + 'is_new': False, + 'manufacturer': 'switchbot', + 'model': 'WoHumi', + 'name': 'Humidifier 182A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.humidifier_182a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidifier 182A Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Humidifier 182A Identify', + }), + 'entity_id': 'button.humidifier_182a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 80, + 'min_humidity': 20, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.humidifier_182a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 0, + 'device_class': 'humidifier', + 'friendly_name': 'Humidifier 182A', + 'humidity': 45, + 'max_humidity': 80, + 'min_humidity': 20, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.humidifier_182a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidifier 182A Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_293334836_8_9', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Humidifier 182A Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.humidifier_182a_current_humidity', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_light] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'HASS Bridge S6', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2024.2.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hass_bridge_s6_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HASS Bridge S6 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HASS Bridge S6 Identify', + }), + 'entity_id': 'button.hass_bridge_s6_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '9.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3982136094', + ]), + ]), + 'is_new': False, + 'manufacturer': 'FirstAlert', + 'model': '1039102', + 'name': 'Laundry Smoke ED78', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.4.84', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_1_597', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Laundry Smoke ED78 Identify', + }), + 'entity_id': 'button.laundry_smoke_ed78_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.laundry_smoke_ed78', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Laundry Smoke ED78', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_608', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Laundry Smoke ED78', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'entity_id': 'light.laundry_smoke_ed78', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Laundry Smoke ED78 Battery', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3982136094_604', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Laundry Smoke ED78 Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.laundry_smoke_ed78_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[homespan_daikin_bridge] list([ dict({ diff --git a/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py new file mode 100644 index 00000000000..87948c92214 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_cover_that_changes_features.py @@ -0,0 +1,54 @@ +"""Test for a Home Assistant bridge that changes cover features at runtime.""" + + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_cover_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic cover that does not support position + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_cover.json" + ) + await setup_test_accessories(hass, accessories) + + cover = entity_registry.async_get("cover.family_room_north") + assert cover.unique_id == "00:00:00:00:00:00_123016423_166" + + cover_state = hass.states.get("cover.family_room_north") + assert ( + cover_state.attributes[ATTR_SUPPORTED_FEATURES] + is CoverEntityFeature.OPEN + | CoverEntityFeature.STOP + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + cover = entity_registry.async_get("cover.family_room_north") + assert cover.unique_id == "00:00:00:00:00:00_123016423_166" + + # Now change the config to remove stop + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_cover.json" + ) + await device_config_changed(hass, accessories) + + cover_state = hass.states.get("cover.family_room_north") + assert ( + cover_state.attributes[ATTR_SUPPORTED_FEATURES] + is CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) diff --git a/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py new file mode 100644 index 00000000000..79b07512c67 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py @@ -0,0 +1,48 @@ +"""Test for a Home Assistant bridge that changes climate features at runtime.""" + + +from homeassistant.components.climate import ATTR_SWING_MODES, ClimateEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_cover_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic heater cooler that does not support swing mode + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_heater_cooler.json" + ) + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.89_living_room") + assert climate.unique_id == "00:00:00:00:00:00_1233851541_169" + + climate_state = hass.states.get("climate.89_living_room") + assert climate_state.attributes[ATTR_SUPPORTED_FEATURES] is ClimateEntityFeature(0) + assert ATTR_SWING_MODES not in climate_state.attributes + + climate = entity_registry.async_get("climate.89_living_room") + assert climate.unique_id == "00:00:00:00:00:00_1233851541_169" + + # Now change the config to add swing mode + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_heater_cooler.json" + ) + await device_config_changed(hass, accessories) + + climate_state = hass.states.get("climate.89_living_room") + assert ( + climate_state.attributes[ATTR_SUPPORTED_FEATURES] + is ClimateEntityFeature.SWING_MODE + ) + assert climate_state.attributes[ATTR_SWING_MODES] == ["off", "vertical"] diff --git a/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py b/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py new file mode 100644 index 00000000000..518bcbbef38 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_humidifier_that_changes_value_range.py @@ -0,0 +1,44 @@ +"""Test for a Home Assistant bridge that changes humidifier min/max at runtime.""" + + +from homeassistant.components.humidifier import ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_humidifier_change_range_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that min max can be changed at runtime.""" + + # Set up a basic humidifier + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_humidifier.json" + ) + await setup_test_accessories(hass, accessories) + + humidifier = entity_registry.async_get("humidifier.humidifier_182a") + assert humidifier.unique_id == "00:00:00:00:00:00_293334836_8" + + humidifier_state = hass.states.get("humidifier.humidifier_182a") + assert humidifier_state.attributes[ATTR_MIN_HUMIDITY] == 0 + assert humidifier_state.attributes[ATTR_MAX_HUMIDITY] == 100 + + cover = entity_registry.async_get("humidifier.humidifier_182a") + assert cover.unique_id == "00:00:00:00:00:00_293334836_8" + + # Now change min/max values + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_humidifier_new_range.json" + ) + await device_config_changed(hass, accessories) + + humidifier_state = hass.states.get("humidifier.humidifier_182a") + assert humidifier_state.attributes[ATTR_MIN_HUMIDITY] == 20 + assert humidifier_state.attributes[ATTR_MAX_HUMIDITY] == 80 diff --git a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py new file mode 100644 index 00000000000..54dc900c130 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py @@ -0,0 +1,42 @@ +"""Test for a Home Assistant bridge that changes light features at runtime.""" + + +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_light_add_feature_at_runtime( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that new features can be added at runtime.""" + + # Set up a basic light that does not support color + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_light.json" + ) + await setup_test_accessories(hass, accessories) + + light = entity_registry.async_get("light.laundry_smoke_ed78") + assert light.unique_id == "00:00:00:00:00:00_3982136094_608" + + light_state = hass.states.get("light.laundry_smoke_ed78") + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + + light = entity_registry.async_get("light.laundry_smoke_ed78") + assert light.unique_id == "00:00:00:00:00:00_3982136094_608" + + # Now add hue and saturation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_light.json" + ) + await device_config_changed(hass, accessories) + + light_state = hass.states.get("light.laundry_smoke_ed78") + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] From 8b1db37a8557cf708e1b0508e578424853e9ebd5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 21:32:31 +0100 Subject: [PATCH 0243/1544] Use snapshots in Glances sensor tests (#107159) * Use snapshots in Glances sensor tests * yes --- .../glances/snapshots/test_sensor.ambr | 915 ++++++++++++++++++ tests/components/glances/test_sensor.py | 52 +- 2 files changed, 928 insertions(+), 39 deletions(-) create mode 100644 tests/components/glances/snapshots/test_sensor.ambr diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0dbdec54714 --- /dev/null +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -0,0 +1,915 @@ +# serializer version: 1 +# name: test_sensor_states[sensor.0_0_0_0_containers_active-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.0_0_0_0_containers_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:docker', + 'original_name': 'Containers active', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Containers active', + 'icon': 'mdi:docker', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_active', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-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.0_0_0_0_containers_cpu_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:docker', + 'original_name': 'Containers CPU used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_cpu_use', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Containers CPU used', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_cpu_used', + 'last_changed': , + 'last_updated': , + 'state': '77.2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-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.0_0_0_0_containers_ram_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:docker', + 'original_name': 'Containers RAM used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--docker_memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Containers RAM used', + 'icon': 'mdi:docker', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_containers_ram_used', + 'last_changed': , + 'last_updated': , + 'state': '1149.6', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_cpu_thermal_1_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.0_0_0_0_cpu_thermal_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'cpu_thermal 1 Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-cpu_thermal 1-temperature_core', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_cpu_thermal_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 cpu_thermal 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_cpu_thermal_1_temperature', + 'last_changed': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_err_temp_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.0_0_0_0_err_temp_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'err_temp Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-err_temp-temperature_hdd', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_err_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 err_temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_err_temp_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-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.0_0_0_0_md1_raid_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md1 Raid available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md1-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 Raid available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_raid_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-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.0_0_0_0_md1_raid_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md1 Raid used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md1-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md1 Raid used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md1_raid_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-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.0_0_0_0_md3_raid_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md3 Raid available', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md3-available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 Raid available', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_raid_available', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-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.0_0_0_0_md3_raid_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': 'md3 Raid used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-md3-used', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 md3 Raid used', + 'icon': 'mdi:harddisk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_md3_raid_used', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_free-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.0_0_0_0_media_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/media free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used-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.0_0_0_0_media_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/media used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-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.0_0_0_0_media_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': '/media used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/media-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /media used percent', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_na_temp_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.0_0_0_0_na_temp_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'na_temp Temperature', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-na_temp-temperature_hdd', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_na_temp_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '0.0.0.0 na_temp Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_na_temp_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_free-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.0_0_0_0_ram_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'RAM free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 RAM free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_free', + 'last_changed': , + 'last_updated': , + 'state': '2745.0', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used-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.0_0_0_0_ram_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'RAM used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 RAM used', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_used', + 'last_changed': , + 'last_updated': , + 'state': '1047.1', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-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.0_0_0_0_ram_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:memory', + 'original_name': 'RAM used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test--memory_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 RAM used percent', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ram_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '27.6', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_free-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.0_0_0_0_ssl_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_free', + 'last_changed': , + 'last_updated': , + 'state': '426.5', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used-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.0_0_0_0_ssl_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-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.0_0_0_0_ssl_used_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl used percent', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-/ssl-disk_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 /ssl used percent', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_used_percent', + 'last_changed': , + 'last_updated': , + 'state': '6.7', + }) +# --- diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index af00126b219..7369bb927ff 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,60 +1,34 @@ """Tests for glances sensors.""" import pytest +from syrupy import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import HA_SENSOR_DATA, MOCK_USER_INPUT +from . import MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_sensor_states(hass: HomeAssistant) -> None: +async def test_sensor_states( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Test sensor states are correctly collected from library.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - assert hass.states.get("sensor.0_0_0_0_ssl_used").state == str( - HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - ) - assert hass.states.get("sensor.0_0_0_0_cpu_thermal_1_temperature").state == str( - HA_SENSOR_DATA["sensors"]["cpu_thermal 1"]["temperature_core"] - ) - assert hass.states.get("sensor.0_0_0_0_err_temp_temperature").state == str( - HA_SENSOR_DATA["sensors"]["err_temp"]["temperature_hdd"] - ) - assert hass.states.get("sensor.0_0_0_0_na_temp_temperature").state == str( - HA_SENSOR_DATA["sensors"]["na_temp"]["temperature_hdd"] - ) - assert hass.states.get("sensor.0_0_0_0_ram_used_percent").state == str( - HA_SENSOR_DATA["mem"]["memory_use_percent"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_active").state == str( - HA_SENSOR_DATA["docker"]["docker_active"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_cpu_used").state == str( - HA_SENSOR_DATA["docker"]["docker_cpu_use"] - ) - assert hass.states.get("sensor.0_0_0_0_containers_ram_used").state == str( - HA_SENSOR_DATA["docker"]["docker_memory_use"] - ) - assert hass.states.get("sensor.0_0_0_0_md3_raid_available").state == str( - HA_SENSOR_DATA["raid"]["md3"]["available"] - ) - assert hass.states.get("sensor.0_0_0_0_md3_raid_used").state == str( - HA_SENSOR_DATA["raid"]["md3"]["used"] - ) - assert hass.states.get("sensor.0_0_0_0_md1_raid_available").state == str( - HA_SENSOR_DATA["raid"]["md1"]["available"] - ) - assert hass.states.get("sensor.0_0_0_0_md1_raid_used").state == str( - HA_SENSOR_DATA["raid"]["md1"]["used"] - ) + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) @pytest.mark.parametrize( From 11170c63451acf304de35b5393fb79316c50f795 Mon Sep 17 00:00:00 2001 From: Paul Holzinger <45212748+Luap99@users.noreply.github.com> Date: Thu, 4 Jan 2024 21:40:49 +0100 Subject: [PATCH 0244/1544] Pass down language to hassil (#106490) Hassil needs the language to convert numbers, this was added in https://github.com/home-assistant/hassil/pull/78. This fixes an annoying warning from the logs. Fixes #104760 --- homeassistant/components/conversation/default_agent.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index aae8f67e1d8..19992e63dad 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -196,6 +196,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents, slot_lists, intent_context, + language, ) return result @@ -283,6 +284,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], intent_context: dict[str, Any] | None, + language: str, ) -> RecognizeResult | None: """Search intents for a match to user input.""" # Prioritize matches with entity names above area names @@ -292,6 +294,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents.intents, slot_lists=slot_lists, intent_context=intent_context, + language=language, ): if "name" in result.entities: return result From eee66938558e7138c4e2ed86448e49ad0f777ec9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 21:41:45 +0100 Subject: [PATCH 0245/1544] Remove precision in streamlabs water (#107096) --- .../components/streamlabswater/coordinator.py | 6 +++--- .../components/streamlabswater/sensor.py | 3 +++ .../streamlabswater/snapshots/test_sensor.ambr | 15 ++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index dc57ae78810..bcb2e7790d4 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -50,8 +50,8 @@ class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): res[location_id] = StreamlabsData( is_away=location["homeAway"] == "away", name=location["name"], - daily_usage=round(water_usage["today"], 1), - monthly_usage=round(water_usage["thisMonth"], 1), - yearly_usage=round(water_usage["thisYear"], 1), + daily_usage=water_usage["today"], + monthly_usage=water_usage["thisMonth"], + yearly_usage=water_usage["thisYear"], ) return res diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index e49668208af..9faf3defa89 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -35,6 +35,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( translation_key="daily_usage", native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, value_fn=lambda data: data.daily_usage, ), StreamlabsWaterSensorEntityDescription( @@ -42,6 +43,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( translation_key="monthly_usage", native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, value_fn=lambda data: data.monthly_usage, ), StreamlabsWaterSensorEntityDescription( @@ -49,6 +51,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( translation_key="yearly_usage", native_unit_of_measurement=UnitOfVolume.GALLONS, device_class=SensorDeviceClass.WATER, + suggested_display_precision=1, value_fn=lambda data: data.yearly_usage, ), ) diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr index 5cd2479903a..9d8ca3a99e6 100644 --- a/tests/components/streamlabswater/snapshots/test_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -18,6 +18,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -41,7 +44,7 @@ 'entity_id': 'sensor.water_monitor_daily_usage', 'last_changed': , 'last_updated': , - 'state': '200.4', + 'state': '200.44691536', }) # --- # name: test_all_entities[sensor.water_monitor_monthly_usage-entry] @@ -63,6 +66,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -86,7 +92,7 @@ 'entity_id': 'sensor.water_monitor_monthly_usage', 'last_changed': , 'last_updated': , - 'state': '420.5', + 'state': '420.514099294', }) # --- # name: test_all_entities[sensor.water_monitor_yearly_usage-entry] @@ -108,6 +114,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -131,6 +140,6 @@ 'entity_id': 'sensor.water_monitor_yearly_usage', 'last_changed': , 'last_updated': , - 'state': '65432.4', + 'state': '65432.389256934', }) # --- From fd3a546cd8cd77e73ed4e1d723a5867af1aa2a21 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 4 Jan 2024 21:42:03 +0100 Subject: [PATCH 0246/1544] Update Home Assistant base image to 2024.01.0 - Python 3.12 (#107175) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 813676de3a7..824d580913d 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.01.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.01.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.01.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.01.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.01.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 34e6fa33288e4ad46a7c075d880411c3c0c1e68d Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 4 Jan 2024 21:42:38 +0100 Subject: [PATCH 0247/1544] Pass aiohttp clientsession to tedee integration (#107089) * pass aiohttpsession * Update homeassistant/components/tedee/config_flow.py Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Update homeassistant/components/tedee/__init__.py Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * move to coordinator --------- Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> --- homeassistant/components/tedee/config_flow.py | 7 ++++++- homeassistant/components/tedee/coordinator.py | 2 ++ homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 47a35089e66..27f455ee20c 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME @@ -34,7 +35,11 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): else: host = user_input[CONF_HOST] local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN] - tedee_client = TedeeClient(local_token=local_access_token, local_ip=host) + tedee_client = TedeeClient( + local_token=local_access_token, + local_ip=host, + session=async_get_clientsession(self.hass), + ) try: local_bridge = await tedee_client.get_local_bridge() except (TedeeAuthException, TedeeLocalAuthException): diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 90539f881c3..2b4f3c6d26b 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN @@ -45,6 +46,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self.tedee_client = TedeeClient( local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN], local_ip=self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), ) self._next_get_locks = time.time() diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 4055130e5e7..f170d116ff7 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.1"] + "requirements": ["pytedee-async==0.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7958c081730..b4f133e0719 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2135,7 +2135,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.1 +pytedee-async==0.2.6 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45155df3996..6ab5fe0223a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.1 +pytedee-async==0.2.6 # homeassistant.components.motionmount python-MotionMount==0.3.1 From 99bcc3828441b92f3f858f2178a5b401d2ecef31 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 4 Jan 2024 23:46:06 +0300 Subject: [PATCH 0248/1544] Add conversation_id parameter to conversation.process service (#106078) * Add conversation_id parameter to conversation.process service * fix test * fix tests --- .../components/conversation/__init__.py | 4 +- .../components/conversation/services.yaml | 4 + .../components/conversation/strings.json | 4 + .../conversation/snapshots/test_init.ambr | 128 +++++++++++++++++- tests/components/conversation/test_init.py | 5 +- 5 files changed, 139 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 29dd56c11ec..193bd45bba0 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -42,6 +42,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" ATTR_LANGUAGE = "language" ATTR_AGENT_ID = "agent_id" +ATTR_CONVERSATION_ID = "conversation_id" DOMAIN = "conversation" @@ -66,6 +67,7 @@ SERVICE_PROCESS_SCHEMA = vol.Schema( vol.Required(ATTR_TEXT): cv.string, vol.Optional(ATTR_LANGUAGE): cv.string, vol.Optional(ATTR_AGENT_ID): agent_id_validator, + vol.Optional(ATTR_CONVERSATION_ID): cv.string, } ) @@ -164,7 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: result = await async_converse( hass=hass, text=text, - conversation_id=None, + conversation_id=service.data.get(ATTR_CONVERSATION_ID), context=service.context, language=service.data.get(ATTR_LANGUAGE), agent_id=service.data.get(ATTR_AGENT_ID), diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 953db065614..3846426c3f0 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -14,6 +14,10 @@ process: example: homeassistant selector: conversation_agent: + conversation_id: + example: my_conversation_1 + selector: + text: reload: fields: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index 8240cfa3f82..255e6cec430 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -16,6 +16,10 @@ "agent_id": { "name": "Agent", "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." + }, + "conversation_id": { + "name": "Conversation ID", + "description": "ID of the conversation, to be able to continue a previous conversation" } } }, diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 35d967f37da..7f928224aba 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -225,7 +225,7 @@ ]), }) # --- -# name: test_turn_on_intent[turn kitchen on-None] +# name: test_turn_on_intent[None-turn kitchen on-None] dict({ 'conversation_id': None, 'response': dict({ @@ -255,7 +255,7 @@ }), }) # --- -# name: test_turn_on_intent[turn kitchen on-homeassistant] +# name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ 'conversation_id': None, 'response': dict({ @@ -285,7 +285,7 @@ }), }) # --- -# name: test_turn_on_intent[turn on kitchen-None] +# name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'conversation_id': None, 'response': dict({ @@ -315,7 +315,127 @@ }), }) # --- -# name: test_turn_on_intent[turn on kitchen-homeassistant] +# name: test_turn_on_intent[None-turn on kitchen-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on light', + }), + }), + }), + }) +# --- +# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ 'conversation_id': None, 'response': dict({ diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 0f47f9ac3d9..820734901ad 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -873,8 +873,9 @@ async def test_http_processing_intent_conversion_not_expose_new( @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) +@pytest.mark.parametrize("conversation_id", ("my_new_conversation", None)) async def test_turn_on_intent( - hass: HomeAssistant, init_components, sentence, agent_id, snapshot + hass: HomeAssistant, init_components, conversation_id, sentence, agent_id, snapshot ) -> None: """Test calling the turn on intent.""" hass.states.async_set("light.kitchen", "off") @@ -883,6 +884,8 @@ async def test_turn_on_intent( data = {conversation.ATTR_TEXT: sentence} if agent_id is not None: data[conversation.ATTR_AGENT_ID] = agent_id + if conversation_id is not None: + data[conversation.ATTR_CONVERSATION_ID] = conversation_id result = await hass.services.async_call( "conversation", "process", From 3caaf2931fae05d63475e8171d864b620c1a87cc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 4 Jan 2024 13:50:45 -0700 Subject: [PATCH 0249/1544] Clean up outdated entity replacement logic in Guardian (#107160) --- homeassistant/components/guardian/binary_sensor.py | 3 --- homeassistant/components/guardian/util.py | 8 ++------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index abe005aae33..6b58e70e45d 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -98,9 +98,6 @@ async def async_setup_entry( EntityDomainReplacementStrategy( BINARY_SENSOR_DOMAIN, f"{uid}_ap_enabled", - f"switch.guardian_valve_controller_{uid}_onboard_ap", - "2022.12.0", - remove_old_entity=True, ), ), ) diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index ff41c6e4936..400cd472446 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -29,9 +29,6 @@ class EntityDomainReplacementStrategy: old_domain: str old_unique_id: str - replacement_entity_id: str - breaks_in_ha_version: str - remove_old_entity: bool = True @callback @@ -55,9 +52,8 @@ def async_finish_entity_domain_replacements( continue old_entity_id = registry_entry.entity_id - if strategy.remove_old_entity: - LOGGER.info('Removing old entity: "%s"', old_entity_id) - ent_reg.async_remove(old_entity_id) + LOGGER.info('Removing old entity: "%s"', old_entity_id) + ent_reg.async_remove(old_entity_id) class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): From 34a8812fc3b8b01682fe2b60d5089af98dddd76d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jan 2024 21:52:38 +0100 Subject: [PATCH 0250/1544] Introduce base entity in streamlabs water (#107095) --- .../streamlabswater/binary_sensor.py | 31 ++++--------- .../components/streamlabswater/entity.py | 31 +++++++++++++ .../components/streamlabswater/sensor.py | 19 ++------ .../components/streamlabswater/strings.json | 5 +++ .../snapshots/test_binary_sensor.ambr | 44 +++++++++++++++++++ .../streamlabswater/test_binary_sensor.py | 35 +++++++++++++++ 6 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/streamlabswater/entity.py create mode 100644 tests/components/streamlabswater/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/streamlabswater/test_binary_sensor.py diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index d0ca500ded4..efc0eb24dd7 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -5,13 +5,10 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import StreamlabsCoordinator from .const import DOMAIN -from .coordinator import StreamlabsData - -NAME_AWAY_MODE = "Water Away Mode" +from .entity import StreamlabsWaterEntity async def async_setup_entry( @@ -22,31 +19,19 @@ async def async_setup_entry( """Set up Streamlabs water binary sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - entities = [] - - for location_id in coordinator.data: - entities.append(StreamlabsAwayMode(coordinator, location_id)) - - async_add_entities(entities) + async_add_entities( + StreamlabsAwayMode(coordinator, location_id) for location_id in coordinator.data + ) -class StreamlabsAwayMode(CoordinatorEntity[StreamlabsCoordinator], BinarySensorEntity): +class StreamlabsAwayMode(StreamlabsWaterEntity, BinarySensorEntity): """Monitor the away mode state.""" + _attr_translation_key = "away_mode" + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the away mode device.""" - super().__init__(coordinator) - self._location_id = location_id - - @property - def location_data(self) -> StreamlabsData: - """Returns the data object.""" - return self.coordinator.data[self._location_id] - - @property - def name(self) -> str: - """Return the name for away mode.""" - return f"{self.location_data.name} {NAME_AWAY_MODE}" + super().__init__(coordinator, location_id, "away_mode") @property def is_on(self) -> bool: diff --git a/homeassistant/components/streamlabswater/entity.py b/homeassistant/components/streamlabswater/entity.py new file mode 100644 index 00000000000..4458523a07f --- /dev/null +++ b/homeassistant/components/streamlabswater/entity.py @@ -0,0 +1,31 @@ +"""Base entity for Streamlabs integration.""" +from homeassistant.core import DOMAIN +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import StreamlabsCoordinator, StreamlabsData + + +class StreamlabsWaterEntity(CoordinatorEntity[StreamlabsCoordinator]): + """Defines a base Streamlabs entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: StreamlabsCoordinator, + location_id: str, + key: str, + ) -> None: + """Initialize the Streamlabs entity.""" + super().__init__(coordinator) + self._location_id = location_id + self._attr_unique_id = f"{location_id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, location_id)}, name=self.location_data.name + ) + + @property + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 9faf3defa89..d9bb76814b5 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -12,14 +12,13 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import StreamlabsCoordinator from .const import DOMAIN from .coordinator import StreamlabsData +from .entity import StreamlabsWaterEntity @dataclass(frozen=True, kw_only=True) @@ -72,11 +71,9 @@ async def async_setup_entry( ) -class StreamLabsSensor(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): +class StreamLabsSensor(StreamlabsWaterEntity, SensorEntity): """Monitors the daily water usage.""" - _attr_has_entity_name = True - entity_description: StreamlabsWaterSensorEntityDescription def __init__( @@ -86,18 +83,8 @@ class StreamLabsSensor(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): entity_description: StreamlabsWaterSensorEntityDescription, ) -> None: """Initialize the daily water usage device.""" - super().__init__(coordinator) - self._location_id = location_id - self._attr_unique_id = f"{location_id}-{entity_description.key}" + super().__init__(coordinator, location_id, entity_description.key) self.entity_description = entity_description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, location_id)}, name=self.location_data.name - ) - - @property - def location_data(self) -> StreamlabsData: - """Returns the data object.""" - return self.coordinator.data[self._location_id] @property def native_value(self) -> StateType: diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index 393c2119501..204f7e831ef 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "binary_sensor": { + "away_mode": { + "name": "Away mode" + } + }, "sensor": { "daily_usage": { "name": "Daily usage" diff --git a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2ca9b802bf5 --- /dev/null +++ b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.water_monitor_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_monitor_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'streamlabswater', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'away_mode', + 'unique_id': '945e7c52-854a-41e1-8524-50c6993277e1-away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.water_monitor_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Monitor Away mode', + }), + 'context': , + 'entity_id': 'binary_sensor.water_monitor_away_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/streamlabswater/test_binary_sensor.py b/tests/components/streamlabswater/test_binary_sensor.py new file mode 100644 index 00000000000..4f533d91b55 --- /dev/null +++ b/tests/components/streamlabswater/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the Streamlabs Water binary sensor platform.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.streamlabswater import setup_integration + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + streamlabswater: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.streamlabswater.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) From 61eb6131283d97a519e2ed3f10208dc82d3385b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 11:21:01 -1000 Subject: [PATCH 0251/1544] Bump aiohomekit to 3.1.2 (#107177) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index edb81c14a72..4af79a6f811 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.1"], + "requirements": ["aiohomekit==3.1.2"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b4f133e0719..3021626b7a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.1 +aiohomekit==3.1.2 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ab5fe0223a..b10ea5f17cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.1 +aiohomekit==3.1.2 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From 0ff5ccb7fd15931b65ff124318c32e50dd50d046 Mon Sep 17 00:00:00 2001 From: Ash Hopkins Date: Thu, 4 Jan 2024 22:00:06 +0000 Subject: [PATCH 0252/1544] Update sensorpush-ble library to 1.6.1 (#107168) --- homeassistant/components/sensorpush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 708d9db03ee..2c6d929a3e4 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.5.5"] + "requirements": ["sensorpush-ble==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3021626b7a3..dda74055914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2464,7 +2464,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.1 # homeassistant.components.sentry sentry-sdk==1.37.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b10ea5f17cb..fbcc3f89a4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1856,7 +1856,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.5 +sensorpush-ble==1.6.1 # homeassistant.components.sentry sentry-sdk==1.37.1 From f5e7631e8496764674500a3480233d6970a7229e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 12:15:35 -1000 Subject: [PATCH 0253/1544] Fix tplink overloading power strips (#104208) --- homeassistant/components/tplink/__init__.py | 19 +++++++++-- .../components/tplink/coordinator.py | 15 ++------- .../components/tplink/diagnostics.py | 5 +-- homeassistant/components/tplink/entity.py | 2 +- homeassistant/components/tplink/light.py | 17 +++++----- homeassistant/components/tplink/models.py | 14 ++++++++ homeassistant/components/tplink/sensor.py | 33 ++++++++++++------- homeassistant/components/tplink/switch.py | 12 ++++--- 8 files changed, 73 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/tplink/models.py diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index f2a1e682304..4efd7ffdf0b 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -102,7 +103,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) - hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) + parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5)) + child_coordinators: list[TPLinkDataUpdateCoordinator] = [] + + if device.is_strip: + child_coordinators = [ + # The child coordinators only update energy data so we can + # set a longer update interval to avoid flooding the device + TPLinkDataUpdateCoordinator(hass, child, timedelta(seconds=60)) + for child in device.children + ] + + hass.data[DOMAIN][entry.entry_id] = TPLinkData( + parent_coordinator, child_coordinators + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -111,7 +125,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass_data: dict[str, Any] = hass.data[DOMAIN] - device: SmartDevice = hass_data[entry.entry_id].device + data: TPLinkData = hass_data[entry.entry_id] + device = data.parent_coordinator.device if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) await device.protocol.close() diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 97c8397831d..582c49638e7 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -22,11 +22,10 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): self, hass: HomeAssistant, device: SmartDevice, + update_interval: timedelta, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.update_children = True - update_interval = timedelta(seconds=5) super().__init__( hass, _LOGGER, @@ -39,19 +38,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ), ) - async def async_request_refresh_without_children(self) -> None: - """Request a refresh without the children.""" - # If the children do get updated this is ok as this is an - # optimization to reduce the number of requests on the device - # when we do not need it. - self.update_children = False - await self.async_request_refresh() - async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=self.update_children) + await self.device.update(update_children=False) except SmartDeviceException as ex: raise UpdateFailed(ex) from ex - finally: - self.update_children = True diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index c81356ee658..65646e8b858 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN -from .coordinator import TPLinkDataUpdateCoordinator +from .models import TPLinkData TO_REDACT = { # Entry fields @@ -36,7 +36,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + data: TPLinkData = hass.data[DOMAIN][entry.entry_id] + coordinator = data.parent_coordinator oui = format_mac(coordinator.device.mac)[:8].upper() return async_redact_data( {"device_last_response": coordinator.device.internal_state, "oui": oui}, diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 2df9a856083..84781597b93 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -24,7 +24,7 @@ def async_refresh_after( async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: await func(self, *args, **kwargs) - await self.coordinator.async_request_refresh_without_children() + await self.coordinator.async_request_refresh() return _async_wrap diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c4ec80347d5..94bb4d287bb 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -28,6 +28,7 @@ from . import legacy_device_id from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .models import TPLinkData _LOGGER = logging.getLogger(__name__) @@ -132,14 +133,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - if coordinator.device.is_light_strip: + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + if device.is_light_strip: async_add_entities( - [ - TPLinkSmartLightStrip( - cast(SmartLightStrip, coordinator.device), coordinator - ) - ] + [TPLinkSmartLightStrip(cast(SmartLightStrip, device), parent_coordinator)] ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -152,9 +151,9 @@ async def async_setup_entry( SEQUENCE_EFFECT_DICT, "async_set_sequence_effect", ) - elif coordinator.device.is_bulb or coordinator.device.is_dimmer: + elif device.is_bulb or device.is_dimmer: async_add_entities( - [TPLinkSmartBulb(cast(SmartBulb, coordinator.device), coordinator)] + [TPLinkSmartBulb(cast(SmartBulb, device), parent_coordinator)] ) diff --git a/homeassistant/components/tplink/models.py b/homeassistant/components/tplink/models.py new file mode 100644 index 00000000000..4367f46711d --- /dev/null +++ b/homeassistant/components/tplink/models.py @@ -0,0 +1,14 @@ +"""The tplink integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .coordinator import TPLinkDataUpdateCoordinator + + +@dataclass(slots=True) +class TPLinkData: + """Data for the tplink integration.""" + + parent_coordinator: TPLinkDataUpdateCoordinator + children_coordinators: list[TPLinkDataUpdateCoordinator] diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 4fd957c2d8f..e5f7ae332ec 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -33,6 +33,7 @@ from .const import ( ) from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity +from .models import TPLinkData @dataclass(frozen=True) @@ -106,31 +107,39 @@ def async_emeter_from_device( return None if device.is_bulb else 0.0 +def _async_sensors_for_device( + device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator +) -> list[SmartPlugSensor]: + """Generate the sensors for the device.""" + return [ + SmartPlugSensor(device, coordinator, description) + for description in ENERGY_SENSORS + if async_emeter_from_device(device, description) is not None + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + children_coordinators = data.children_coordinators entities: list[SmartPlugSensor] = [] - parent = coordinator.device + parent = parent_coordinator.device if not parent.has_emeter: return - def _async_sensors_for_device(device: SmartDevice) -> list[SmartPlugSensor]: - return [ - SmartPlugSensor(device, coordinator, description) - for description in ENERGY_SENSORS - if async_emeter_from_device(device, description) is not None - ] - if parent.is_strip: # Historically we only add the children if the device is a strip - for child in parent.children: - entities.extend(_async_sensors_for_device(child)) + for idx, child in enumerate(parent.children): + entities.extend( + _async_sensors_for_device(child, children_coordinators[idx]) + ) else: - entities.extend(_async_sensors_for_device(parent)) + entities.extend(_async_sensors_for_device(parent, parent_coordinator)) async_add_entities(entities) diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index fb812abc293..b1ca848260f 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -16,6 +16,7 @@ from . import legacy_device_id from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after +from .models import TPLinkData _LOGGER = logging.getLogger(__name__) @@ -26,8 +27,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - device = cast(SmartPlug, coordinator.device) + data: TPLinkData = hass.data[DOMAIN][config_entry.entry_id] + parent_coordinator = data.parent_coordinator + device = cast(SmartPlug, parent_coordinator.device) if not device.is_plug and not device.is_strip and not device.is_dimmer: return entities: list = [] @@ -35,11 +37,11 @@ async def async_setup_entry( # Historically we only add the children if the device is a strip _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) for child in device.children: - entities.append(SmartPlugSwitchChild(device, coordinator, child)) + entities.append(SmartPlugSwitchChild(device, parent_coordinator, child)) elif device.is_plug: - entities.append(SmartPlugSwitch(device, coordinator)) + entities.append(SmartPlugSwitch(device, parent_coordinator)) - entities.append(SmartPlugLedSwitch(device, coordinator)) + entities.append(SmartPlugLedSwitch(device, parent_coordinator)) async_add_entities(entities) From 9b8f0e1ee973e564e0d416227fe37eb6e0e06256 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 4 Jan 2024 23:36:36 +0100 Subject: [PATCH 0254/1544] Fix switch states in AVM FRITZ!Box Tools (#107183) --- homeassistant/components/fritz/common.py | 1 + homeassistant/components/fritz/switch.py | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 63f9f593ea8..bad73d91320 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1063,6 +1063,7 @@ class SwitchInfo(TypedDict): type: str callback_update: Callable callback_switch: Callable + init_state: bool class FritzBoxBaseEntity: diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 026c0f3d6fb..c3da6b5af0b 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -166,9 +166,7 @@ async def _async_wifi_entities_list( _LOGGER.debug("WiFi networks list: %s", networks) return [ - FritzBoxWifiSwitch( - avm_wrapper, device_friendly_name, index, data["switch_name"] - ) + FritzBoxWifiSwitch(avm_wrapper, device_friendly_name, index, data) for index, data in networks.items() ] @@ -310,18 +308,16 @@ class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity) await self._async_handle_turn_on_off(turn_on=False) -class FritzBoxBaseSwitch(FritzBoxBaseEntity): +class FritzBoxBaseSwitch(FritzBoxBaseEntity, SwitchEntity): """Fritz switch base class.""" - _attr_is_on: bool | None = False - def __init__( self, avm_wrapper: AvmWrapper, device_friendly_name: str, switch_info: SwitchInfo, ) -> None: - """Init Fritzbox port switch.""" + """Init Fritzbox base switch.""" super().__init__(avm_wrapper, device_friendly_name) self._description = switch_info["description"] @@ -330,6 +326,7 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity): self._type = switch_info["type"] self._update = switch_info["callback_update"] self._switch = switch_info["callback_switch"] + self._attr_is_on = switch_info["init_state"] self._name = f"{self._friendly_name} {self._description}" self._unique_id = f"{self._avm_wrapper.unique_id}-{slugify(self._description)}" @@ -381,7 +378,7 @@ class FritzBoxBaseSwitch(FritzBoxBaseEntity): self._attr_is_on = turn_on -class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxPortSwitch(FritzBoxBaseSwitch): """Defines a FRITZ!Box Tools PortForward switch.""" def __init__( @@ -412,6 +409,7 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): type=SWITCH_TYPE_PORTFORWARD, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, + init_state=port_mapping["NewEnabled"], ) super().__init__(avm_wrapper, device_friendly_name, switch_info) @@ -553,7 +551,7 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): return True -class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxWifiSwitch(FritzBoxBaseSwitch): """Defines a FRITZ!Box Tools Wifi switch.""" def __init__( @@ -561,7 +559,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): avm_wrapper: AvmWrapper, device_friendly_name: str, network_num: int, - network_name: str, + network_data: dict, ) -> None: """Init Fritz Wifi switch.""" self._avm_wrapper = avm_wrapper @@ -571,12 +569,13 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): self._network_num = network_num switch_info = SwitchInfo( - description=f"Wi-Fi {network_name}", + description=f"Wi-Fi {network_data['switch_name']}", friendly_name=device_friendly_name, icon="mdi:wifi", type=SWITCH_TYPE_WIFINETWORK, callback_update=self._async_fetch_update, callback_switch=self._async_switch_on_off_executor, + init_state=network_data["enabled"], ) super().__init__(self._avm_wrapper, device_friendly_name, switch_info) From 1a7b06f66a7832cbc4f4a79d155e60de91c7f6c2 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 4 Jan 2024 23:41:56 +0100 Subject: [PATCH 0255/1544] Bump to PyTado 0.17.3 (#107181) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 467697fc810..bae637f3180 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.0"] + "requirements": ["python-tado==0.17.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dda74055914..eaf70a24fa7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.17.0 +python-tado==0.17.3 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbcc3f89a4b..52610995ff6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1699,7 +1699,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.17.0 +python-tado==0.17.3 # homeassistant.components.telegram_bot python-telegram-bot==13.1 From 269500cb299bcadd3eb182e31cdd8c1951da2131 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 4 Jan 2024 17:09:20 -0600 Subject: [PATCH 0256/1544] Report missing entities/areas instead of failing to match in Assist (#107151) * Report missing entities/areas instead of failing * Fix test * Update assist pipeline test snapshots * Test complete match failure * Fix conflict --- .../components/conversation/default_agent.py | 94 +- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../assist_pipeline/snapshots/test_init.ambr | 40 +- .../snapshots/test_websocket.ambr | 44 +- .../conversation/snapshots/test_init.ambr | 926 +++++++++++++++++- .../conversation/test_default_agent.py | 54 +- tests/components/conversation/test_init.py | 640 ++---------- tests/helpers/test_intent.py | 2 +- 11 files changed, 1200 insertions(+), 608 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 19992e63dad..e66c246dc44 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -19,7 +19,12 @@ from hassil.intents import ( TextSlotList, WildcardSlotList, ) -from hassil.recognize import RecognizeResult, recognize_all +from hassil.recognize import ( + RecognizeResult, + UnmatchedEntity, + UnmatchedTextEntity, + recognize_all, +) from hassil.util import merge_dict from home_assistant_intents import get_domains_and_languages, get_intents import yaml @@ -213,6 +218,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents = self._lang_intents.get(language) if result is None: + # Intent was not recognized _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( language, @@ -221,6 +227,28 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) + if result.unmatched_entities: + # Intent was recognized, but not entity/area names, etc. + _LOGGER.debug( + "Recognized intent '%s' for template '%s' but had unmatched: %s", + result.intent.name, + result.intent_sentence.text + if result.intent_sentence is not None + else "", + result.unmatched_entities_list, + ) + error_response_type, error_response_args = _get_unmatched_response( + result.unmatched_entities + ) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) + # Will never happen because result will be None when no intents are # loaded in async_recognize. assert lang_intents is not None @@ -302,7 +330,35 @@ class DefaultAgent(AbstractConversationAgent): # Keep looking in case an entity has the same name maybe_result = result - return maybe_result + if maybe_result is not None: + # Successful strict match + return maybe_result + + # Try again with missing entities enabled + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if maybe_result is None: + # First result + maybe_result = result + elif len(result.unmatched_entities) < len(maybe_result.unmatched_entities): + # Fewer unmatched entities + maybe_result = result + elif len(result.unmatched_entities) == len(maybe_result.unmatched_entities): + if result.text_chunks_matched > maybe_result.text_chunks_matched: + # More literal text chunks matched + maybe_result = result + + if (maybe_result is not None) and maybe_result.unmatched_entities: + # Failed to match, but we have more information about why in unmatched_entities + return maybe_result + + # Complete match failure + return None async def _build_speech( self, @@ -655,15 +711,22 @@ class DefaultAgent(AbstractConversationAgent): return {"area": device_area.id} def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents | None + self, + response_type: ResponseType, + lang_intents: LanguageIntents | None, + **response_args, ) -> str: """Get response error text by type.""" if lang_intents is None: return _DEFAULT_ERROR_TEXT response_key = response_type.value - response_str = lang_intents.error_responses.get(response_key) - return response_str or _DEFAULT_ERROR_TEXT + response_str = ( + lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT + ) + response_template = template.Template(response_str, self.hass) + + return response_template.async_render(response_args) def register_trigger( self, @@ -783,6 +846,27 @@ def _make_error_result( return ConversationResult(response, conversation_id) +def _get_unmatched_response( + unmatched_entities: dict[str, UnmatchedEntity], +) -> tuple[ResponseType, dict[str, Any]]: + error_response_type = ResponseType.NO_INTENT + error_response_args: dict[str, Any] = {} + + if unmatched_name := unmatched_entities.get("name"): + # Unmatched device or entity + assert isinstance(unmatched_name, UnmatchedTextEntity) + error_response_type = ResponseType.NO_ENTITY + error_response_args["entity"] = unmatched_name.text + + elif unmatched_area := unmatched_entities.get("area"): + # Unmatched area + assert isinstance(unmatched_area, UnmatchedTextEntity) + error_response_type = ResponseType.NO_AREA + error_response_args["area"] = unmatched_area.text + + return error_response_type, error_response_args + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5f0c7b171ae..5de11d7a41a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.5.2", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac82851adc1..a392cb492ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.0.2 hass-nabucasa==0.75.1 -hassil==1.5.1 +hassil==1.5.2 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20240104.0 home-assistant-intents==2024.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index eaf70a24fa7..d1bf48801c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hass-nabucasa==0.75.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52610995ff6..15385290abb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -809,7 +809,7 @@ habluetooth==2.0.2 hass-nabucasa==0.75.1 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index e822759d208..128f5479077 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -48,14 +48,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -67,7 +67,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -75,9 +75,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , @@ -137,14 +137,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -156,7 +156,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -164,9 +164,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -226,14 +226,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -245,7 +245,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -253,9 +253,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -338,14 +338,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -357,7 +357,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -365,9 +365,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index c165675a6ff..31b1c44e67e 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -46,14 +46,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -64,16 +64,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -127,14 +127,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -145,16 +145,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -220,14 +220,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -238,16 +238,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -421,14 +421,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -439,16 +439,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -771,14 +771,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No area named are', }), }), }), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 7f928224aba..b68f2fb8701 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,4 +1,104 @@ # serializer version: 1 +# name: test_custom_agent + dict({ + 'conversation_id': 'test-conv-id', + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'test-language', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Test response', + }), + }), + }), + }) +# --- +# name: test_custom_sentences + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a stout', + }), + }), + }), + }) +# --- +# name: test_custom_sentences.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a lager', + }), + }), + }), + }) +# --- +# name: test_custom_sentences_config + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Stealth mode engaged', + }), + }), + }), + }) +# --- # name: test_get_agent_info dict({ 'id': 'homeassistant', @@ -225,6 +325,686 @@ ]), }) # --- +# name: test_http_api_handle_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'failed_to_handle', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred while handling the intent', + }), + }), + }), + }) +# --- +# name: test_http_api_no_match + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named do something', + }), + }), + }), + }) +# --- +# name: test_http_api_unexpected_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred while handling the intent', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_alias_added_removed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_alias_added_removed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_alias_added_removed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named late added alias', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_conversion_not_expose_new + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_conversion_not_expose_new.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_added_removed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named late added', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named my cool', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_exposed.5 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'renamed light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named kitchen', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_entity_renamed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No area named renamed', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent_target_ha_agent + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ 'conversation_id': None, @@ -339,7 +1119,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -369,7 +1149,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -399,7 +1179,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -429,7 +1209,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -465,6 +1245,126 @@ }), }) # --- +# name: test_ws_api[payload0] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload1] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload2] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload3] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload4] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload5] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- # name: test_ws_get_agent_info dict({ 'attribution': None, @@ -590,7 +1490,23 @@ }), }), }), - None, + dict({ + 'details': dict({ + 'domain': dict({ + 'name': 'domain', + 'text': '', + 'value': 'script', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'slots': dict({ + 'domain': 'script', + }), + 'targets': dict({ + }), + }), ]), }) # --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c68ec301280..4c1d395a2cc 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -57,7 +57,7 @@ async def test_hidden_entities_skipped( assert len(calls) == 0 assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: @@ -70,10 +70,10 @@ async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: hass, "turn on test media player", None, Context(), None ) - # This is an intent match failure instead of a handle failure because the - # media player domain is not exposed. + # This is a match failure instead of a handle failure because the media + # player domain is not exposed. assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_areas( @@ -127,9 +127,9 @@ async def test_exposed_areas( hass, "turn on lights in the bedroom", None, Context(), None ) - # This should be an intent match failure because the area isn't in the slot list + # This should be a match failure because the area isn't in the slot list assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_conversation_agent( @@ -417,6 +417,48 @@ async def test_device_area_context( result = await conversation.async_converse( hass, f"turn {command} all lights", None, Context(), None ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + + +async def test_error_missing_entity(hass: HomeAssistant, init_components) -> None: + """Test error message when entity is missing.""" + result = await conversation.async_converse( + hass, "turn on missing entity", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "No device or entity named missing entity" + ) + + +async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: + """Test error message when area is missing.""" + result = await conversation.async_converse( + hass, "turn on the lights in missing area", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.speech["plain"]["speech"] == "No area named missing area" + + +async def test_error_match_failure(hass: HomeAssistant, init_components) -> None: + """Test response with complete match failure.""" + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[], + ): + result = await conversation.async_converse( + hass, "do something", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR assert ( result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 820734901ad..b3167d979d5 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -58,6 +58,7 @@ async def test_http_processing_intent( hass_admin_user: MockUser, agent_id, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API.""" # Add an alias @@ -78,27 +79,7 @@ async def test_http_processing_intent( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot async def test_http_processing_intent_target_ha_agent( @@ -108,6 +89,7 @@ async def test_http_processing_intent_target_ha_agent( hass_admin_user: MockUser, mock_agent, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent can be processed via HTTP API with picking agent.""" # Add an alias @@ -127,28 +109,8 @@ async def test_http_processing_intent_target_ha_agent( assert resp.status == HTTPStatus.OK assert len(calls) == 1 data = await resp.json() - - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_entity_added_removed( @@ -157,6 +119,7 @@ async def test_http_processing_intent_entity_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities added later. @@ -179,27 +142,8 @@ async def test_http_processing_intent_entity_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an entity entity_registry.async_get_or_create( @@ -215,27 +159,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now add an alias entity_registry.async_update_entity("light.late", aliases={"late added light"}) @@ -248,27 +173,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now delete the entity hass.states.async_remove("light.late") @@ -280,21 +186,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_alias_added_removed( @@ -303,6 +196,7 @@ async def test_http_processing_intent_alias_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with aliases added later. @@ -324,27 +218,8 @@ async def test_http_processing_intent_alias_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an alias entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"}) @@ -357,27 +232,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now remove the alieas entity_registry.async_update_entity("light.kitchen", aliases={}) @@ -389,21 +245,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_renamed( @@ -413,6 +256,7 @@ async def test_http_processing_intent_entity_renamed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities renamed later. @@ -442,27 +286,8 @@ async def test_http_processing_intent_entity_renamed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Rename the entity entity_registry.async_update_entity("light.kitchen", name="renamed light") @@ -476,27 +301,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "renamed light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -505,21 +311,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now clear the custom name entity_registry.async_update_entity("light.kitchen", name=None) @@ -533,27 +326,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -562,21 +336,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_exposed( @@ -586,6 +347,7 @@ async def test_http_processing_intent_entity_exposed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with manual expose. @@ -617,27 +379,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") client = await hass_client() @@ -649,27 +392,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Unexpose the entity expose_entity(hass, "light.kitchen", False) @@ -682,21 +406,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" client = await hass_client() resp = await client.post( @@ -705,21 +416,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now expose the entity expose_entity(hass, "light.kitchen", True) @@ -733,27 +431,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -762,27 +441,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_conversion_not_expose_new( @@ -792,6 +452,7 @@ async def test_http_processing_intent_conversion_not_expose_new( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API when not exposing new entities.""" # Disable exposing new entities to the default agent @@ -820,21 +481,8 @@ async def test_http_processing_intent_conversion_not_expose_new( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Expose the entity expose_entity(hass, "light.kitchen", True) @@ -848,27 +496,8 @@ async def test_http_processing_intent_conversion_not_expose_new( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -936,7 +565,10 @@ async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) - async def test_http_api_no_match( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an intent match failure.""" client = await hass_client() @@ -947,25 +579,15 @@ async def test_http_api_no_match( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - }, - }, - "language": hass.config.language, - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_api_handle_failure( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an error during handling.""" client = await hass_client() @@ -984,29 +606,16 @@ async def test_http_api_handle_failure( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "failed_to_handle", - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "failed_to_handle" async def test_http_api_unexpected_failure( hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an unexpected error during handling.""" client = await hass_client() @@ -1025,23 +634,9 @@ async def test_http_api_unexpected_failure( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "unknown", - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "unknown" async def test_http_api_wrong_data( @@ -1062,6 +657,7 @@ async def test_custom_agent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, + snapshot: SnapshotAssertion, ) -> None: """Test a custom conversation agent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1079,21 +675,11 @@ async def test_custom_agent( resp = await client.post("/api/conversation/process", json=data) assert resp.status == HTTPStatus.OK - assert await resp.json() == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Test response", - } - }, - "language": "test-language", - "data": {"targets": [], "success": [], "failed": []}, - }, - "conversation_id": "test-conv-id", - } + data = await resp.json() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Test response" + assert data["conversation_id"] == "test-conv-id" assert len(mock_agent.calls) == 1 assert mock_agent.calls[0].text == "Test Text" @@ -1136,7 +722,10 @@ async def test_custom_agent( ], ) async def test_ws_api( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + payload, + snapshot: SnapshotAssertion, ) -> None: """Test the Websocket conversation API.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1148,21 +737,7 @@ async def test_ws_api( msg = await client.receive_json() assert msg["success"] - assert msg["result"] == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - "language": payload.get("language", hass.config.language), - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert msg["result"] == snapshot @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -1198,7 +773,10 @@ async def test_ws_prepare( async def test_custom_sentences( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1223,30 +801,19 @@ async def test_custom_sentences( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": f"You ordered a {beer_style}", - } - }, - "language": language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert ( + data["response"]["speech"]["plain"]["speech"] + == f"You ordered a {beer_style}" + ) async def test_custom_sentences_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent in config.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1274,26 +841,9 @@ async def test_custom_sentences_config( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Stealth mode engaged", - } - }, - "language": hass.config.language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Stealth mode engaged" async def test_prepare_reload(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 8d473338058..0486211417c 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -192,7 +192,7 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: ) assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS def test_async_register(hass: HomeAssistant) -> None: From 72e908f6cca95251b6a08e38d71cfdefb071d300 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 00:41:52 +0100 Subject: [PATCH 0257/1544] Fix conversation snapshots (#107196) From 2a9a046fab2ff3cde2ede62a50c253d5454b62de Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 4 Jan 2024 17:07:15 -0800 Subject: [PATCH 0258/1544] Disable IPv6 in the opower integration to fix AEP utilities (#107203) --- homeassistant/components/opower/config_flow.py | 3 ++- homeassistant/components/opower/coordinator.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index d456fc536e5..ab1fbbe36e3 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping import logging +import socket from typing import Any from opower import ( @@ -38,7 +39,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass), + async_create_clientsession(hass, family=socket.AF_INET), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index a474255e34d..73c60068cd4 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -1,6 +1,7 @@ """Coordinator to handle Opower connections.""" from datetime import datetime, timedelta import logging +import socket from types import MappingProxyType from typing import Any, cast @@ -51,7 +52,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass), + aiohttp_client.async_get_clientsession(hass, family=socket.AF_INET), entry_data[CONF_UTILITY], entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], From d67c8bb44f81b0c5d17287805491f7aadcd56300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 19:54:46 -1000 Subject: [PATCH 0259/1544] Bump bluetooth-adapters to 0.17.0 (#107195) changelog: https://github.com/Bluetooth-Devices/bluetooth-adapters/compare/v0.16.2...v0.17.0 related https://github.com/home-assistant/operating-system/issues/2944 --- 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 7308f3a83ff..e7145d0385a 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.4.0", - "bluetooth-adapters==0.16.2", + "bluetooth-adapters==0.17.0", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a392cb492ff..32189d875a2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ awesomeversion==23.11.0 bcrypt==4.0.1 bleak-retry-connector==3.4.0 bleak==0.21.1 -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index d1bf48801c4..023ddd084a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -563,7 +563,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15385290abb..888906f875f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -475,7 +475,7 @@ bluecurrent-api==1.0.6 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.2 +bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 From 00ff93a69ea5e31fe2c9460c1ba168fa7ade18dc Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 5 Jan 2024 07:03:28 +0100 Subject: [PATCH 0260/1544] Set zwave_js voltage sensor suggested precision (#107116) --- homeassistant/components/zwave_js/sensor.py | 1 + tests/components/zwave_js/test_sensor.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 798d4bf92bc..9e95d430a4c 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -128,6 +128,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, ), ( ENTITY_DESC_KEY_VOLTAGE, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 390d9631f23..0fe3e32043b 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -165,7 +165,10 @@ async def test_invalid_multilevel_sensor_scale( async def test_energy_sensors( - hass: HomeAssistant, hank_binary_switch, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hank_binary_switch, + integration, ) -> None: """Test power and energy sensors.""" state = hass.states.get(POWER_SENSOR) @@ -191,6 +194,13 @@ async def test_energy_sensors( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfElectricPotential.VOLT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.VOLTAGE + entity_entry = entity_registry.async_get(VOLTAGE_SENSOR) + + assert entity_entry is not None + sensor_options = entity_entry.options.get("sensor") + assert sensor_options is not None + assert sensor_options["suggested_display_precision"] == 0 + state = hass.states.get(CURRENT_SENSOR) assert state From 8017661d313e9d44b7030d6f93d2d074bcf97577 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 21:21:26 -1000 Subject: [PATCH 0261/1544] Change default python version to 3.12 for image builds (#107209) --- .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 4a767d234b5..89d9a69ed03 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,7 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.11" + DEFAULT_PYTHON: "3.12" jobs: init: From 298e2e2b99d18bbab13a8d1167fcbc5e3835c594 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 21:23:04 -1000 Subject: [PATCH 0262/1544] Attempt to fix 32bit docker builds (#107210) --- Dockerfile | 11 +++++++++-- script/hassfest/docker.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 43b21ab3ba8..da46f71ad22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,12 +28,19 @@ RUN \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ - && \ + && if [ "${BUILD_ARCH}" = "i386" ]; then \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + linux32 pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt; \ + else \ LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --only-binary=:all: \ - -r homeassistant/requirements_all.txt + -r homeassistant/requirements_all.txt; \ + fi ## Setup Home Assistant Core COPY . homeassistant/ diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index c9d81424229..2856c1ee0ea 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -34,12 +34,19 @@ RUN \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ - && \ + && if [ "${{BUILD_ARCH}}" = "i386" ]; then \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + linux32 pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt; \ + else \ LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --only-binary=:all: \ - -r homeassistant/requirements_all.txt + -r homeassistant/requirements_all.txt; \ + fi ## Setup Home Assistant Core COPY . homeassistant/ From ace4edf91c2d7ee4fc5e36e4e703957034745a82 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 5 Jan 2024 17:23:43 +1000 Subject: [PATCH 0263/1544] Hotfix cache logic bug in Tessie (#107187) --- homeassistant/components/tessie/coordinator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index c2f53da53bc..75cac088bde 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -41,7 +41,6 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.vin = vin self.session = async_get_clientsession(hass) self.data = self._flatten(data) - self.did_first_update = False async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" @@ -50,7 +49,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): session=self.session, api_key=self.api_key, vin=self.vin, - use_cache=self.did_first_update, + use_cache=False, ) except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: @@ -58,7 +57,6 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from e raise e - self.did_first_update = True if vehicle["state"] == TessieStatus.ONLINE: # Vehicle is online, all data is fresh return self._flatten(vehicle) From 4a2958baeb8c7f91aaa75cd5c46a09f703cd34b1 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Fri, 5 Jan 2024 08:18:25 +0000 Subject: [PATCH 0264/1544] Fix entity property cache creation arguments (#107221) --- homeassistant/helpers/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index ea0267b21db..55a32670288 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -299,7 +299,7 @@ class CachedProperties(type): Pop cached_properties and store it in the namespace. """ namespace["_CachedProperties__cached_properties"] = cached_properties or set() - return super().__new__(mcs, name, bases, namespace) + return super().__new__(mcs, name, bases, namespace, **kwargs) def __init__( cls, From c7b6c9da318c0825884e577f150b7a74d87f6f65 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 5 Jan 2024 09:24:52 +0100 Subject: [PATCH 0265/1544] Remove work-a-round for mqtt sensors with an entity_category set to `config` (#107199) * Remove work-a-round for mqtt sensors with an entity_category set to `config` * Cleanup strings --- .../components/mqtt/binary_sensor.py | 13 +-- homeassistant/components/mqtt/mixins.py | 59 +---------- homeassistant/components/mqtt/sensor.py | 3 - homeassistant/components/mqtt/strings.json | 4 - tests/components/mqtt/test_init.py | 100 +----------------- 5 files changed, 4 insertions(+), 175 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 9143b804c60..7ab2e9ebf90 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -42,7 +42,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -56,7 +55,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, @@ -68,15 +67,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = vol.All( - validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=True), - _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), -) - -PLATFORM_SCHEMA_MODERN = vol.All( - validate_sensor_entity_category(binary_sensor.DOMAIN, discovery=False), - _PLATFORM_SCHEMA_BASE, -) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 412664ceedf..8047af17050 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -27,9 +27,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -207,62 +206,6 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType ) -def validate_sensor_entity_category( - domain: str, discovery: bool -) -> Callable[[ConfigType], ConfigType]: - """Check the sensor's entity category is not set to `config` which is invalid for sensors.""" - - # A guard was added to the core sensor platform with HA core 2023.11.0 - # See: https://github.com/home-assistant/core/pull/101471 - # A developers blog from october 2021 explains the correct uses of the entity category - # See: - # https://developers.home-assistant.io/blog/2021/10/26/config-entity/?_highlight=entity_category#entity-categories - # - # To limitate the impact of the change we use a grace period - # of 3 months for user to update there configs. - - def _validate(config: ConfigType) -> ConfigType: - if ( - CONF_ENTITY_CATEGORY in config - and config[CONF_ENTITY_CATEGORY] == EntityCategory.CONFIG - ): - config_str: str - if not discovery: - config_str = yaml_dump(config) - config.pop(CONF_ENTITY_CATEGORY) - _LOGGER.warning( - "Entity category `config` is invalid for sensors, ignoring. " - "This stops working from HA Core 2024.2.0" - ) - # We only open an issue if the user can fix it - if discovery: - return config - config_file = getattr(config, "__config_file__", "?") - line = getattr(config, "__line__", "?") - hass = async_get_hass() - async_create_issue( - hass, - domain=DOMAIN, - issue_id="invalid_entity_category", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="invalid_entity_category", - learn_more_url=( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ), - translation_placeholders={ - "domain": domain, - "config": config_str, - "config_file": config_file, - "line": line, - }, - ) - return config - - return _validate - - MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), vol.Schema( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 2c173f801fa..9d1ed964be3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -44,7 +44,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entity_entry_helper, - validate_sensor_entity_category, write_state_on_attr_change, ) from .models import ( @@ -88,7 +87,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category(sensor.DOMAIN, discovery=False), _PLATFORM_SCHEMA_BASE, ) @@ -96,7 +94,6 @@ DISCOVERY_SCHEMA = vol.All( # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 # Removed in HA Core 2023.6.0 cv.removed(CONF_LAST_RESET_TOPIC), - validate_sensor_entity_category(sensor.DOMAIN, discovery=True), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fac2f32d284..3194806a221 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -20,10 +20,6 @@ "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." }, - "invalid_entity_category": { - "title": "An MQTT {domain} with an invalid entity category was found", - "description": "Home Assistant detected a manually configured MQTT `{domain}` entity that has an `entity_category` set to `config`. \nConfiguration file: **{config_file}**\nNear line: **{line}**\n\nConfig with invalid setting:\n\n```yaml\n{config}\n```\n\nWhen set, make sure `entity_category` for a `{domain}` is set to `diagnostic` or `None`. Update your YAML configuration and restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 98e2c9b71fe..3d7b349712e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -31,12 +31,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, - template, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType @@ -49,7 +44,6 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, MockEntity, - async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -2159,98 +2153,6 @@ async def test_setup_manual_mqtt_with_invalid_config( assert "required key not provided" in caplog.text -@pytest.mark.parametrize( - ("hass_config", "entity_id"), - [ - ( - { - mqtt.DOMAIN: { - "sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, - "sensor.test", - ), - ( - { - mqtt.DOMAIN: { - "binary_sensor": { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - } - } - }, - "binary_sensor.test", - ), - ], -) -@patch( - "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] -) -async def test_setup_manual_mqtt_with_invalid_entity_category( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - entity_id: str, -) -> None: - """Test set up a manual sensor item with an invalid entity category.""" - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - assert await mqtt_mock_entry() - assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text - state = hass.states.get(entity_id) - assert state is not None - assert len(events) == 1 - - -@pytest.mark.parametrize( - ("config", "entity_id"), - [ - ( - { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - }, - "binary_sensor.test", - ), - ( - { - "name": "test", - "state_topic": "test-topic", - "entity_category": "config", - }, - "sensor.test", - ), - ], -) -@patch( - "homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR, Platform.SENSOR] -) -async def test_setup_discovery_mqtt_with_invalid_entity_category( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - config: dict[str, Any], - entity_id: str, -) -> None: - """Test set up a discovered sensor item with an invalid entity category.""" - events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - assert await mqtt_mock_entry() - - domain = entity_id.split(".")[0] - json_config = json.dumps(config) - async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", json_config) - await hass.async_block_till_done() - assert "Entity category `config` is invalid for sensors, ignoring" in caplog.text - state = hass.states.get(entity_id) - assert state is not None - assert len(events) == 0 - - @patch("homeassistant.components.mqtt.PLATFORMS", []) @pytest.mark.parametrize( ("mqtt_config_entry_data", "protocol"), From 8c4a29c2008d8a20cee1b6049eb23f0c43821d24 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 5 Jan 2024 09:27:48 +0100 Subject: [PATCH 0266/1544] Remove unneeded preset_mode checks for mqtt climate (#107190) --- homeassistant/components/mqtt/climate.py | 26 +++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 65ffd4d17c0..c3e3448da0a 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -989,23 +989,17 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set a preset mode.""" - if self._feature_preset_mode and self.preset_modes: - if preset_mode not in self.preset_modes and preset_mode is not PRESET_NONE: - _LOGGER.warning("'%s' is not a valid preset mode", preset_mode) - return - mqtt_payload = self._command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE]( - preset_mode - ) - await self._publish( - CONF_PRESET_MODE_COMMAND_TOPIC, - mqtt_payload, - ) + mqtt_payload = self._command_templates[CONF_PRESET_MODE_COMMAND_TEMPLATE]( + preset_mode + ) + await self._publish( + CONF_PRESET_MODE_COMMAND_TOPIC, + mqtt_payload, + ) - if self._optimistic_preset_mode: - self._attr_preset_mode = preset_mode - self.async_write_ha_state() - - return + if self._optimistic_preset_mode: + self._attr_preset_mode = preset_mode + self.async_write_ha_state() # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 From f0ec1235b1f65e985393ddeaea1df439849e3612 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 5 Jan 2024 09:32:22 +0100 Subject: [PATCH 0267/1544] Remove naming warnings and work-a-rounds for incorrectly configured MQTT entities (#107188) * Remove naming warnings for MQTT entities * Remove unused const --- homeassistant/components/mqtt/client.py | 25 ---------- homeassistant/components/mqtt/mixins.py | 54 +++------------------- homeassistant/components/mqtt/models.py | 1 - homeassistant/components/mqtt/strings.json | 8 ---- tests/components/mqtt/test_mixins.py | 29 ++++-------- 5 files changed, 15 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index c87d4c9244a..e9ef92ddbf8 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -35,7 +35,6 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -94,10 +93,6 @@ SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 -MQTT_ENTRIES_NAMING_BLOG_URL = ( - "https://developers.home-assistant.io/blog/2023-057-21-change-naming-mqtt-entities/" -) - SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -425,7 +420,6 @@ class MQTT: @callback def ha_started(_: Event) -> None: - self.register_naming_issues() self._ha_started.set() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) @@ -438,25 +432,6 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) - def register_naming_issues(self) -> None: - """Register issues with MQTT entity naming.""" - mqtt_data = get_mqtt_data(self.hass) - for issue_key, items in mqtt_data.issues.items(): - config_list = "\n".join([f"- {item}" for item in items]) - async_create_issue( - self.hass, - DOMAIN, - issue_key, - breaks_in_ha_version="2024.2.0", - is_fixable=False, - translation_key=issue_key, - translation_placeholders={ - "config": config_list, - }, - learn_more_url=MQTT_ENTRIES_NAMING_BLOG_URL, - severity=IssueSeverity.WARNING, - ) - def start( self, mqtt_data: MqttData, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 8047af17050..1cb12930b5a 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1158,7 +1158,6 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str - _issue_key: str | None def __init__( self, @@ -1196,7 +1195,6 @@ class MqttEntity( @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" - self.collect_issues() await super().async_added_to_hass() self._prepare_subscribe_topics() await self._subscribe_topics() @@ -1269,7 +1267,6 @@ class MqttEntity( def _set_entity_name(self, config: ConfigType) -> None: """Help setting the entity name if needed.""" - self._issue_key = None entity_name: str | None | UndefinedType = config.get(CONF_NAME, UNDEFINED) # Only set _attr_name if it is needed if entity_name is not UNDEFINED: @@ -1282,50 +1279,13 @@ class MqttEntity( # don't set the name attribute and derive # the name from the device_class delattr(self, "_attr_name") - if CONF_DEVICE in config: - device_name: str - if CONF_NAME not in config[CONF_DEVICE]: - _LOGGER.info( - "MQTT device information always needs to include a name, got %s, " - "if device information is shared between multiple entities, the device " - "name must be included in each entity's device configuration", - config, - ) - elif (device_name := config[CONF_DEVICE][CONF_NAME]) == entity_name: - self._attr_name = None - if not self._discovery: - self._issue_key = "entity_name_is_device_name_yaml" - _LOGGER.warning( - "MQTT device name is equal to entity name in your config %s, " - "this is not expected. Please correct your configuration. " - "The entity name will be set to `null`", - config, - ) - elif isinstance(entity_name, str) and entity_name.startswith(device_name): - self._attr_name = ( - new_entity_name := entity_name[len(device_name) :].lstrip() - ) - if device_name[:1].isupper(): - # Ensure a capital if the device name first char is a capital - new_entity_name = new_entity_name[:1].upper() + new_entity_name[1:] - if not self._discovery: - self._issue_key = "entity_name_startswith_device_name_yaml" - _LOGGER.warning( - "MQTT entity name starts with the device name in your config %s, " - "this is not expected. Please correct your configuration. " - "The device name prefix will be stripped off the entity name " - "and becomes '%s'", - config, - new_entity_name, - ) - - def collect_issues(self) -> None: - """Process issues for MQTT entities.""" - if self._issue_key is None: - return - mqtt_data = get_mqtt_data(self.hass) - issues = mqtt_data.issues.setdefault(self._issue_key, set()) - issues.add(self.entity_id) + if CONF_DEVICE in config and CONF_NAME not in config[CONF_DEVICE]: + _LOGGER.info( + "MQTT device information always needs to include a name, got %s, " + "if device information is shared between multiple entities, the device " + "name must be included in each entity's device configuration", + config, + ) def _setup_common_attributes_from_config(self, config: ConfigType) -> None: """(Re)Setup the common attributes for the entity.""" diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 63b8d537170..0d009cf356b 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -339,7 +339,6 @@ class MqttData: ) discovery_unsubscribe: list[CALLBACK_TYPE] = field(default_factory=list) integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) - issues: dict[str, set[str]] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) reload_handlers: dict[str, CALLBACK_TYPE] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3194806a221..5cd7676115b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -8,14 +8,6 @@ "title": "MQTT vacuum entities with legacy schema added through MQTT discovery", "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your devices to use the correct schema and restart Home Assistant to fix this issue." }, - "entity_name_is_device_name_yaml": { - "title": "Manual configured MQTT entities with a name that is equal to the device name", - "description": "Some MQTT entities have an entity name equal to the device name. This is not expected. The entity name is set to `null` as a work-a-round to avoid a duplicate name. Please update your configuration and restart Home Assistant to fix this issue.\n\nList of affected entities:\n\n{config}" - }, - "entity_name_startswith_device_name_yaml": { - "title": "Manual configured MQTT entities with a name that starts with the device name", - "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped off the entity name as a work-a-round. Please update your configuration and restart Home Assistant to fix this issue. \n\nList of affected entities:\n\n{config}" - }, "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 7a625a2f5f6..3c25d419cfe 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -89,7 +89,6 @@ async def test_availability_with_shared_state_topic( "friendly_name", "device_name", "assert_log", - "issue_events", ), [ ( # default_entity_name_without_device_name @@ -106,7 +105,6 @@ async def test_availability_with_shared_state_topic( DEFAULT_SENSOR_NAME, None, True, - 0, ), ( # default_entity_name_with_device_name { @@ -122,7 +120,6 @@ async def test_availability_with_shared_state_topic( "Test MQTT Sensor", "Test", False, - 0, ), ( # name_follows_device_class { @@ -139,7 +136,6 @@ async def test_availability_with_shared_state_topic( "Test Humidity", "Test", False, - 0, ), ( # name_follows_device_class_without_device_name { @@ -156,7 +152,6 @@ async def test_availability_with_shared_state_topic( "Humidity", None, True, - 0, ), ( # name_overrides_device_class { @@ -174,7 +169,6 @@ async def test_availability_with_shared_state_topic( "Test MySensor", "Test", False, - 0, ), ( # name_set_no_device_name_set { @@ -192,7 +186,6 @@ async def test_availability_with_shared_state_topic( "MySensor", None, True, - 0, ), ( # none_entity_name_with_device_name { @@ -210,7 +203,6 @@ async def test_availability_with_shared_state_topic( "Test", "Test", False, - 0, ), ( # none_entity_name_without_device_name { @@ -228,7 +220,6 @@ async def test_availability_with_shared_state_topic( "mqtt veryunique", None, True, - 0, ), ( # entity_name_and_device_name_the_same { @@ -245,11 +236,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.hello_world", - "Hello world", + "sensor.hello_world_hello_world", + "Hello world Hello world", "Hello world", False, - 1, ), ( # entity_name_startswith_device_name1 { @@ -266,11 +256,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.world_automation", - "World automation", + "sensor.world_world_automation", + "World World automation", "World", False, - 1, ), ( # entity_name_startswith_device_name2 { @@ -287,11 +276,10 @@ async def test_availability_with_shared_state_topic( } } }, - "sensor.world_automation", - "world automation", + "sensor.world_world_automation", + "world world automation", "world", False, - 1, ), ], ids=[ @@ -320,7 +308,6 @@ async def test_default_entity_and_device_name( friendly_name: str, device_name: str | None, assert_log: bool, - issue_events: int, ) -> None: """Test device name setup with and without a device_class set. @@ -349,8 +336,8 @@ async def test_default_entity_and_device_name( "MQTT device information always needs to include a name" in caplog.text ) is assert_log - # Assert that an issues ware registered - assert len(events) == issue_events + # Assert that no issues ware registered + assert len(events) == 0 @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) From 2641e4014a617739bbf220d013b0897a6002f7fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 4 Jan 2024 22:50:26 -1000 Subject: [PATCH 0268/1544] Add color temp support for older HomeKit devices (#107206) --- .../components/homekit_controller/light.py | 41 +++++++++---- .../snapshots/test_diagnostics.ambr | 14 +++++ .../snapshots/test_init.ambr | 60 +++++++++++++++++++ .../test_light_that_changes_features.py | 5 +- .../homekit_controller/test_light.py | 45 +++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index f1d36c02933..fd3bf4f800b 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.color as color_util from . import KNOWN_DEVICES from .connection import HKDevice @@ -94,12 +95,16 @@ class HomeKitLight(HomeKitEntity, LightEntity): @cached_property def min_mireds(self) -> int: """Return minimum supported color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().min_mireds min_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].minValue return int(min_value) if min_value else super().min_mireds @cached_property def max_mireds(self) -> int: """Return the maximum color temperature.""" + if not self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + return super().max_mireds max_value = self.service[CharacteristicsTypes.COLOR_TEMPERATURE].maxValue return int(max_value) if max_value else super().max_mireds @@ -135,8 +140,9 @@ class HomeKitLight(HomeKitEntity, LightEntity): CharacteristicsTypes.SATURATION ): color_modes.add(ColorMode.HS) + color_modes.add(ColorMode.COLOR_TEMP) - if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + elif self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) if not color_modes and self.service.has(CharacteristicsTypes.BRIGHTNESS): @@ -153,23 +159,36 @@ class HomeKitLight(HomeKitEntity, LightEntity): temperature = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - characteristics = {} - - if hs_color is not None: - characteristics.update( - { - CharacteristicsTypes.HUE: hs_color[0], - CharacteristicsTypes.SATURATION: hs_color[1], - } - ) + characteristics: dict[str, Any] = {} if brightness is not None: characteristics[CharacteristicsTypes.BRIGHTNESS] = int( brightness * 100 / 255 ) + # If they send both temperature and hs_color, and the device + # does not support both, temperature will win. This is not + # expected to happen in the UI, but it is possible via a manual + # service call. if temperature is not None: - characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature) + if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int( + temperature + ) + elif hs_color is None: + # Some HomeKit devices implement color temperature with HS + # since the spec "technically" does not permit the COLOR_TEMPERATURE + # characteristic and the HUE and SATURATION characteristics to be + # present at the same time. + hue_sat = color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(temperature) + ) + characteristics[CharacteristicsTypes.HUE] = hue_sat[0] + characteristics[CharacteristicsTypes.SATURATION] = hue_sat[1] + + if hs_color is not None: + characteristics[CharacteristicsTypes.HUE] = hs_color[0] + characteristics[CharacteristicsTypes.SATURATION] = hs_color[1] characteristics[CharacteristicsTypes.ON] = True diff --git a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr index d3205b09de3..bda92943cce 100644 --- a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -43,10 +43,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, @@ -360,10 +367,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + 'color_temp', 'hs', ]), 'supported_features': 0, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4b4ffeb9aa3..2f38229aef8 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1626,7 +1626,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -1656,10 +1661,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -2014,7 +2026,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -2044,10 +2061,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'ArloBabyA0 Nightlight', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -9279,7 +9303,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -9309,10 +9338,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Laundry Smoke ED78', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -11535,7 +11571,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -11565,10 +11606,17 @@ 'attributes': dict({ 'brightness': None, 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', 'hs_color': None, + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': None, 'supported_color_modes': list([ + , , ]), 'supported_features': , @@ -16318,7 +16366,12 @@ ]), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ + , , ]), }), @@ -16348,17 +16401,24 @@ 'attributes': dict({ 'brightness': 127.5, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'hs_color': tuple( 120.0, 100.0, ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'rgb_color': tuple( 0, 255, 0, ), 'supported_color_modes': list([ + , , ]), 'supported_features': , diff --git a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py index 54dc900c130..4e62c75d8f2 100644 --- a/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_light_that_changes_features.py @@ -39,4 +39,7 @@ async def test_light_add_feature_at_runtime( await device_config_changed(hass, accessories) light_state = hass.states.get("light.laundry_smoke_ed78") - assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert light_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 72bf579b36e..c7f168b2abe 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -74,6 +74,22 @@ async def test_switch_change_light_state(hass: HomeAssistant) -> None: }, ) + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.testdevice", "brightness": 255, "color_temp": 300}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.BRIGHTNESS: 100, + CharacteristicsTypes.HUE: 27, + CharacteristicsTypes.SATURATION: 49, + }, + ) + await hass.services.async_call( "light", "turn_off", {"entity_id": "light.testdevice"}, blocking=True ) @@ -176,7 +192,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: state = await helper.poll_and_get_state() assert state.state == "off" assert state.attributes[ATTR_COLOR_MODE] is None - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that someone switched on the device in the real world not via HA @@ -193,7 +212,10 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: assert state.attributes["brightness"] == 255 assert state.attributes["hs_color"] == (4, 5) assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Simulate that device switched off in the real world not via HA @@ -205,6 +227,25 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: ) assert state.state == "off" + # Simulate that device switched on in the real world not via HA + state = await helper.async_update( + ServicesTypes.LIGHTBULB, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.HUE: 6, + CharacteristicsTypes.SATURATION: 7, + }, + ) + assert state.state == "on" + assert state.attributes["brightness"] == 255 + assert state.attributes["hs_color"] == (6, 7) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" From 6da82cf07e418cb42d7b0a90add04d5b1b7b82c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 5 Jan 2024 10:38:54 +0100 Subject: [PATCH 0269/1544] Use supported_features_compat in update.install service (#107224) --- homeassistant/components/update/__init__.py | 4 +- tests/components/update/test_init.py | 72 +++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 40431332aaf..8ec14b6e3a8 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -140,7 +140,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features_compat ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -149,7 +149,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and UpdateEntityFeature.BACKUP not in entity.supported_features: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features_compat: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 92e63af4b6f..67661d6936e 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -885,3 +885,75 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> caplog.clear() assert entity.supported_features_compat is UpdateEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + +async def test_deprecated_supported_features_ints_with_service_call( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated supported features ints with install service.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + class MockUpdateEntity(UpdateEntity): + _attr_supported_features = 1 | 2 + + def install(self, version: str | None = None, backup: bool = False) -> None: + """Install an update.""" + + entity = MockUpdateEntity() + entity.entity_id = ( + "update.test_deprecated_supported_features_ints_with_service_call" + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test update platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert "is using deprecated supported features values" in caplog.text + + assert isinstance(entity.supported_features, int) + + with pytest.raises( + HomeAssistantError, + match="Backup is not supported for update.test_deprecated_supported_features_ints_with_service_call", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_VERSION: "0.9.9", + ATTR_BACKUP: True, + ATTR_ENTITY_ID: "update.test_deprecated_supported_features_ints_with_service_call", + }, + blocking=True, + ) From c063bf403a09444bc8258044ef5348aa9b602b24 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 5 Jan 2024 10:53:59 +0100 Subject: [PATCH 0270/1544] Fix mobile_app cloudhook creation (#107068) --- homeassistant/components/cloud/__init__.py | 17 +++++ .../components/mobile_app/__init__.py | 13 ++-- .../components/mobile_app/http_api.py | 5 +- homeassistant/components/mobile_app/util.py | 20 ++++++ tests/components/cloud/conftest.py | 1 + tests/components/cloud/test_init.py | 64 ++++++++++++++++++- tests/components/mobile_app/test_init.py | 10 +-- 7 files changed, 113 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d7d57835e3a..6e5cddd0f28 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from enum import Enum +from typing import cast from hass_nabucasa import Cloud import voluptuous as vol @@ -176,6 +177,22 @@ def async_active_subscription(hass: HomeAssistant) -> bool: return async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired +async def async_get_or_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: + """Get or create a cloudhook.""" + if not async_is_connected(hass): + raise CloudNotConnected + + if not async_is_logged_in(hass): + raise CloudNotAvailable + + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + cloudhooks = cloud.client.cloudhooks + if hook := cloudhooks.get(webhook_id): + return cast(str, hook["cloudhook_url"]) + + return await async_create_cloudhook(hass, webhook_id) + + @bind_hass async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: """Create a cloudhook.""" diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index cb5c0ae5c3d..124ef750baa 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -36,6 +36,7 @@ from .const import ( ) from .helpers import savable_state from .http_api import RegistrationsView +from .util import async_create_cloud_hook from .webhook import handle_webhook PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] @@ -103,26 +104,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) - async def create_cloud_hook() -> None: - """Create a cloud hook.""" - hook = await cloud.async_create_cloudhook(hass, webhook_id) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook} - ) - async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: if ( state is cloud.CloudConnectionState.CLOUD_CONNECTED and CONF_CLOUDHOOK_URL not in entry.data ): - await create_cloud_hook() + await async_create_cloud_hook(hass, webhook_id, entry) if ( CONF_CLOUDHOOK_URL not in entry.data and cloud.async_active_subscription(hass) and cloud.async_is_connected(hass) ): - await create_cloud_hook() + await async_create_cloud_hook(hass, webhook_id, entry) + entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 3c34a291df1..92bb473d51a 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -35,6 +35,7 @@ from .const import ( SCHEMA_APP_DATA, ) from .helpers import supports_encryption +from .util import async_create_cloud_hook class RegistrationsView(HomeAssistantView): @@ -69,8 +70,8 @@ class RegistrationsView(HomeAssistantView): webhook_id = secrets.token_hex() if cloud.async_active_subscription(hass): - data[CONF_CLOUDHOOK_URL] = await cloud.async_create_cloudhook( - hass, webhook_id + data[CONF_CLOUDHOOK_URL] = await async_create_cloud_hook( + hass, webhook_id, None ) data[CONF_WEBHOOK_ID] = webhook_id diff --git a/homeassistant/components/mobile_app/util.py b/homeassistant/components/mobile_app/util.py index 45641861e5c..a7871d935ed 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -1,8 +1,11 @@ """Mobile app utility functions.""" from __future__ import annotations +import asyncio from typing import TYPE_CHECKING +from homeassistant.components import cloud +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from .const import ( @@ -10,6 +13,7 @@ from .const import ( ATTR_PUSH_TOKEN, ATTR_PUSH_URL, ATTR_PUSH_WEBSOCKET_CHANNEL, + CONF_CLOUDHOOK_URL, DATA_CONFIG_ENTRIES, DATA_DEVICES, DATA_NOTIFY, @@ -53,3 +57,19 @@ def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None: return target_service return None + + +_CLOUD_HOOK_LOCK = asyncio.Lock() + + +async def async_create_cloud_hook( + hass: HomeAssistant, webhook_id: str, entry: ConfigEntry | None +) -> str: + """Create a cloud hook.""" + async with _CLOUD_HOOK_LOCK: + hook = await cloud.async_get_or_create_cloudhook(hass, webhook_id) + if entry: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook} + ) + return hook diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index ef8cb037cdb..42852b15206 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -109,6 +109,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: is_connected = PropertyMock(side_effect=mock_is_connected) type(mock_cloud).is_connected = is_connected + type(mock_cloud.iot).connected = is_connected # Properties that we mock as attributes. mock_cloud.expiration_date = utcnow() diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index e12775d5a4a..850f8e12e02 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -1,12 +1,18 @@ """Test the cloud component.""" +from collections.abc import Callable, Coroutine from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch from hass_nabucasa import Cloud import pytest from homeassistant.components import cloud -from homeassistant.components.cloud.const import DOMAIN +from homeassistant.components.cloud import ( + CloudNotAvailable, + CloudNotConnected, + async_get_or_create_cloudhook, +) +from homeassistant.components.cloud.const import DOMAIN, PREF_CLOUDHOOKS from homeassistant.components.cloud.prefs import STORAGE_KEY from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Context, HomeAssistant @@ -214,3 +220,57 @@ async def test_remote_ui_url(hass: HomeAssistant, mock_cloud_fixture) -> None: cl.client.prefs._prefs["remote_domain"] = "example.com" assert cloud.async_remote_ui_url(hass) == "https://example.com" + + +async def test_async_get_or_create_cloudhook( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test async_get_or_create_cloudhook.""" + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + + webhook_id = "mock-webhook-id" + cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" + + with patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value=cloudhook_url, + ) as async_create_cloudhook_mock: + # create cloudhook as it does not exist + assert (await async_get_or_create_cloudhook(hass, webhook_id)) == cloudhook_url + async_create_cloudhook_mock.assert_called_once_with(hass, webhook_id) + + await set_cloud_prefs( + { + PREF_CLOUDHOOKS: { + webhook_id: { + "webhook_id": webhook_id, + "cloudhook_id": "random-id", + "cloudhook_url": cloudhook_url, + "managed": True, + } + } + } + ) + + async_create_cloudhook_mock.reset_mock() + + # get cloudhook as it exists + assert await async_get_or_create_cloudhook(hass, webhook_id) == cloudhook_url + async_create_cloudhook_mock.assert_not_called() + + # Simulate logged out + cloud.id_token = None + + # Not logged in + with pytest.raises(CloudNotAvailable): + await async_get_or_create_cloudhook(hass, webhook_id) + + # Simulate disconnected + cloud.iot.state = "disconnected" + + # Not connected + with pytest.raises(CloudNotConnected): + await async_get_or_create_cloudhook(hass, webhook_id) diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index d504703c222..6a365e84fb0 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -88,15 +88,17 @@ async def _test_create_cloud_hook( ), patch( "homeassistant.components.cloud.async_is_connected", return_value=True ), patch( - "homeassistant.components.cloud.async_create_cloudhook", autospec=True - ) as mock_create_cloudhook: + "homeassistant.components.cloud.async_get_or_create_cloudhook", autospec=True + ) as mock_async_get_or_create_cloudhook: cloud_hook = "https://hook-url" - mock_create_cloudhook.return_value = cloud_hook + mock_async_get_or_create_cloudhook.return_value = cloud_hook assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - await additional_steps(config_entry, mock_create_cloudhook, cloud_hook) + await additional_steps( + config_entry, mock_async_get_or_create_cloudhook, cloud_hook + ) async def test_create_cloud_hook_on_setup( From c805ea7b4ffd99760da1a9d0b458d6126497888d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 5 Jan 2024 11:46:45 +0100 Subject: [PATCH 0271/1544] Include deprecated constants in wildcard imports (#107114) --- .../components/alarm_control_panel/__init__.py | 17 +++++++++++------ .../components/alarm_control_panel/const.py | 12 ++++++++---- .../components/automation/__init__.py | 13 +++++++++---- .../components/binary_sensor/__init__.py | 13 +++++++++---- homeassistant/components/camera/__init__.py | 13 +++++++++---- homeassistant/components/camera/const.py | 8 ++++++-- homeassistant/components/climate/__init__.py | 17 +++++++++++------ homeassistant/components/climate/const.py | 8 ++++++-- homeassistant/components/cover/__init__.py | 13 +++++++++---- .../components/device_tracker/__init__.py | 17 +++++++++++------ .../components/device_tracker/const.py | 12 ++++++++---- homeassistant/components/fan/__init__.py | 13 +++++++++---- .../components/humidifier/__init__.py | 17 +++++++++++------ homeassistant/components/humidifier/const.py | 8 ++++++-- homeassistant/components/lock/__init__.py | 13 +++++++++---- homeassistant/components/number/const.py | 12 ++++++++---- homeassistant/components/remote/__init__.py | 13 +++++++++---- homeassistant/components/sensor/__init__.py | 17 +++++++++++------ homeassistant/components/sensor/const.py | 12 ++++++++---- homeassistant/components/siren/__init__.py | 17 +++++++++++------ homeassistant/components/siren/const.py | 8 ++++++-- homeassistant/components/switch/__init__.py | 13 +++++++++---- .../components/water_heater/__init__.py | 13 +++++++++---- homeassistant/const.py | 14 ++++++++------ homeassistant/core.py | 14 +++++++++----- homeassistant/data_entry_flow.py | 13 +++++++++---- homeassistant/helpers/deprecation.py | 18 +++++++++++++++--- homeassistant/helpers/device_registry.py | 13 +++++++++---- pyproject.toml | 6 +++++- tests/common.py | 14 ++++++++++++-- .../alarm_control_panel/test_init.py | 11 ++++++++++- tests/components/automation/test_init.py | 6 ++++++ tests/components/binary_sensor/test_init.py | 6 ++++++ tests/components/camera/test_init.py | 11 ++++++++++- tests/components/climate/test_init.py | 10 ++++++++++ tests/components/cover/test_init.py | 7 ++++++- tests/components/device_tracker/test_init.py | 10 ++++++++++ tests/components/fan/test_init.py | 7 ++++++- tests/components/humidifier/test_init.py | 11 ++++++++++- tests/components/lock/test_init.py | 7 ++++++- tests/components/number/test_const.py | 7 ++++++- tests/components/remote/test_init.py | 11 ++++++++++- tests/components/sensor/test_init.py | 10 ++++++++++ tests/components/siren/test_init.py | 11 ++++++++++- tests/components/switch/test_init.py | 11 ++++++++++- tests/components/water_heater/test_init.py | 11 ++++++++++- tests/helpers/test_deprecation.py | 6 +++--- tests/helpers/test_device_registry.py | 6 ++++++ tests/test_const.py | 6 ++++++ tests/test_core.py | 6 ++++++ tests/test_data_entry_flow.py | 11 ++++++++++- .../test_constant_deprecation/__init__.py | 2 +- 52 files changed, 438 insertions(+), 137 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 9c53f2b7fd0..45e1d63e0c2 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -52,12 +53,6 @@ if TYPE_CHECKING: else: from homeassistant.backports.functools import cached_property -# As we import constants of the cost module here, we need to add the following -# functions to check for deprecated constants again -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -249,3 +244,13 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A ATTR_CHANGED_BY: self.changed_by, ATTR_CODE_ARM_REQUIRED: self.code_arm_required, } + + +# As we import constants of the const module here, we need to add the following +# functions to check for deprecated constants again +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index 90bbcba1314..fe4be649e19 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -5,6 +5,7 @@ from typing import Final from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -60,10 +61,6 @@ _DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" CONDITION_ARMED_HOME: Final = "is_armed_home" @@ -71,3 +68,10 @@ CONDITION_ARMED_AWAY: Final = "is_armed_away" CONDITION_ARMED_NIGHT: Final = "is_armed_night" CONDITION_ARMED_VACATION: Final = "is_armed_vacation" CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass" + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4e6fa477ed2..efad44b15ef 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -58,6 +58,7 @@ from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import ( DeprecatedConstant, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -147,10 +148,6 @@ _DEPRECATED_AutomationTriggerInfo = DeprecatedConstant( TriggerInfo, "TriggerInfo", "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: @@ -1108,3 +1105,11 @@ def websocket_config( "config": automation.raw_config, }, ) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 3a32a1afb57..06185489419 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -218,10 +219,6 @@ _DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( BinarySensorDeviceClass.WINDOW, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - # mypy: disallow-any-generics @@ -303,3 +300,11 @@ class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) if (is_on := self.is_on) is None: return None return STATE_ON if is_on else STATE_OFF + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7a56292f7bb..ce75f064d47 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -54,6 +54,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -123,10 +124,6 @@ _DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum( CameraEntityFeature.STREAM, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} DEFAULT_CONTENT_TYPE: Final = "image/jpeg" @@ -1082,3 +1079,11 @@ async def async_handle_record_service( duration=service_call.data[CONF_DURATION], lookback=service_call.data[CONF_LOOKBACK], ) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index da41c0b9fab..09c4c7c1fb2 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -5,6 +5,7 @@ from typing import Final from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -47,6 +48,9 @@ _DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1") _DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore +# These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 78cb92944cb..c315765925f 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 make_entity_service_schema, ) from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -141,12 +142,6 @@ SET_TEMPERATURE_SCHEMA = vol.All( ), ) -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - # mypy: disallow-any-generics @@ -734,3 +729,13 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 615dc7d48dd..9c9153d9f63 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -188,6 +189,9 @@ _DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum( ClimateEntityFeature.AUX_HEAT, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore +# These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 3e438fb4ca1..945585de522 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -143,10 +144,6 @@ _DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( CoverEntityFeature.SET_TILT_POSITION, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" ATTR_POSITION = "position" @@ -493,3 +490,11 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self._cover_is_last_toggle_direction_open: return fns["close"] return fns["open"] + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index b5ad4660cde..adcc90cccbf 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -6,6 +6,7 @@ from functools import partial from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -57,12 +58,6 @@ from .legacy import ( # noqa: F401 see, ) -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: @@ -83,3 +78,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_setup_legacy_integration(hass, config) return True + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 10c16e09107..67a90ab0f95 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -9,6 +9,7 @@ from typing import Final from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -44,10 +45,6 @@ _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( SourceType.BLUETOOTH_LE, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) @@ -71,3 +68,10 @@ ATTR_CONSIDER_HOME: Final = "consider_home" ATTR_IP: Final = "ip" CONNECTED_DEVICE_REGISTERED: Final = "device_tracker_connected_device_registered" + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index dedaedfe600..c35d828e398 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -76,10 +77,6 @@ _DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( FanEntityFeature.PRESET_MODE, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" SERVICE_OSCILLATE = "oscillate" @@ -471,3 +468,11 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if hasattr(self, "_attr_preset_modes"): return self._attr_preset_modes return None + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 184c638e8f5..37f9d49f0dd 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -81,12 +82,6 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(HumidifierDeviceClass)) # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - # mypy: disallow-any-generics @@ -293,3 +288,13 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT self._report_deprecated_supported_features_values(new_features) return new_features return features + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index a1a219ddce7..66ac0fcf18d 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstant, DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -66,6 +67,9 @@ _DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( HumidifierEntityFeature.MODES, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore +# These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index a9370f8d092..a4e7c4b7d1a 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -33,6 +33,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -70,10 +71,6 @@ class LockEntityFeature(IntFlag): # Please use the LockEntityFeature enum instead. _DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} # mypy: disallow-any-generics @@ -315,3 +312,11 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return self._lock_option_default_code = "" + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 55d22c86648..a2d7c066af7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -38,6 +38,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -70,10 +71,6 @@ _DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") _DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") _DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - class NumberDeviceClass(StrEnum): """Device class for numbers.""" @@ -481,3 +478,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, } + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 7e9ebfe12b9..c5facb9785c 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -92,10 +93,6 @@ _DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - REMOTE_SERVICE_ACTIVITY_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} ) @@ -262,3 +259,11 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) await self.hass.async_add_executor_job( ft.partial(self.delete_command, **kwargs) ) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6077e4708d5..694af903a3e 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -59,6 +59,7 @@ from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -120,12 +121,6 @@ __all__ = [ "SensorStateClass", ] -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - # mypy: disallow-any-generics @@ -954,3 +949,13 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st value = f"{numerical_value:z.{precision}f}" return value + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index d57a09981ef..b1cb120e3fe 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -38,6 +38,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -468,10 +469,6 @@ _DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum( ) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, @@ -631,3 +628,10 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.WEIGHT: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 29ad238ac00..fb41d5f7b48 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -17,6 +17,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.deprecation import ( + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -53,12 +54,6 @@ TURN_ON_SCHEMA = { vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, } -# As we import deprecated constants from the const module, we need to add these two functions -# otherwise this module will be logged for using deprecated constants and not the custom component -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - class SirenTurnOnServiceParameters(TypedDict, total=False): """Represent possible parameters to siren.turn_on service data dict type.""" @@ -218,3 +213,13 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self._report_deprecated_supported_features_values(new_features) return new_features return features + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 50c3af61c8d..9e46d8dc997 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -6,6 +6,7 @@ from typing import Final from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -47,6 +48,9 @@ _DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( SirenEntityFeature.DURATION, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore +# These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a318f763fcb..ce9b1477ad6 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -66,10 +67,6 @@ _DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum( SwitchDeviceClass.SWITCH, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - # mypy: disallow-any-generics @@ -133,3 +130,11 @@ class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_) if hasattr(self, "entity_description"): return self.entity_description.device_class return None + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index e5cf2cc2d3c..82a853125ff 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -86,10 +87,6 @@ _DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum( WaterHeaterEntityFeature.AWAY_MODE, "2025.1" ) -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) - ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" ATTR_AWAY_MODE = "away_mode" @@ -441,3 +438,11 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3aa0a75729e..e0d5a859913 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,6 +8,7 @@ from typing import Final from .helpers.deprecation import ( DeprecatedConstant, DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -440,12 +441,6 @@ _DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant( "2025.1", ) - -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - - # #### STATES #### STATE_ON: Final = "on" STATE_OFF: Final = "off" @@ -1607,3 +1602,10 @@ SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" FORMAT_DATE: Final = "%Y-%m-%d" FORMAT_TIME: Final = "%H:%M:%S" FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/core.py b/homeassistant/core.py index 4fdaa662e71..6f71e5513f1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -83,6 +83,7 @@ from .exceptions import ( ) from .helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -159,11 +160,6 @@ _DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025. _DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = functools.partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = functools.partial(dir_with_deprecated_constants, module_globals=globals()) - - # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -2545,3 +2541,11 @@ class Config: if self._original_unit_system: data["unit_system"] = self._original_unit_system return await super().async_save(data) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = functools.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = functools.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 5c9c0ff1ce4..63ba565582a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -17,6 +17,7 @@ from .core import HomeAssistant, callback from .exceptions import HomeAssistantError from .helpers.deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -59,10 +60,6 @@ _DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum( ) _DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" @@ -700,3 +697,11 @@ def _create_abort_data( reason=reason, description_placeholders=description_placeholders, ) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 72b26e90b84..18a42ce9bcf 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -292,10 +292,22 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A return value -def dir_with_deprecated_constants(module_globals: dict[str, Any]) -> list[str]: +def dir_with_deprecated_constants(module_globals_keys: list[str]) -> list[str]: """Return dir() with deprecated constants.""" - return list(module_globals) + [ + return module_globals_keys + [ name.removeprefix(_PREFIX_DEPRECATED) - for name in module_globals + for name in module_globals_keys + if name.startswith(_PREFIX_DEPRECATED) + ] + + +def all_with_deprecated_constants(module_globals: dict[str, Any]) -> list[str]: + """Generate a list for __all___ with deprecated constants.""" + # Iterate over a copy in case the globals dict is mutated by another thread + # while we loop over it. + module_globals_keys = list(module_globals) + return [itm for itm in module_globals_keys if not itm.startswith("_")] + [ + name.removeprefix(_PREFIX_DEPRECATED) + for name in module_globals_keys if name.startswith(_PREFIX_DEPRECATED) ] diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bd509cb47ec..cfe3b78ebab 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -24,6 +24,7 @@ from . import storage from .debounce import Debouncer from .deprecation import ( DeprecatedConstantEnum, + all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) @@ -75,10 +76,6 @@ _DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum( ) _DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1") -# Both can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) - class DeviceInfo(TypedDict, total=False): """Entity device information for device registry.""" @@ -1113,3 +1110,11 @@ def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) for key, value in connections } + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/pyproject.toml b/pyproject.toml index f611cc73f1b..e6fe35c3960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,8 @@ disable = [ "duplicate-bases", # PLE0241 "format-needs-mapping", # F502 "function-redefined", # F811 - "invalid-all-format", # PLE0605 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 "invalid-all-object", # PLE0604 "invalid-character-backspace", # PLE2510 "invalid-character-esc", # PLE2513 @@ -673,6 +674,9 @@ ignore = [ "COM819", "ISC001", "ISC002", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/tests/common.py b/tests/common.py index b07788dc3d7..85193022e4f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -92,7 +92,7 @@ import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader from tests.testing_config.custom_components.test_constant_deprecation import ( - import_deprecated_costant, + import_deprecated_constant, ) _LOGGER = logging.getLogger(__name__) @@ -1482,6 +1482,7 @@ def import_and_test_deprecated_constant_enum( - Assert value is the same as the replacement - Assert a warning is logged - Assert the deprecated constant is included in the modules.__dir__() + - Assert the deprecated constant is included in the modules.__all__() """ import_and_test_deprecated_constant( caplog, @@ -1507,8 +1508,9 @@ def import_and_test_deprecated_constant( - Assert value is the same as the replacement - Assert a warning is logged - Assert the deprecated constant is included in the modules.__dir__() + - Assert the deprecated constant is included in the modules.__all__() """ - value = import_deprecated_costant(module, constant_name) + value = import_deprecated_constant(module, constant_name) assert value == replacement assert ( module.__name__, @@ -1523,3 +1525,11 @@ def import_and_test_deprecated_constant( # verify deprecated constant is included in dir() assert constant_name in dir(module) + assert constant_name in module.__all__ + + +def help_test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + assert set(module.__all__) == { + itm for itm in module.__dir__() if not itm.startswith("_") + } diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 1e6fce6def6..42a532cbb1a 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -6,7 +6,16 @@ import pytest from homeassistant.components import alarm_control_panel -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) @pytest.mark.parametrize( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 235ca48f095..6bb1b89259a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -59,6 +59,7 @@ from tests.common import ( async_capture_events, async_fire_time_changed, async_mock_service, + help_test_all, import_and_test_deprecated_constant, mock_restore_cache, ) @@ -2569,6 +2570,11 @@ async def test_websocket_config( assert msg["error"]["code"] == "not_found" +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(automation) + + @pytest.mark.parametrize( ("constant_name", "replacement"), [ diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 014722d94a4..6ca189113b9 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -14,6 +14,7 @@ from tests.common import ( MockConfigEntry, MockModule, MockPlatform, + help_test_all, import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, @@ -197,6 +198,11 @@ async def test_entity_category_config_raises_error( ) +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(binary_sensor) + + @pytest.mark.parametrize( "device_class", list(binary_sensor.BinarySensorDeviceClass), diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0e761f2f437..f1e3a4fdef5 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -27,7 +27,7 @@ from homeassistant.setup import async_setup_component from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum from tests.typing import ClientSessionGenerator, WebSocketGenerator STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -962,6 +962,15 @@ async def test_use_stream_for_stills( assert await resp.read() == b"stream_keyframe_image" +@pytest.mark.parametrize( + "module", + [camera, camera.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize( "enum", list(camera.const.StreamType), diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 8fc82365c23..89826c98086 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -36,6 +36,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, + help_test_all, import_and_test_deprecated_constant, import_and_test_deprecated_constant_enum, mock_integration, @@ -157,6 +158,15 @@ def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: return result +@pytest.mark.parametrize( + "module", + [climate, climate.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize( ("enum", "constant_prefix"), _create_tuples(climate.ClimateEntityFeature, "SUPPORT_") diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 1b08658d983..480d1ef83aa 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -> None: @@ -127,6 +127,11 @@ def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: return result +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(cover) + + @pytest.mark.parametrize( ("enum", "constant_prefix"), _create_tuples(cover.CoverEntityFeature, "SUPPORT_") diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 024187a33f6..eb8fde8f0e2 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -34,6 +34,7 @@ from . import common from tests.common import ( assert_setup_component, async_fire_time_changed, + help_test_all, import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, @@ -685,6 +686,15 @@ def test_see_schema_allowing_ios_calls() -> None: ) +@pytest.mark.parametrize( + "module", + [device_tracker, device_tracker.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize(("enum"), list(SourceType)) @pytest.mark.parametrize( "module", diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 828c13b6f16..1beea47c6fa 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum from tests.testing_config.custom_components.test.fan import MockFan @@ -150,6 +150,11 @@ async def test_preset_mode_validation( assert exc.value.translation_key == "not_valid_preset_mode" +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(fan) + + @pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index 24cf4b6d962..3ef3fca8589 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum class MockHumidifierEntity(HumidifierEntity): @@ -54,6 +54,15 @@ def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: return result +@pytest.mark.parametrize( + "module", + [humidifier, humidifier.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize( ("enum", "constant_prefix"), _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 854b89fd1d8..7ebb5bf3027 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum async def help_test_async_lock_service( @@ -371,6 +371,11 @@ async def test_lock_with_illegal_default_code( assert exc.value.translation_key == "add_default_code" +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(lock) + + @pytest.mark.parametrize(("enum"), list(LockEntityFeature)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py index e4b47e17e6e..13d94e2eeaf 100644 --- a/tests/components/number/test_const.py +++ b/tests/components/number/test_const.py @@ -4,7 +4,12 @@ import pytest from homeassistant.components.number import const -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum + + +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(const) @pytest.mark.parametrize(("enum"), list(const.NumberMode)) diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index a75ff858483..be4a4843097 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -22,7 +22,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service, import_and_test_deprecated_constant_enum +from tests.common import ( + async_mock_service, + help_test_all, + import_and_test_deprecated_constant_enum, +) TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -143,6 +147,11 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.data[ATTR_ENTITY_ID] == ENTITY_ID +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(remote) + + @pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 829bb5af827..522afe3b992 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -52,6 +52,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, + help_test_all, import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, @@ -2524,6 +2525,15 @@ async def test_entity_category_config_raises_error( assert not hass.states.get("sensor.test") +@pytest.mark.parametrize( + "module", + [sensor, sensor.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize(("enum"), list(sensor.SensorStateClass)) @pytest.mark.parametrize(("module"), [sensor, sensor.const]) def test_deprecated_constants( diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index abc5b0fac38..1cf44d16ea0 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.siren import ( from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant -from tests.common import import_and_test_deprecated_constant_enum +from tests.common import help_test_all, import_and_test_deprecated_constant_enum class MockSirenEntity(SirenEntity): @@ -110,6 +110,15 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: process_turn_on_params(siren, {"tone": 3}) +@pytest.mark.parametrize( + "module", + [siren, siren.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + @pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) @pytest.mark.parametrize(("module"), [siren, siren.const]) def test_deprecated_constants( diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 7a43e0bf50e..deb7acb512a 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -9,7 +9,11 @@ from homeassistant.setup import async_setup_component from . import common -from tests.common import MockUser, import_and_test_deprecated_constant_enum +from tests.common import ( + MockUser, + help_test_all, + import_and_test_deprecated_constant_enum, +) @pytest.fixture(autouse=True) @@ -82,6 +86,11 @@ async def test_switch_context( assert state2.context.user_id == hass_admin_user.id +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(switch) + + @pytest.mark.parametrize(("enum"), list(switch.SwitchDeviceClass)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 861be192340..b81ef369452 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -17,7 +17,11 @@ from homeassistant.components.water_heater import ( from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from tests.common import async_mock_service, import_and_test_deprecated_constant_enum +from tests.common import ( + async_mock_service, + help_test_all, + import_and_test_deprecated_constant_enum, +) async def test_set_temp_schema_no_req( @@ -102,6 +106,11 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: assert water_heater.async_turn_off.call_count == 1 +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(water_heater) + + @pytest.mark.parametrize( ("enum"), [ diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 017e541bb08..25b37e2073f 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -429,7 +429,7 @@ def test_test_check_if_deprecated_constant_invalid( @pytest.mark.parametrize( - ("module_global", "expected"), + ("module_globals", "expected"), [ ({"CONSTANT": 1}, ["CONSTANT"]), ({"_DEPRECATED_CONSTANT": 1}, ["_DEPRECATED_CONSTANT", "CONSTANT"]), @@ -440,7 +440,7 @@ def test_test_check_if_deprecated_constant_invalid( ], ) def test_dir_with_deprecated_constants( - module_global: dict[str, Any], expected: list[str] + module_globals: dict[str, Any], expected: list[str] ) -> None: """Test dir() with deprecated constants.""" - assert dir_with_deprecated_constants(module_global) == expected + assert dir_with_deprecated_constants([*module_globals.keys()]) == expected diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 43540a52f7d..240afa2cbab 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -20,6 +20,7 @@ from homeassistant.helpers import ( from tests.common import ( MockConfigEntry, flush_store, + help_test_all, import_and_test_deprecated_constant_enum, ) @@ -2018,6 +2019,11 @@ async def test_loading_invalid_configuration_url_from_storage( assert entry.configuration_url == "invalid" +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(dr) + + @pytest.mark.parametrize(("enum"), list(dr.DeviceEntryDisabler)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/test_const.py b/tests/test_const.py index fedf35ae6d1..4b9be4f27f1 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -9,6 +9,7 @@ from homeassistant import const from homeassistant.components import sensor from tests.common import ( + help_test_all, import_and_test_deprecated_constant, import_and_test_deprecated_constant_enum, ) @@ -23,6 +24,11 @@ def _create_tuples( return result +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(const) + + @pytest.mark.parametrize( ("enum", "constant_prefix"), _create_tuples(const.EntityCategory, "ENTITY_CATEGORY_") diff --git a/tests/test_core.py b/tests/test_core.py index da76961c5be..02fba4c93af 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -61,6 +61,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .common import ( async_capture_events, async_mock_service, + help_test_all, import_and_test_deprecated_constant_enum, ) @@ -2650,6 +2651,11 @@ async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: assert not evt.is_set() +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(ha) + + @pytest.mark.parametrize( ("enum"), [ diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index eb507febe8a..602b21c15bc 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -10,7 +10,11 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.core import HomeAssistant from homeassistant.util.decorator import Registry -from .common import async_capture_events, import_and_test_deprecated_constant_enum +from .common import ( + async_capture_events, + help_test_all, + import_and_test_deprecated_constant_enum, +) @pytest.fixture @@ -804,6 +808,11 @@ async def test_find_flows_by_init_data_type( assert len(manager.async_progress()) == 0 +def test_all() -> None: + """Test module.__all__ is correctly set.""" + help_test_all(data_entry_flow) + + @pytest.mark.parametrize(("enum"), list(data_entry_flow.FlowResultType)) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, diff --git a/tests/testing_config/custom_components/test_constant_deprecation/__init__.py b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py index 4367cbed7b1..b061b9c35fc 100644 --- a/tests/testing_config/custom_components/test_constant_deprecation/__init__.py +++ b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py @@ -4,6 +4,6 @@ from types import ModuleType from typing import Any -def import_deprecated_costant(module: ModuleType, constant_name: str) -> Any: +def import_deprecated_constant(module: ModuleType, constant_name: str) -> Any: """Import and return deprecated constant.""" return getattr(module, constant_name) From 6033a7c3d44e6bd452c3442ea59154bebfaa8f1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 12:32:32 +0100 Subject: [PATCH 0272/1544] Finish Efergy entity translations (#107152) --- homeassistant/components/efergy/sensor.py | 5 +++-- homeassistant/components/efergy/strings.json | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 809f1c531da..dd8752dde7f 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key=CONF_CURRENT_VALUES, - name="Power Usage", + translation_key="power_usage", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -156,7 +156,8 @@ class EfergySensor(EfergyEntity, SensorEntity): super().__init__(api, server_unique_id) self.entity_description = description if description.key == CONF_CURRENT_VALUES: - self._attr_name = f"{description.name}_{'' if sid is None else sid}" + assert sid is not None + self._attr_translation_placeholders = {"sid": str(sid)} self._attr_unique_id = ( f"{server_unique_id}/{description.key}_{'' if sid is None else sid}" ) diff --git a/homeassistant/components/efergy/strings.json b/homeassistant/components/efergy/strings.json index 3b17bf07f1a..612c964487b 100644 --- a/homeassistant/components/efergy/strings.json +++ b/homeassistant/components/efergy/strings.json @@ -48,6 +48,9 @@ }, "cost_year": { "name": "Yearly energy cost" + }, + "power_usage": { + "name": "Power usage {sid}" } } } From 85cdbb5adefbafd4c2814353d6ef1dcc565d663a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 5 Jan 2024 06:38:00 -0500 Subject: [PATCH 0273/1544] Bump zwave-js-server-python to 0.55.3 (#107225) Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_events.py | 11 ++++++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 9a66dae8e93..a06de5cb8ee 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.2"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 023ddd084a3..a9e23547334 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2896,7 +2896,7 @@ zigpy==0.60.4 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.2 +zwave-js-server-python==0.55.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 888906f875f..9060526df4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2192,7 +2192,7 @@ zigpy-znp==0.12.1 zigpy==0.60.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.2 +zwave-js-server-python==0.55.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 4fbaa97f118..1e91b9338fa 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -348,7 +348,11 @@ async def test_power_level_notification( async def test_unknown_notification( - hass: HomeAssistant, hank_binary_switch, integration, client + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + hank_binary_switch, + integration, + client, ) -> None: """Test behavior of unknown notification type events.""" # just pick a random node to fake the notification event @@ -358,8 +362,9 @@ async def test_unknown_notification( # by the lib. We will use a class that is guaranteed not to be recognized notification_obj = AsyncMock() notification_obj.node = node - with pytest.raises(TypeError): - node.emit("notification", {"notification": notification_obj}) + node.emit("notification", {"notification": notification_obj}) + + assert f"Unhandled notification type: {notification_obj}" in caplog.text notification_events = async_capture_events(hass, "zwave_js_notification") From bc539a946f9814d0b1ea1b1c622b8a771b9b10de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 02:27:10 -1000 Subject: [PATCH 0274/1544] Use identity checks for unifiprotect enums (#106795) enums are singletons in this case and there is no need to use the slower equality checks here --- .../components/unifiprotect/camera.py | 8 +++----- homeassistant/components/unifiprotect/data.py | 4 ++-- .../components/unifiprotect/entity.py | 20 +++++++++---------- .../components/unifiprotect/light.py | 2 +- homeassistant/components/unifiprotect/lock.py | 6 +++--- .../components/unifiprotect/media_player.py | 2 +- .../components/unifiprotect/select.py | 2 +- .../components/unifiprotect/utils.py | 4 ++-- 8 files changed, 23 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 481d51ec529..dc579ad6b7c 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -191,13 +191,11 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._attr_motion_detection_enabled = ( motion_enabled if motion_enabled is not None else True ) + state_type_is_connected = updated_device.state is StateType.CONNECTED self._attr_is_recording = ( - updated_device.state == StateType.CONNECTED and updated_device.is_recording - ) - is_connected = ( - self.data.last_update_success - and updated_device.state == StateType.CONNECTED + state_type_is_connected and updated_device.is_recording ) + is_connected = self.data.last_update_success and state_type_is_connected # some cameras have detachable lens that could cause the camera to be offline self._attr_available = is_connected and updated_device.is_video_ready diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 8b8ec80c5ba..11782c42bee 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -192,7 +192,7 @@ class ProtectData: ) -> None: self._async_signal_device_update(device) if ( - device.model == ModelType.CAMERA + device.model is ModelType.CAMERA and device.id in self._pending_camera_ids and "channels" in changed_data ): @@ -249,7 +249,7 @@ class ProtectData: obj.id, ) - if obj.type == EventType.DEVICE_ADOPTED: + if obj.type is EventType.DEVICE_ADOPTED: if obj.metadata is not None and obj.metadata.device_id is not None: device = self.api.bootstrap.get_device_from_id( obj.metadata.device_id diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 28149d349c9..00160005fe0 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -80,12 +80,12 @@ def _async_device_entities( can_write = device.can_write(data.api.bootstrap.auth_user) for description in descs: if description.ufp_perm is not None: - if description.ufp_perm == PermRequired.WRITE and not can_write: + if description.ufp_perm is PermRequired.WRITE and not can_write: continue - if description.ufp_perm == PermRequired.NO_WRITE and can_write: + if description.ufp_perm is PermRequired.NO_WRITE and can_write: continue if ( - description.ufp_perm == PermRequired.DELETE + description.ufp_perm is PermRequired.DELETE and not device.can_delete(data.api.bootstrap.auth_user) ): continue @@ -157,17 +157,17 @@ def async_all_device_entities( ) descs = [] - if ufp_device.model == ModelType.CAMERA: + if ufp_device.model is ModelType.CAMERA: descs = camera_descs - elif ufp_device.model == ModelType.LIGHT: + elif ufp_device.model is ModelType.LIGHT: descs = light_descs - elif ufp_device.model == ModelType.SENSOR: + elif ufp_device.model is ModelType.SENSOR: descs = sense_descs - elif ufp_device.model == ModelType.VIEWPORT: + elif ufp_device.model is ModelType.VIEWPORT: descs = viewer_descs - elif ufp_device.model == ModelType.DOORLOCK: + elif ufp_device.model is ModelType.DOORLOCK: descs = lock_descs - elif ufp_device.model == ModelType.CHIME: + elif ufp_device.model is ModelType.CHIME: descs = chime_descs if not descs and not unadopted_descs or ufp_device.model is None: @@ -249,7 +249,7 @@ class ProtectDeviceEntity(Entity): self._attr_available = ( last_update_success and ( - device.state == StateType.CONNECTED + device.state is StateType.CONNECTED or (not device.is_adopted_by_us and device.can_adopt) ) and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 38ce73828c2..6cc56009cea 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -34,7 +34,7 @@ async def async_setup_entry( data: ProtectData = hass.data[DOMAIN][entry.entry_id] async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - if device.model == ModelType.LIGHT and device.can_write( + if device.model is ModelType.LIGHT and device.can_write( data.api.bootstrap.auth_user ): async_add_entities([ProtectLight(data, device)]) diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 791a5e958ea..2b7ce4f1147 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -79,11 +79,11 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): self._attr_is_locking = False self._attr_is_unlocking = False self._attr_is_jammed = False - if lock_status == LockStatusType.CLOSED: + if lock_status is LockStatusType.CLOSED: self._attr_is_locked = True - elif lock_status == LockStatusType.CLOSING: + elif lock_status is LockStatusType.CLOSING: self._attr_is_locking = True - elif lock_status == LockStatusType.OPENING: + elif lock_status is LockStatusType.OPENING: self._attr_is_unlocking = True elif lock_status in ( LockStatusType.FAILED_WHILE_CLOSING, diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index b2376277e6f..5daac033048 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -110,7 +110,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE is_connected = self.data.last_update_success and ( - updated_device.state == StateType.CONNECTED + updated_device.state is StateType.CONNECTED or (not updated_device.is_adopted_by_us and updated_device.can_adopt) ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index dfc3be2d4a1..ecbc22f5787 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -116,7 +116,7 @@ def _get_doorbell_options(api: ProtectApiClient) -> list[dict[str, Any]]: for item in messages: msg_type = item.type.value - if item.type == DoorbellMessageType.CUSTOM_MESSAGE: + if item.type is DoorbellMessageType.CUSTOM_MESSAGE: msg_type = f"{DoorbellMessageType.CUSTOM_MESSAGE.value}:{item.text}" built_messages.append({"id": msg_type, "name": item.text}) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 3e2b5e1b19e..f07e1eb9554 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -111,8 +111,8 @@ def async_get_light_motion_current(obj: Light) -> str: """Get light motion mode for Flood Light.""" if ( - obj.light_mode_settings.mode == LightModeType.MOTION - and obj.light_mode_settings.enable_at == LightModeEnableType.DARK + obj.light_mode_settings.mode is LightModeType.MOTION + and obj.light_mode_settings.enable_at is LightModeEnableType.DARK ): return f"{LightModeType.MOTION.value}Dark" return obj.light_mode_settings.mode.value From 371ee1aa8ec623eb87b4fc386388d933a66bd128 Mon Sep 17 00:00:00 2001 From: Grant <38738958+ThePapaG@users.noreply.github.com> Date: Fri, 5 Jan 2024 22:36:57 +1000 Subject: [PATCH 0275/1544] Add Tyua Product Category "dsd" for Filament Light (#106709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Product Category "dsd" support to tuya integration for Filament Lights * remove unnecessary color_temp and color_data arguments --------- Co-authored-by: Jan Čermák --- homeassistant/components/tuya/light.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 8e98e8d6a41..50927d35d32 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -118,6 +118,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_1, ), ), + # Filament Light + # Based on data from https://github.com/home-assistant/core/issues/106703 + # Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 + # As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc + "dsd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + ), + ), # Ceiling Fan Light # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v "fsd": ( From 84d7be71e0fdfecd797eabf52c78c2145efa0ff3 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 5 Jan 2024 13:40:00 +0100 Subject: [PATCH 0276/1544] Bump velbus-aio to 2023.12.0 (#107066) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 1f0dd001853..c5f9ccd3563 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.11.0"], + "requirements": ["velbus-aio==2023.12.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index a9e23547334..00206ed2bf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ vallox-websocket-api==4.0.2 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.11.0 +velbus-aio==2023.12.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9060526df4b..95c2ebfd704 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2065,7 +2065,7 @@ vallox-websocket-api==4.0.2 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2023.11.0 +velbus-aio==2023.12.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 824bb94d1db84239480a151ff45d5262ecfbf757 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 03:00:38 -1000 Subject: [PATCH 0277/1544] Add test coverage for ESPHome device info (#107034) --- tests/components/esphome/test_manager.py | 129 ++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 1376e8bd41d..a1ba05d4a94 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,7 +25,7 @@ from homeassistant.components.esphome.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice @@ -785,3 +785,130 @@ async def test_esphome_user_services_changes( ] ) mock_client.execute_service.reset_mock() + + +async def test_esphome_device_with_suggested_area( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with suggested area.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"suggested_area": "kitchen"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.suggested_area == "kitchen" + + +async def test_esphome_device_with_project( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a project.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"project_name": "mfr.model", "project_version": "2.2.2"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.manufacturer == "mfr" + assert dev.model == "model" + assert dev.hw_version == "2.2.2" + + +async def test_esphome_device_with_manufacturer( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a manufacturer.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"manufacturer": "acme"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.manufacturer == "acme" + + +async def test_esphome_device_with_web_server( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a web server.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"webserver_port": 80}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert dev.configuration_url == "http://test.local:80" + + +async def test_esphome_device_with_compilation_time( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with a compilation_time.""" + device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + device_info={"compilation_time": "comp_time"}, + states=[], + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + entry = device.entry + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert "comp_time" in dev.sw_version From f2495636085b6e520209275e78ee541a5a5cc5a4 Mon Sep 17 00:00:00 2001 From: Scott K Logan Date: Fri, 5 Jan 2024 07:00:54 -0600 Subject: [PATCH 0278/1544] Add Rainforest RAVEn integration (#80061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Rainforest RAVEn integration * Add Rainforest Automation brand * Add diagnostics to Rainforest RAVEn integration * Drop a test assertion for an undefined behavior * Add DEVICE_NAME test constant * Catch up with reality * Use Platform.SENSOR Co-authored-by: Robert Resch * Make rainforest_raven translatable * Stop setting device_class on unsupported scenarios * Rename rainforest_raven.data -> rainforest_raven.coordinator * Make _generate_unique_id more reusable * Move device synchronization into third party library * Switch from asyncio_timeout to asyncio.timeout * Ignore non-electric meters Co-authored-by: Robert Resch * Drop direct dependency on iso4217, bump aioraven * Use RAVEn-specific exceptions * Add timeouts to data updates * Move DeviceInfo generation from Sensor to Coordinator * Store meter macs as strings * Convert to using SelectSelector * Drop test_flow_user_invalid_mac This test isn't necessary now that SelectSelector is used. * Implement PR feedback - Split some long format lines - Simplify meter mac_id extraction in diagnostics - Expose unique_id using an attribute instead of a property - Add a comment about the meters dictionary shallow copy Co-authored-by: Erik Montnemery * Simplify mac address redaction Co-authored-by: Joakim Sørensen * Freeze RAVEnSensorEntityDescription dataclass Co-authored-by: Erik Montnemery --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery Co-authored-by: Joakim Sørensen --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/rainforest.json | 5 + .../components/rainforest_raven/__init__.py | 29 +++ .../rainforest_raven/config_flow.py | 158 ++++++++++++ .../components/rainforest_raven/const.py | 3 + .../rainforest_raven/coordinator.py | 163 ++++++++++++ .../rainforest_raven/diagnostics.py | 43 ++++ .../components/rainforest_raven/manifest.json | 26 ++ .../components/rainforest_raven/sensor.py | 186 ++++++++++++++ .../components/rainforest_raven/strings.json | 51 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 +- homeassistant/generated/usb.py | 14 ++ mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/rainforest_raven/__init__.py | 44 ++++ tests/components/rainforest_raven/const.py | 132 ++++++++++ .../rainforest_raven/test_config_flow.py | 238 ++++++++++++++++++ .../rainforest_raven/test_coordinator.py | 93 +++++++ .../rainforest_raven/test_diagnostics.py | 103 ++++++++ .../components/rainforest_raven/test_init.py | 43 ++++ .../rainforest_raven/test_sensor.py | 59 +++++ 24 files changed, 1426 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/rainforest.json create mode 100644 homeassistant/components/rainforest_raven/__init__.py create mode 100644 homeassistant/components/rainforest_raven/config_flow.py create mode 100644 homeassistant/components/rainforest_raven/const.py create mode 100644 homeassistant/components/rainforest_raven/coordinator.py create mode 100644 homeassistant/components/rainforest_raven/diagnostics.py create mode 100644 homeassistant/components/rainforest_raven/manifest.json create mode 100644 homeassistant/components/rainforest_raven/sensor.py create mode 100644 homeassistant/components/rainforest_raven/strings.json create mode 100644 tests/components/rainforest_raven/__init__.py create mode 100644 tests/components/rainforest_raven/const.py create mode 100644 tests/components/rainforest_raven/test_config_flow.py create mode 100644 tests/components/rainforest_raven/test_coordinator.py create mode 100644 tests/components/rainforest_raven/test_diagnostics.py create mode 100644 tests/components/rainforest_raven/test_init.py create mode 100644 tests/components/rainforest_raven/test_sensor.py diff --git a/.strict-typing b/.strict-typing index e46f439e4ca..be595c52a23 100644 --- a/.strict-typing +++ b/.strict-typing @@ -308,6 +308,7 @@ homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* +homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* diff --git a/CODEOWNERS b/CODEOWNERS index 21d692d2942..04dd08841a1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1047,6 +1047,8 @@ build.json @home-assistant/supervisor /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin /tests/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin +/homeassistant/components/rainforest_raven/ @cottsay +/tests/components/rainforest_raven/ @cottsay /homeassistant/components/rainmachine/ @bachya /tests/components/rainmachine/ @bachya /homeassistant/components/random/ @fabaff diff --git a/homeassistant/brands/rainforest.json b/homeassistant/brands/rainforest.json new file mode 100644 index 00000000000..6d04a4bf2d1 --- /dev/null +++ b/homeassistant/brands/rainforest.json @@ -0,0 +1,5 @@ +{ + "domain": "rainforest_automation", + "name": "Rainforest Automation", + "integrations": ["rainforest_eagle", "rainforest_raven"] +} diff --git a/homeassistant/components/rainforest_raven/__init__.py b/homeassistant/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..d72b12f68c6 --- /dev/null +++ b/homeassistant/components/rainforest_raven/__init__.py @@ -0,0 +1,29 @@ +"""Integration for Rainforest RAVEn devices.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + +PLATFORMS = (Platform.SENSOR,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rainforest RAVEn device from a config entry.""" + coordinator = RAVEnDataCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +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].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py new file mode 100644 index 00000000000..cd8ce68c7e7 --- /dev/null +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from aioraven.data import MeterType +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DEFAULT_NAME, DOMAIN + + +def _format_id(value: str | int) -> str: + if isinstance(value, str): + return value + return f"{value or 0:04X}" + + +def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: + """Generate unique id from usb attributes.""" + return ( + f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" + f"_{info.manufacturer}_{info.description}" + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rainforest RAVEn devices.""" + + def __init__(self) -> None: + """Set up flow instance.""" + self._dev_path: str | None = None + self._meter_macs: set[str] = set() + + async def _validate_device(self, dev_path: str) -> None: + self._abort_if_unique_id_configured(updates={CONF_DEVICE: dev_path}) + async with ( + asyncio.timeout(5), + RAVEnSerialDevice(dev_path) as raven_device, + ): + await raven_device.synchronize() + meters = await raven_device.get_meter_list() + if meters: + for meter in meters.meter_mac_ids or (): + meter_info = await raven_device.get_meter_info(meter=meter) + if meter_info and ( + meter_info.meter_type is None + or meter_info.meter_type == MeterType.ELECTRIC + ): + self._meter_macs.add(meter.hex()) + self._dev_path = dev_path + + async def async_step_meters( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Connect to device and discover meters.""" + errors: dict[str, str] = {} + if user_input is not None: + meter_macs = [] + for raw_mac in user_input.get(CONF_MAC, ()): + mac = bytes.fromhex(raw_mac).hex() + if mac not in meter_macs: + meter_macs.append(mac) + if meter_macs and not errors: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_DEVICE: self._dev_path, + CONF_MAC: meter_macs, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_MAC): SelectSelector( + SelectSelectorConfig( + options=sorted(self._meter_macs), + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key=CONF_MAC, + ) + ), + } + ) + return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) + + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: + """Handle USB Discovery.""" + device = discovery_info.device + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = _generate_unique_id(discovery_info) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + return self.async_abort(reason="timeout_connect") + except RAVEnConnectionError: + return self.async_abort(reason="cannot_connect") + return await self.async_step_meters() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + existing_devices = [ + entry.data[CONF_DEVICE] for entry in self._async_current_entries() + ] + unused_ports = [ + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + for port in ports + if port.device not in existing_devices + ] + if not unused_ports: + return self.async_abort(reason="no_devices_found") + + errors = {} + if user_input is not None and user_input.get(CONF_DEVICE, "").strip(): + port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))] + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, port.device + ) + unique_id = _generate_unique_id(port) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + errors[CONF_DEVICE] = "timeout_connect" + except RAVEnConnectionError: + errors[CONF_DEVICE] = "cannot_connect" + else: + return await self.async_step_meters() + + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/rainforest_raven/const.py b/homeassistant/components/rainforest_raven/const.py new file mode 100644 index 00000000000..a5269ddbc26 --- /dev/null +++ b/homeassistant/components/rainforest_raven/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rainforest RAVEn integration.""" +DEFAULT_NAME = "Rainforest RAVEn" +DOMAIN = "rainforest_raven" diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py new file mode 100644 index 00000000000..edae4f11433 --- /dev/null +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -0,0 +1,163 @@ +"""Data update coordination for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from datetime import timedelta +import logging +from typing import Any + +from aioraven.data import DeviceInfo as RAVEnDeviceInfo +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _get_meter_data( + device: RAVEnSerialDevice, meter: bytes +) -> dict[str, dict[str, Any]]: + data = {} + + sum_info = await device.get_current_summation_delivered(meter=meter) + demand_info = await device.get_instantaneous_demand(meter=meter) + price_info = await device.get_current_price(meter=meter) + + if sum_info and sum_info.meter_mac_id == meter: + data["CurrentSummationDelivered"] = asdict(sum_info) + + if demand_info and demand_info.meter_mac_id == meter: + data["InstantaneousDemand"] = asdict(demand_info) + + if price_info and price_info.meter_mac_id == meter: + data["PriceCluster"] = asdict(price_info) + + return data + + +async def _get_all_data( + device: RAVEnSerialDevice, meter_macs: list[str] +) -> dict[str, dict[str, Any]]: + data: dict[str, dict[str, Any]] = {"Meters": {}} + + for meter_mac in meter_macs: + data["Meters"][meter_mac] = await _get_meter_data( + device, bytes.fromhex(meter_mac) + ) + + network_info = await device.get_network_info() + + if network_info and network_info.link_strength: + data["NetworkInfo"] = asdict(network_info) + + return data + + +class RAVEnDataCoordinator(DataUpdateCoordinator): + """Communication coordinator for a Rainforest RAVEn device.""" + + _raven_device: RAVEnSerialDevice | None = None + _device_info: RAVEnDeviceInfo | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @property + def device_fw_version(self) -> str | None: + """Return the firmware version of the device.""" + if self._device_info: + return self._device_info.fw_version + return None + + @property + def device_hw_version(self) -> str | None: + """Return the hardware version of the device.""" + if self._device_info: + return self._device_info.hw_version + return None + + @property + def device_mac_address(self) -> str | None: + """Return the MAC address of the device.""" + if self._device_info and self._device_info.device_mac_id: + return self._device_info.device_mac_id.hex() + return None + + @property + def device_manufacturer(self) -> str | None: + """Return the manufacturer of the device.""" + if self._device_info: + return self._device_info.manufacturer + return None + + @property + def device_model(self) -> str | None: + """Return the model of the device.""" + if self._device_info: + return self._device_info.model_id + return None + + @property + def device_name(self) -> str: + """Return the product name of the device.""" + return "RAVEn Device" + + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + if self._device_info and self.device_mac_address: + return DeviceInfo( + identifiers={(DOMAIN, self.device_mac_address)}, + manufacturer=self.device_manufacturer, + model=self.device_model, + name=self.device_name, + sw_version=self.device_fw_version, + hw_version=self.device_hw_version, + ) + return None + + async def _async_update_data(self) -> dict[str, Any]: + try: + device = await self._get_device() + async with asyncio.timeout(5): + return await _get_all_data(device, self.entry.data[CONF_MAC]) + except RAVEnConnectionError as err: + if self._raven_device: + await self._raven_device.close() + self._raven_device = None + raise UpdateFailed(f"RAVEnConnectionError: {err}") from err + + async def _get_device(self) -> RAVEnSerialDevice: + if self._raven_device is not None: + return self._raven_device + + device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE]) + + async with asyncio.timeout(5): + await device.open() + + try: + await device.synchronize() + self._device_info = await device.get_device_info() + except Exception: + await device.close() + raise + + self._raven_device = device + return device diff --git a/homeassistant/components/rainforest_raven/diagnostics.py b/homeassistant/components/rainforest_raven/diagnostics.py new file mode 100644 index 00000000000..970915888ec --- /dev/null +++ b/homeassistant/components/rainforest_raven/diagnostics.py @@ -0,0 +1,43 @@ +"""Diagnostics support for a Rainforest RAVEn device.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + +TO_REDACT_CONFIG = {CONF_MAC} +TO_REDACT_DATA = {"device_mac_id", "meter_mac_id"} + + +@callback +def async_redact_meter_macs(data: dict) -> dict: + """Redact meter MAC addresses from mapping keys.""" + if not data.get("Meters"): + return data + + redacted = {**data, "Meters": {}} + for idx, mac_id in enumerate(data["Meters"]): + redacted["Meters"][f"**REDACTED{idx}**"] = data["Meters"][mac_id] + + return redacted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> Mapping[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: RAVEnDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), + "data": async_redact_meter_macs( + async_redact_data(coordinator.data, TO_REDACT_DATA) + ), + } diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json new file mode 100644 index 00000000000..900c947821d --- /dev/null +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -0,0 +1,26 @@ +{ + "domain": "rainforest_raven", + "name": "Rainforest RAVEn", + "codeowners": ["@cottsay"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", + "iot_class": "local_polling", + "requirements": ["aioraven==0.5.0"], + "usb": [ + { + "vid": "0403", + "pid": "8A28", + "manufacturer": "*rainforest*", + "description": "*raven*", + "known_devices": ["Rainforest RAVEn"] + }, + { + "vid": "04B4", + "pid": "0003", + "manufacturer": "*rainforest*", + "description": "*emu-2*", + "known_devices": ["Rainforest EMU-2"] + } + ] +} diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py new file mode 100644 index 00000000000..731b511fe90 --- /dev/null +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -0,0 +1,186 @@ +"""Sensor entity for a Rainforest RAVEn device.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_MAC, + PERCENTAGE, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + + +@dataclass(frozen=True) +class RAVEnSensorEntityDescription(SensorEntityDescription): + """A class that describes RAVEn sensor entities.""" + + message_key: str | None = None + attribute_keys: list[str] | None = None + + +SENSORS = ( + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_delivered", + key="summation_delivered", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_received", + key="summation_received", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="InstantaneousDemand", + translation_key="power_demand", + key="demand", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +DIAGNOSTICS = ( + RAVEnSensorEntityDescription( + message_key="NetworkInfo", + translation_key="signal_strength", + key="link_strength", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + attribute_keys=[ + "channel", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[RAVEnSensor] = [ + RAVEnSensor(coordinator, description) for description in DIAGNOSTICS + ] + + for meter_mac_addr in entry.data[CONF_MAC]: + entities.extend( + RAVEnMeterSensor(coordinator, description, meter_mac_addr) + for description in SENSORS + ) + + meter_data = coordinator.data.get("Meters", {}).get(meter_mac_addr) or {} + if meter_data.get("PriceCluster", {}).get("currency"): + entities.append( + RAVEnMeterSensor( + coordinator, + RAVEnSensorEntityDescription( + message_key="PriceCluster", + translation_key="meter_price", + key="price", + native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", + icon="mdi:cash", + state_class=SensorStateClass.MEASUREMENT, + attribute_keys=[ + "tier", + "rate_label", + ], + ), + meter_mac_addr, + ) + ) + + async_add_entities(entities) + + +class RAVEnSensor(CoordinatorEntity[RAVEnDataCoordinator], SensorEntity): + """Rainforest RAVEn Sensor.""" + + _attr_has_entity_name = True + entity_description: RAVEnSensorEntityDescription + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{self.coordinator.device_mac_address}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return self.coordinator.data.get(self.entity_description.message_key, {}) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if self.entity_description.attribute_keys: + return { + key: self._data.get(key) + for key in self.entity_description.attribute_keys + } + return None + + @property + def native_value(self) -> StateType: + """Return native value of the sensor.""" + return str(self._data.get(self.entity_description.key)) + + +class RAVEnMeterSensor(RAVEnSensor): + """Rainforest RAVEn Meter Sensor.""" + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + meter_mac_addr: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entity_description) + self._meter_mac_addr = meter_mac_addr + self._attr_unique_id = ( + f"{self._meter_mac_addr}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return ( + self.coordinator.data.get("Meters", {}) + .get(self._meter_mac_addr, {}) + .get(self.entity_description.message_key, {}) + ) diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json new file mode 100644 index 00000000000..fb667d64d3f --- /dev/null +++ b/homeassistant/components/rainforest_raven/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "No compatible devices found" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + }, + "step": { + "meters": { + "data": { + "mac": "Meter MAC Addresses" + } + }, + "user": { + "data": { + "device": "[%key:common::config_flow::data::device%]" + } + } + } + }, + "entity": { + "sensor": { + "meter_price": { + "name": "Meter price", + "state_attributes": { + "rate_label": { "name": "Rate" }, + "tier": { "name": "Tier" } + } + }, + "power_demand": { + "name": "Meter power demand" + }, + "signal_strength": { + "name": "Meter signal strength", + "state_attributes": { + "channel": { "name": "Channel" } + } + }, + "total_energy_delivered": { + "name": "Total meter energy delivered" + }, + "total_energy_received": { + "name": "Total meter energy received" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 254a3ad0df3..cd9b3c6a982 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ FLOWS = { "radiotherm", "rainbird", "rainforest_eagle", + "rainforest_raven", "rainmachine", "rapt_ble", "rdw", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6df3dc5cbd6..f9427b8f336 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4688,11 +4688,22 @@ "config_flow": true, "iot_class": "local_polling" }, - "rainforest_eagle": { - "name": "Rainforest Eagle", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "rainforest": { + "name": "Rainforest Automation", + "integrations": { + "rainforest_eagle": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest Eagle" + }, + "rainforest_raven": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest RAVEn" + } + } }, "rainmachine": { "name": "RainMachine", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2fdd032c2dd..ce40f481d96 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -19,6 +19,20 @@ USB = [ "pid": "1340", "vid": "0572", }, + { + "description": "*raven*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "8A28", + "vid": "0403", + }, + { + "description": "*emu-2*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "0003", + "vid": "04B4", + }, { "domain": "velbus", "pid": "0B1B", diff --git a/mypy.ini b/mypy.ini index 53f5b0715ce..6e2630813e6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2841,6 +2841,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rainforest_raven.*] +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.rainmachine.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 00206ed2bf2..37c220b576c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -340,6 +340,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95c2ebfd704..c8a873370ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -313,6 +313,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/tests/components/rainforest_raven/__init__.py b/tests/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..0269e4cf0f4 --- /dev/null +++ b/tests/components/rainforest_raven/__init__.py @@ -0,0 +1,44 @@ +"""Tests for the Rainforest RAVEn component.""" + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_MAC + +from .const import ( + DEMAND, + DEVICE_INFO, + DISCOVERY_INFO, + METER_INFO, + METER_LIST, + NETWORK_INFO, + PRICE_CLUSTER, + SUMMATION, +) + +from tests.common import AsyncMock, MockConfigEntry + + +def create_mock_device(): + """Create a mock instance of RAVEnStreamDevice.""" + device = AsyncMock() + + device.__aenter__.return_value = device + device.get_current_price.return_value = PRICE_CLUSTER + device.get_current_summation_delivered.return_value = SUMMATION + device.get_device_info.return_value = DEVICE_INFO + device.get_instantaneous_demand.return_value = DEMAND + device.get_meter_list.return_value = METER_LIST + device.get_meter_info.side_effect = lambda meter: METER_INFO.get(meter) + device.get_network_info.return_value = NETWORK_INFO + + return device + + +def create_mock_entry(no_meters=False): + """Create a mock config entry for a RAVEn device.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: DISCOVERY_INFO.device, + CONF_MAC: [] if no_meters else [METER_INFO[None].meter_mac_id.hex()], + }, + ) diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py new file mode 100644 index 00000000000..7e75440c30d --- /dev/null +++ b/tests/components/rainforest_raven/const.py @@ -0,0 +1,132 @@ +"""Constants for the Rainforest RAVEn tests.""" + +from aioraven.data import ( + CurrentSummationDelivered, + DeviceInfo, + InstantaneousDemand, + MeterInfo, + MeterList, + MeterType, + NetworkInfo, + PriceCluster, +) +from iso4217 import Currency + +from homeassistant.components import usb + +DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/ttyACM0", + pid="0x0003", + vid="0x04B4", + serial_number="1234", + description="RFA-Z105-2 HW2.7.3 EMU-2", + manufacturer="Rainforest Automation, Inc.", +) + + +DEVICE_NAME = usb.human_readable_device_name( + DISCOVERY_INFO.device, + DISCOVERY_INFO.serial_number, + DISCOVERY_INFO.manufacturer, + DISCOVERY_INFO.description, + int(DISCOVERY_INFO.vid, 0), + int(DISCOVERY_INFO.pid, 0), +) + + +DEVICE_INFO = DeviceInfo( + device_mac_id=bytes.fromhex("abcdef0123456789"), + install_code=None, + link_key=None, + fw_version="2.0.0 (7400)", + hw_version="2.7.3", + image_type=None, + manufacturer=DISCOVERY_INFO.manufacturer, + model_id="Z105-2-EMU2-LEDD_JM", + date_code=None, +) + + +METER_LIST = MeterList( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_ids=[ + bytes.fromhex("1234567890abcdef"), + bytes.fromhex("9876543210abcdef"), + ], +) + + +METER_INFO = { + None: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[0]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[1]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[1], + meter_type=MeterType.GAS, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), +} + + +NETWORK_INFO = NetworkInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + coord_mac_id=None, + status=None, + description=None, + status_code=None, + ext_pan_id=None, + channel=13, + short_addr=None, + link_strength=100, +) + + +PRICE_CLUSTER = PriceCluster( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + price="0.10", + currency=Currency.usd, + tier=3, + tier_label="Set by user", + rate_label="Set by user", +) + + +SUMMATION = CurrentSummationDelivered( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + summation_delivered="23456.7890", + summation_received="00000.0000", +) + + +DEMAND = InstantaneousDemand( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + demand="1.2345", +) diff --git a/tests/components/rainforest_raven/test_config_flow.py b/tests/components/rainforest_raven/test_config_flow.py new file mode 100644 index 00000000000..7ec6c52349c --- /dev/null +++ b/tests/components/rainforest_raven/test_config_flow.py @@ -0,0 +1,238 @@ +"""Test Rainforest RAVEn config flow.""" +import asyncio +from unittest.mock import patch + +from aioraven.device import RAVEnConnectionError +import pytest +import serial.tools.list_ports + +from homeassistant import data_entry_flow +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import SOURCE_USB, SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_SOURCE +from homeassistant.core import HomeAssistant + +from . import create_mock_device +from .const import DEVICE_NAME, DISCOVERY_INFO, METER_LIST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.config_flow.RAVEnSerialDevice", + return_value=device, + ): + yield device + + +@pytest.fixture +def mock_device_no_open(mock_device): + """Mock a device which fails to open.""" + mock_device.__aenter__.side_effect = RAVEnConnectionError + mock_device.open.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_comm_error(mock_device): + """Mock a device which fails to read or parse raw data.""" + mock_device.get_meter_list.side_effect = RAVEnConnectionError + mock_device.get_meter_info.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_timeout(mock_device): + """Mock a device which times out when queried.""" + mock_device.get_meter_list.side_effect = asyncio.TimeoutError + mock_device.get_meter_info.side_effect = asyncio.TimeoutError + return mock_device + + +@pytest.fixture +def mock_comports(): + """Mock serial port list.""" + port = serial.tools.list_ports_common.ListPortInfo(DISCOVERY_INFO.device) + port.serial_number = DISCOVERY_INFO.serial_number + port.manufacturer = DISCOVERY_INFO.manufacturer + port.device = DISCOVERY_INFO.device + port.description = DISCOVERY_INFO.description + port.pid = int(DISCOVERY_INFO.pid, 0) + port.vid = int(DISCOVERY_INFO.vid, 0) + comports = [port] + with patch("serial.tools.list_ports.comports", return_value=comports): + yield comports + + +async def test_flow_usb(hass: HomeAssistant, mock_comports, mock_device): + """Test usb flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_usb_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test usb flow connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_usb_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test usb flow connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "timeout_connect" + + +async def test_flow_usb_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test usb flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_user(hass: HomeAssistant, mock_comports, mock_device): + """Test user flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_user_no_available_devices(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: DISCOVERY_INFO.device}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "no_devices_found" + + +async def test_flow_user_in_progress(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "already_in_progress" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} + + +async def test_flow_user_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "timeout_connect"} + + +async def test_flow_user_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} diff --git a/tests/components/rainforest_raven/test_coordinator.py b/tests/components/rainforest_raven/test_coordinator.py new file mode 100644 index 00000000000..6b29c944aeb --- /dev/null +++ b/tests/components/rainforest_raven/test_coordinator.py @@ -0,0 +1,93 @@ +"""Tests for the Rainforest RAVEn data coordinator.""" +from aioraven.device import RAVEnConnectionError +import pytest + +from homeassistant.components.rainforest_raven.coordinator import RAVEnDataCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +async def test_coordinator_device_info(hass: HomeAssistant, mock_device): + """Test reporting device information from the coordinator.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + assert coordinator.device_fw_version is None + assert coordinator.device_hw_version is None + assert coordinator.device_info is None + assert coordinator.device_mac_address is None + assert coordinator.device_manufacturer is None + assert coordinator.device_model is None + assert coordinator.device_name == "RAVEn Device" + + await coordinator.async_config_entry_first_refresh() + + assert coordinator.device_fw_version == "2.0.0 (7400)" + assert coordinator.device_hw_version == "2.7.3" + assert coordinator.device_info + assert coordinator.device_mac_address + assert coordinator.device_manufacturer == "Rainforest Automation, Inc." + assert coordinator.device_model == "Z105-2-EMU2-LEDD_JM" + assert coordinator.device_name == "RAVEn Device" + + +async def test_coordinator_cache_device(hass: HomeAssistant, mock_device): + """Test that the device isn't re-opened for subsequent refreshes.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert mock_device.get_network_info.call_count == 1 + assert mock_device.open.call_count == 1 + + await coordinator.async_refresh() + assert mock_device.get_network_info.call_count == 2 + assert mock_device.open.call_count == 1 + + +async def test_coordinator_device_error_setup(hass: HomeAssistant, mock_device): + """Test handling of a device error during initialization.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.get_network_info.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() + + +async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device): + """Test handling of a device error during an update.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert coordinator.last_update_success is True + + mock_device.get_network_info.side_effect = RAVEnConnectionError + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + +async def test_coordinator_comm_error(hass: HomeAssistant, mock_device): + """Test handling of an error parsing or reading raw device data.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.synchronize.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py new file mode 100644 index 00000000000..639eacadc76 --- /dev/null +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Test the Rainforest Eagle diagnostics.""" +from dataclasses import asdict + +import pytest + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry +from .const import DEMAND, NETWORK_INFO, PRICE_CLUSTER, SUMMATION + +from tests.common import patch +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +@pytest.fixture +async def mock_entry_no_meters(hass: HomeAssistant, mock_device): + """Mock a RAVEn config entry with no meters.""" + mock_entry = create_mock_entry(True) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_entry_diagnostics_no_meters( + hass, hass_client, mock_device, mock_entry_no_meters +): + """Test RAVEn diagnostics before the coordinator has updated.""" + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_entry_no_meters + ) + + config_entry_dict = mock_entry_no_meters.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": {}, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } + + +async def test_entry_diagnostics(hass, hass_client, mock_device, mock_entry): + """Test RAVEn diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry) + + config_entry_dict = mock_entry.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": { + "**REDACTED0**": { + "CurrentSummationDelivered": { + **asdict(SUMMATION), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "InstantaneousDemand": { + **asdict(DEMAND), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "PriceCluster": { + **asdict(PRICE_CLUSTER), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + "currency": { + "__type": str(type(PRICE_CLUSTER.currency)), + "repr": repr(PRICE_CLUSTER.currency), + }, + }, + }, + }, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } diff --git a/tests/components/rainforest_raven/test_init.py b/tests/components/rainforest_raven/test_init.py new file mode 100644 index 00000000000..b99d94f4b43 --- /dev/null +++ b/tests/components/rainforest_raven/test_init.py @@ -0,0 +1,43 @@ +"""Tests for the Rainforest RAVEn component initialisation.""" +import pytest + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_load_unload_entry(hass: HomeAssistant, mock_entry): + """Test load and unload.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/rainforest_raven/test_sensor.py b/tests/components/rainforest_raven/test_sensor.py new file mode 100644 index 00000000000..e637e22ecf9 --- /dev/null +++ b/tests/components/rainforest_raven/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for the Rainforest RAVEn sensors.""" +import pytest + +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_sensors(hass: HomeAssistant, mock_device, mock_entry): + """Test the sensors.""" + assert len(hass.states.async_all()) == 5 + + demand = hass.states.get("sensor.raven_device_meter_power_demand") + assert demand is not None + assert demand.state == "1.2345" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.raven_device_total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "23456.7890" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.raven_device_total_meter_energy_received") + assert received is not None + assert received.state == "00000.0000" + assert received.attributes["unit_of_measurement"] == "kWh" + + price = hass.states.get("sensor.raven_device_meter_price") + assert price is not None + assert price.state == "0.10" + assert price.attributes["unit_of_measurement"] == "USD/kWh" + + signal = hass.states.get("sensor.raven_device_meter_signal_strength") + assert signal is not None + assert signal.state == "100" + assert signal.attributes["unit_of_measurement"] == "%" From 4485ece719add8e9887c276f477c96f5bc7b3ab0 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:27:42 +0100 Subject: [PATCH 0279/1544] Add support for service response to RESTful command (#97208) * Add ServiceResponse to rest_command * Handle json and text responses. Add Unit tests * Rest command text output handling. Prevent issue solved by PR#97777 * Re-raise exceptions as HomeAssistantError to enable 'continue_on_error' in scripts / automations. * Improve test coverage * Restructure to improve McCabe Complexity * Remove LookupError * Revert exception catching location * Remove LookupError from exception handling --- .../components/rest_command/__init__.py | 72 +++++++++----- tests/components/rest_command/test_init.py | 94 +++++++++++++++++++ 2 files changed, 144 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index dcf790748ec..a07ca03a258 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,6 +1,7 @@ """Support for exposing regular REST commands as services.""" import asyncio from http import HTTPStatus +from json.decoder import JSONDecodeError import logging import aiohttp @@ -18,7 +19,14 @@ from homeassistant.const import ( CONF_VERIFY_SSL, SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config @@ -98,17 +106,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: template_payload = command_config[CONF_PAYLOAD] template_payload.hass = hass - template_headers = None - if CONF_HEADERS in command_config: - template_headers = command_config[CONF_HEADERS] - for template_header in template_headers.values(): - template_header.hass = hass + template_headers = command_config.get(CONF_HEADERS, {}) + for template_header in template_headers.values(): + template_header.hass = hass - content_type = None - if CONF_CONTENT_TYPE in command_config: - content_type = command_config[CONF_CONTENT_TYPE] + content_type = command_config.get(CONF_CONTENT_TYPE) - async def async_service_handler(service: ServiceCall) -> None: + async def async_service_handler(service: ServiceCall) -> ServiceResponse: """Execute a shell command service.""" payload = None if template_payload: @@ -123,17 +127,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: variables=service.data, parse_result=False ) - headers = None - if template_headers: - headers = {} - for header_name, template_header in template_headers.items(): - headers[header_name] = template_header.async_render( - variables=service.data, parse_result=False - ) + headers = {} + for header_name, template_header in template_headers.items(): + headers[header_name] = template_header.async_render( + variables=service.data, parse_result=False + ) if content_type: - if headers is None: - headers = {} headers[hdrs.CONTENT_TYPE] = content_type try: @@ -141,7 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: request_url, data=payload, auth=auth, - headers=headers, + headers=headers or None, timeout=timeout, ) as response: if response.status < HTTPStatus.BAD_REQUEST: @@ -159,8 +159,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: payload, ) - except asyncio.TimeoutError: + if not service.return_response: + return None + + _content = None + try: + if response.content_type == "application/json": + _content = await response.json() + else: + _content = await response.text() + except (JSONDecodeError, AttributeError) as err: + _LOGGER.error("Response of `%s` has invalid JSON", request_url) + raise HomeAssistantError from err + + except UnicodeDecodeError as err: + _LOGGER.error( + "Response of `%s` could not be interpreted as text", + request_url, + ) + raise HomeAssistantError from err + return {"content": _content, "status": response.status} + + except asyncio.TimeoutError as err: _LOGGER.warning("Timeout call %s", request_url) + raise HomeAssistantError from err except aiohttp.ClientError as err: _LOGGER.error( @@ -168,9 +190,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: request_url, err, ) + raise HomeAssistantError from err # register services - hass.services.async_register(DOMAIN, name, async_service_handler) + hass.services.async_register( + DOMAIN, + name, + async_service_handler, + supports_response=SupportsResponse.OPTIONAL, + ) for name, command_config in config[DOMAIN].items(): async_register_rest_command(name, command_config) diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index c43fe84ea8f..ce0359e0fdb 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -1,9 +1,11 @@ """The tests for the rest command platform.""" import asyncio +import base64 from http import HTTPStatus from unittest.mock import patch import aiohttp +import pytest import homeassistant.components.rest_command as rc from homeassistant.const import ( @@ -11,6 +13,7 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, SERVICE_RELOAD, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -352,3 +355,94 @@ class TestRestCommandComponent: == "text/json" ) assert aioclient_mock.mock_calls[6][3].get("Accept") == "application/2json" + + def test_rest_command_get_response_plaintext(self, aioclient_mock): + """Get rest_command response, text.""" + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, self.config) + + aioclient_mock.get( + self.url, content=b"success", headers={"content-type": "text/plain"} + ) + + response = self.hass.services.call( + rc.DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"] == "success" + assert response["status"] == 200 + + def test_rest_command_get_response_json(self, aioclient_mock): + """Get rest_command response, json.""" + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, self.config) + + aioclient_mock.get( + self.url, + json={"status": "success", "number": 42}, + headers={"content-type": "application/json"}, + ) + + response = self.hass.services.call( + rc.DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"]["status"] == "success" + assert response["content"]["number"] == 42 + assert response["status"] == 200 + + def test_rest_command_get_response_malformed_json(self, aioclient_mock): + """Get rest_command response, malformed json.""" + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, self.config) + + aioclient_mock.get( + self.url, + content='{"status": "failure", 42', + headers={"content-type": "application/json"}, + ) + + # No problem without 'return_response' + response = self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) + self.hass.block_till_done() + assert not response + + # Throws error when requesting response + with pytest.raises(HomeAssistantError): + response = self.hass.services.call( + rc.DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + self.hass.block_till_done() + + def test_rest_command_get_response_none(self, aioclient_mock): + """Get rest_command response, other.""" + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, self.config) + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + + aioclient_mock.get( + self.url, + content=png, + headers={"content-type": "text/plain"}, + ) + + # No problem without 'return_response' + response = self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) + self.hass.block_till_done() + assert not response + + # Throws Decode error when requesting response + with pytest.raises(HomeAssistantError): + response = self.hass.services.call( + rc.DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + self.hass.block_till_done() + + assert not response From 8645d9c717f7931cba4b54c04b6f85a672cfba0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 03:28:29 -1000 Subject: [PATCH 0280/1544] Bump aiohttp-zlib-ng to 0.3.0 (#107184) --- .github/workflows/builder.yml | 9 +++++++++ .github/workflows/wheels.yml | 14 ++++++++++---- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 89d9a69ed03..9d1ab9e8a49 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -179,6 +179,15 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt + - name: Adjustments for 64-bit + if: matrix.arch == 'x86_64' || matrix.arch == 'aarch64' + run: | + # Some speedups are only available on 64-bit, and since + # we build 32bit images on 64bit hosts, we only enable + # the speed ups on 64bit since the wheels for 32bit + # are not available. + sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt + - name: Download Translations run: python3 -m script.translations download env: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3b23f1b5b05..d5c82c37e60 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -106,7 +106,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev" + apk: "libffi-dev;openssl-dev;yaml-dev;nasm" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -160,6 +160,12 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" ${requirement_file} sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} fi + + # Some speedups are only for 64-bit + if [ "${{ matrix.arch }}" = "x86_64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then + sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file} + fi + done - name: Split requirements all @@ -214,7 +220,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -228,7 +234,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -242,7 +248,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + 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 constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 399cbf70ad7..b5d4de43cd0 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3" + "aiohttp-zlib-ng==0.3.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 32189d875a2..b53ff8cc428 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.0 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index e6fe35c3960..a588cb19bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.3", + "aiohttp-zlib-ng==0.3.0", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 55cbdc31730..46e6fcb4a32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.0 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 37c220b576c..f8a1f9d2c44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.2 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.0 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8a873370ac..1dacbb8fb2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.2 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.3 +aiohttp-zlib-ng==0.3.0 # homeassistant.components.emulated_hue # homeassistant.components.http From e7573c3ed4f5e7f562bdbddfa4dd89f3e94adc9b Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:30:15 +0100 Subject: [PATCH 0281/1544] Add python_script response (#97937) * Add response to python_script * Reset output to empty dict if not valid dict * Add tests for python_script response * Raise Exceptions on service execution * Add info on exception type * Raise ServiceValidationError instead of ValueError * Raise only on return_response=True * Fix exception logger if no service response * Create issue if exception is not raised * Revert "Create issue if exception is not raised" This reverts commit a61dd8619fdf09585bbd70e1392d476f3fcea698. --------- Co-authored-by: rikroe --- .../components/python_script/__init__.py | 49 +++++- tests/components/python_script/test_init.py | 147 ++++++++++++++++++ 2 files changed, 188 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 098603b9494..7b49a6b1b0d 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -20,8 +20,13 @@ from RestrictedPython.Guards import ( import voluptuous as vol from homeassistant.const import CONF_DESCRIPTION, CONF_NAME, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -107,9 +112,9 @@ def discover_scripts(hass): _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) return False - def python_script_service_handler(call: ServiceCall) -> None: + def python_script_service_handler(call: ServiceCall) -> ServiceResponse: """Handle python script service calls.""" - execute_script(hass, call.service, call.data) + return execute_script(hass, call.service, call.data, call.return_response) existing = hass.services.services.get(DOMAIN, {}).keys() for existing_service in existing: @@ -126,7 +131,12 @@ def discover_scripts(hass): for fil in glob.iglob(os.path.join(path, "*.py")): name = os.path.splitext(os.path.basename(fil))[0] - hass.services.register(DOMAIN, name, python_script_service_handler) + hass.services.register( + DOMAIN, + name, + python_script_service_handler, + supports_response=SupportsResponse.OPTIONAL, + ) service_desc = { CONF_NAME: services_dict.get(name, {}).get("name", name), @@ -137,17 +147,17 @@ def discover_scripts(hass): @bind_hass -def execute_script(hass, name, data=None): +def execute_script(hass, name, data=None, return_response=False): """Execute a script.""" filename = f"{name}.py" raise_if_invalid_filename(filename) with open(hass.config.path(FOLDER, filename), encoding="utf8") as fil: source = fil.read() - execute(hass, filename, source, data) + return execute(hass, filename, source, data, return_response=return_response) @bind_hass -def execute(hass, filename, source, data=None): +def execute(hass, filename, source, data=None, return_response=False): """Execute Python source.""" compiled = compile_restricted_exec(source, filename=filename) @@ -216,16 +226,39 @@ def execute(hass, filename, source, data=None): "hass": hass, "data": data or {}, "logger": logger, + "output": {}, } try: _LOGGER.info("Executing %s: %s", filename, data) # pylint: disable-next=exec-used exec(compiled.code, restricted_globals) # noqa: S102 + _LOGGER.debug( + "Output of python_script: `%s`:\n%s", + filename, + restricted_globals["output"], + ) + # Ensure that we're always returning a dictionary + if not isinstance(restricted_globals["output"], dict): + output_type = type(restricted_globals["output"]) + restricted_globals["output"] = {} + raise ScriptError( + f"Expected `output` to be a dictionary, was {output_type}" + ) except ScriptError as err: + if return_response: + raise ServiceValidationError(f"Error executing script: {err}") from err logger.error("Error executing script: %s", err) + return None except Exception as err: # pylint: disable=broad-except + if return_response: + raise HomeAssistantError( + f"Error executing script ({type(err).__name__}): {err}" + ) from err logger.exception("Error executing script: %s", err) + return None + + return restricted_globals["output"] class StubPrinter: diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 4744c065ede..ee7fedee0d5 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.python_script import DOMAIN, FOLDER, execute from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component @@ -136,6 +137,19 @@ raise Exception('boom') assert "Error executing script: boom" in caplog.text +async def test_execute_runtime_error_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + source = """ +raise Exception('boom') + """ + + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == HomeAssistantError + assert "Error executing script (Exception): boom" in str(task.exception()) + + async def test_accessing_async_methods( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -151,6 +165,19 @@ hass.async_stop() assert "Not allowed to access async methods" in caplog.text +async def test_accessing_async_methods_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + source = """ +hass.async_stop() + """ + + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == ServiceValidationError + assert "Not allowed to access async methods" in str(task.exception()) + + async def test_using_complex_structures( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -186,6 +213,21 @@ async def test_accessing_forbidden_methods( assert f"Not allowed to access {name}" in caplog.text +async def test_accessing_forbidden_methods_with_response(hass: HomeAssistant) -> None: + """Test compile error logs error.""" + for source, name in { + "hass.stop()": "HomeAssistant.stop", + "dt_util.set_default_time_zone()": "module.set_default_time_zone", + "datetime.non_existing": "module.non_existing", + "time.tzset()": "TimeWrapper.tzset", + }.items(): + task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) + await hass.async_block_till_done() + + assert type(task.exception()) == ServiceValidationError + assert f"Not allowed to access {name}" in str(task.exception()) + + async def test_iterating(hass: HomeAssistant) -> None: """Test compile error logs error.""" source = """ @@ -449,3 +491,108 @@ time.sleep(5) await hass.async_block_till_done() assert caplog.text.count("time.sleep") == 1 + + +async def test_execute_with_output( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test executing a script with a return value.""" + caplog.set_level(logging.WARNING) + + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +output = {"result": f"hello {data.get('name', 'World')}"} + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ): + response = await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) + + assert isinstance(response, dict) + assert len(response) == 1 + assert response["result"] == "hello paulus" + + # No errors logged = good + assert caplog.text == "" + + +async def test_execute_no_output( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test executing a script without a return value.""" + caplog.set_level(logging.WARNING) + + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +no_output = {"result": f"hello {data.get('name', 'World')}"} + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ): + response = await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) + + assert isinstance(response, dict) + assert len(response) == 0 + + # No errors logged = good + assert caplog.text == "" + + +async def test_execute_wrong_output_type(hass: HomeAssistant) -> None: + """Test executing a script without a return value.""" + scripts = [ + "/some/config/dir/python_scripts/hello.py", + ] + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch("homeassistant.components.python_script.glob.iglob", return_value=scripts): + await async_setup_component(hass, "python_script", {}) + + source = """ +output = f"hello {data.get('name', 'World')}" + """ + + with patch( + "homeassistant.components.python_script.open", + mock_open(read_data=source), + create=True, + ), pytest.raises(ServiceValidationError): + await hass.services.async_call( + "python_script", + "hello", + {"name": "paulus"}, + blocking=True, + return_response=True, + ) From 0d7627da22707e7925076b8da6e06a4c85ac7186 Mon Sep 17 00:00:00 2001 From: MisterCommand Date: Fri, 5 Jan 2024 21:52:46 +0800 Subject: [PATCH 0282/1544] Add Hong Kong Observatory integration (#98703) * Add Hong Kong Observatory integration * Move coordinator to a separate file * Map icons to conditions * Fix code for review * Skip name * Add typings to data_coordinator * Some small fixes * Rename coordinator.py --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/hko/__init__.py | 41 +++++ homeassistant/components/hko/config_flow.py | 70 ++++++++ homeassistant/components/hko/const.py | 74 ++++++++ homeassistant/components/hko/coordinator.py | 187 ++++++++++++++++++++ homeassistant/components/hko/manifest.json | 9 + homeassistant/components/hko/strings.json | 19 ++ homeassistant/components/hko/weather.py | 75 ++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/hko/__init__.py | 1 + tests/components/hko/conftest.py | 17 ++ tests/components/hko/fixtures/rhrread.json | 82 +++++++++ tests/components/hko/test_config_flow.py | 112 ++++++++++++ 17 files changed, 705 insertions(+) create mode 100644 homeassistant/components/hko/__init__.py create mode 100644 homeassistant/components/hko/config_flow.py create mode 100644 homeassistant/components/hko/const.py create mode 100644 homeassistant/components/hko/coordinator.py create mode 100644 homeassistant/components/hko/manifest.json create mode 100644 homeassistant/components/hko/strings.json create mode 100644 homeassistant/components/hko/weather.py create mode 100644 tests/components/hko/__init__.py create mode 100644 tests/components/hko/conftest.py create mode 100644 tests/components/hko/fixtures/rhrread.json create mode 100644 tests/components/hko/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d17676d79c9..b2290556519 100644 --- a/.coveragerc +++ b/.coveragerc @@ -495,6 +495,9 @@ omit = homeassistant/components/hive/sensor.py homeassistant/components/hive/switch.py homeassistant/components/hive/water_heater.py + homeassistant/components/hko/__init__.py + homeassistant/components/hko/weather.py + homeassistant/components/hko/coordinator.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 04dd08841a1..f52d810958b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -528,6 +528,8 @@ build.json @home-assistant/supervisor /tests/components/history/ @home-assistant/core /homeassistant/components/hive/ @Rendili @KJonline /tests/components/hive/ @Rendili @KJonline +/homeassistant/components/hko/ @MisterCommand +/tests/components/hko/ @MisterCommand /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard /homeassistant/components/holiday/ @jrieger @gjohansson-ST diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py new file mode 100644 index 00000000000..a83c1dd2d89 --- /dev/null +++ b/homeassistant/components/hko/__init__.py @@ -0,0 +1,41 @@ +"""The Hong Kong Observatory integration.""" +from __future__ import annotations + +from hko import LOCATIONS + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LOCATION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_DISTRICT, DOMAIN, KEY_DISTRICT, KEY_LOCATION +from .coordinator import HKOUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Hong Kong Observatory from a config entry.""" + + location = entry.data[CONF_LOCATION] + district = next( + (item for item in LOCATIONS if item[KEY_LOCATION] == location), + {KEY_DISTRICT: DEFAULT_DISTRICT}, + )[KEY_DISTRICT] + websession = async_get_clientsession(hass) + + coordinator = HKOUpdateCoordinator(hass, websession, district, location) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py new file mode 100644 index 00000000000..21697d2dd53 --- /dev/null +++ b/homeassistant/components/hko/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Hong Kong Observatory integration.""" +from __future__ import annotations + +from asyncio import timeout +from typing import Any + +from hko import HKO, LOCATIONS, HKOError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LOCATION +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION + + +def get_loc_name(item): + """Return an array of supported locations.""" + return item[KEY_LOCATION] + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION, default=DEFAULT_LOCATION): SelectSelector( + SelectSelectorConfig(options=list(map(get_loc_name, LOCATIONS)), sort=True) + ) + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hong Kong Observatory.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + websession = async_get_clientsession(self.hass) + hko = HKO(websession) + async with timeout(60): + await hko.weather(API_RHRREAD) + + except HKOError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_LOCATION], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LOCATION], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/hko/const.py b/homeassistant/components/hko/const.py new file mode 100644 index 00000000000..a9a554850b0 --- /dev/null +++ b/homeassistant/components/hko/const.py @@ -0,0 +1,74 @@ +"""Constants for the Hong Kong Observatory integration.""" +from hko import LOCATIONS + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) + +DOMAIN = "hko" + +DISTRICT = "name" + +KEY_LOCATION = "LOCATION" +KEY_DISTRICT = "DISTRICT" + +DEFAULT_LOCATION = LOCATIONS[0][KEY_LOCATION] +DEFAULT_DISTRICT = LOCATIONS[0][KEY_DISTRICT] + +ATTRIBUTION = "Data provided by the Hong Kong Observatory" +MANUFACTURER = "Hong Kong Observatory" + +API_CURRENT = "current" +API_FORECAST = "forecast" +API_WEATHER_FORECAST = "weatherForecast" +API_FORECAST_DATE = "forecastDate" +API_FORECAST_ICON = "ForecastIcon" +API_FORECAST_WEATHER = "forecastWeather" +API_FORECAST_MAX_TEMP = "forecastMaxtemp" +API_FORECAST_MIN_TEMP = "forecastMintemp" +API_CONDITION = "condition" +API_TEMPERATURE = "temperature" +API_HUMIDITY = "humidity" +API_PLACE = "place" +API_DATA = "data" +API_VALUE = "value" +API_RHRREAD = "rhrread" + +WEATHER_INFO_RAIN = "rain" +WEATHER_INFO_SNOW = "snow" +WEATHER_INFO_WIND = "wind" +WEATHER_INFO_MIST = "mist" +WEATHER_INFO_CLOUD = "cloud" +WEATHER_INFO_THUNDERSTORM = "thunderstorm" +WEATHER_INFO_SHOWER = "shower" +WEATHER_INFO_ISOLATED = "isolated" +WEATHER_INFO_HEAVY = "heavy" +WEATHER_INFO_SUNNY = "sunny" +WEATHER_INFO_FINE = "fine" +WEATHER_INFO_AT_TIMES_AT_FIRST = "at times at first" +WEATHER_INFO_OVERCAST = "overcast" +WEATHER_INFO_INTERVAL = "interval" +WEATHER_INFO_PERIOD = "period" +WEATHER_INFO_FOG = "FOG" + +ICON_CONDITION_MAP = { + ATTR_CONDITION_SUNNY: [50], + ATTR_CONDITION_PARTLYCLOUDY: [51, 52, 53, 54, 76], + ATTR_CONDITION_CLOUDY: [60, 61], + ATTR_CONDITION_RAINY: [62, 63], + ATTR_CONDITION_POURING: [64], + ATTR_CONDITION_LIGHTNING_RAINY: [65], + ATTR_CONDITION_CLEAR_NIGHT: [70, 71, 72, 73, 74, 75, 77], + ATTR_CONDITION_SNOWY_RAINY: [7, 14, 15, 27, 37], + ATTR_CONDITION_WINDY: [80], + ATTR_CONDITION_FOG: [83, 84], +} diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py new file mode 100644 index 00000000000..05280c4a3bd --- /dev/null +++ b/homeassistant/components/hko/coordinator.py @@ -0,0 +1,187 @@ +"""Weather data coordinator for the HKO API.""" +from asyncio import timeout +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientSession +from hko import HKO, HKOError + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_CURRENT, + API_DATA, + API_FORECAST, + API_FORECAST_DATE, + API_FORECAST_ICON, + API_FORECAST_MAX_TEMP, + API_FORECAST_MIN_TEMP, + API_FORECAST_WEATHER, + API_HUMIDITY, + API_PLACE, + API_TEMPERATURE, + API_VALUE, + API_WEATHER_FORECAST, + DOMAIN, + ICON_CONDITION_MAP, + WEATHER_INFO_AT_TIMES_AT_FIRST, + WEATHER_INFO_CLOUD, + WEATHER_INFO_FINE, + WEATHER_INFO_FOG, + WEATHER_INFO_HEAVY, + WEATHER_INFO_INTERVAL, + WEATHER_INFO_ISOLATED, + WEATHER_INFO_MIST, + WEATHER_INFO_OVERCAST, + WEATHER_INFO_PERIOD, + WEATHER_INFO_RAIN, + WEATHER_INFO_SHOWER, + WEATHER_INFO_SNOW, + WEATHER_INFO_SUNNY, + WEATHER_INFO_THUNDERSTORM, + WEATHER_INFO_WIND, +) + +_LOGGER = logging.getLogger(__name__) + + +class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """HKO Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, session: ClientSession, district: str, location: str + ) -> None: + """Update data via library.""" + self.location = location + self.district = district + self.hko = HKO(session) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via HKO library.""" + try: + async with timeout(60): + rhrread = await self.hko.weather("rhrread") + fnd = await self.hko.weather("fnd") + except HKOError as error: + raise UpdateFailed(error) from error + return { + API_CURRENT: self._convert_current(rhrread), + API_FORECAST: [ + self._convert_forecast(item) for item in fnd[API_WEATHER_FORECAST] + ], + } + + def _convert_current(self, data: dict[str, Any]) -> dict[str, Any]: + """Return temperature and humidity in the appropriate format.""" + current = { + API_HUMIDITY: data[API_HUMIDITY][API_DATA][0][API_VALUE], + API_TEMPERATURE: next( + ( + item[API_VALUE] + for item in data[API_TEMPERATURE][API_DATA] + if item[API_PLACE] == self.location + ), + 0, + ), + } + return current + + def _convert_forecast(self, data: dict[str, Any]) -> dict[str, Any]: + """Return daily forecast in the appropriate format.""" + date = data[API_FORECAST_DATE] + forecast = { + ATTR_FORECAST_CONDITION: self._convert_icon_condition( + data[API_FORECAST_ICON], data[API_FORECAST_WEATHER] + ), + ATTR_FORECAST_TEMP: data[API_FORECAST_MAX_TEMP][API_VALUE], + ATTR_FORECAST_TEMP_LOW: data[API_FORECAST_MIN_TEMP][API_VALUE], + ATTR_FORECAST_TIME: f"{date[0:4]}-{date[4:6]}-{date[6:8]}T00:00:00+08:00", + } + return forecast + + def _convert_icon_condition(self, icon_code: int, info: str) -> str: + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return self._convert_info_condition(info) + + def _convert_info_condition(self, info: str) -> str: + """Return the condition corresponding to the weather info.""" + info = info.lower() + if WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_HAIL + if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: + return ATTR_CONDITION_SNOWY_RAINY + if WEATHER_INFO_SNOW in info: + return ATTR_CONDITION_SNOWY + if WEATHER_INFO_FOG in info or WEATHER_INFO_MIST in info: + return ATTR_CONDITION_FOG + if WEATHER_INFO_WIND in info and WEATHER_INFO_CLOUD in info: + return ATTR_CONDITION_WINDY_VARIANT + if WEATHER_INFO_WIND in info: + return ATTR_CONDITION_WINDY + if WEATHER_INFO_THUNDERSTORM in info and WEATHER_INFO_ISOLATED not in info: + return ATTR_CONDITION_LIGHTNING_RAINY + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_HEAVY in info + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + and WEATHER_INFO_AT_TIMES_AT_FIRST not in info + ): + return ATTR_CONDITION_POURING + if ( + ( + WEATHER_INFO_RAIN in info + or WEATHER_INFO_SHOWER in info + or WEATHER_INFO_THUNDERSTORM in info + ) + and WEATHER_INFO_SUNNY not in info + and WEATHER_INFO_FINE not in info + ): + return ATTR_CONDITION_RAINY + if (WEATHER_INFO_CLOUD in info or WEATHER_INFO_OVERCAST in info) and not ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_CLOUDY + if (WEATHER_INFO_SUNNY in info) and ( + WEATHER_INFO_INTERVAL in info or WEATHER_INFO_PERIOD in info + ): + return ATTR_CONDITION_PARTLYCLOUDY + if ( + WEATHER_INFO_SUNNY in info or WEATHER_INFO_FINE in info + ) and WEATHER_INFO_SHOWER not in info: + return ATTR_CONDITION_SUNNY + return ATTR_CONDITION_PARTLYCLOUDY diff --git a/homeassistant/components/hko/manifest.json b/homeassistant/components/hko/manifest.json new file mode 100644 index 00000000000..74718bb98c2 --- /dev/null +++ b/homeassistant/components/hko/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hko", + "name": "Hong Kong Observatory", + "codeowners": ["@MisterCommand"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hko", + "iot_class": "cloud_polling", + "requirements": ["hko==0.3.2"] +} diff --git a/homeassistant/components/hko/strings.json b/homeassistant/components/hko/strings.json new file mode 100644 index 00000000000..a537c864528 --- /dev/null +++ b/homeassistant/components/hko/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "Please select a location to use for weather forecasting.", + "data": { + "location": "[%key:common::config_flow::data::location%]" + } + } + } + } +} diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py new file mode 100644 index 00000000000..f4a784c5308 --- /dev/null +++ b/homeassistant/components/hko/weather.py @@ -0,0 +1,75 @@ +"""Support for the HKO service.""" +from homeassistant.components.weather import ( + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + API_CONDITION, + API_CURRENT, + API_FORECAST, + API_HUMIDITY, + API_TEMPERATURE, + ATTRIBUTION, + DOMAIN, + MANUFACTURER, +) +from .coordinator import HKOUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a HKO weather entity from a config_entry.""" + assert config_entry.unique_id is not None + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([HKOEntity(unique_id, coordinator)], False) + + +class HKOEntity(CoordinatorEntity[HKOUpdateCoordinator], WeatherEntity): + """Define a HKO entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + _attr_attribution = ATTRIBUTION + + def __init__(self, unique_id: str, coordinator: HKOUpdateCoordinator) -> None: + """Initialise the weather platform.""" + super().__init__(coordinator) + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=MANUFACTURER, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def condition(self) -> str: + """Return the current condition.""" + return self.coordinator.data[API_FORECAST][0][API_CONDITION] + + @property + def native_temperature(self) -> int: + """Return the temperature.""" + return self.coordinator.data[API_CURRENT][API_TEMPERATURE] + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self.coordinator.data[API_CURRENT][API_HUMIDITY] + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast data.""" + return self.coordinator.data[API_FORECAST] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cd9b3c6a982..8a71d51acf2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -205,6 +205,7 @@ FLOWS = { "here_travel_time", "hisense_aehw4a1", "hive", + "hko", "hlk_sw16", "holiday", "home_connect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f9427b8f336..4738de291fa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2446,6 +2446,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hko": { + "name": "Hong Kong Observatory", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hlk_sw16": { "name": "Hi-Link HLK-SW16", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f8a1f9d2c44..8d68009e180 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,6 +1030,9 @@ hikvision==0.4 # homeassistant.components.harman_kardon_avr hkavr==0.0.5 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dacbb8fb2a..b7f526ad15f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -823,6 +823,9 @@ here-routing==0.2.0 # homeassistant.components.here_travel_time here-transit==1.2.0 +# homeassistant.components.hko +hko==0.3.2 + # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/tests/components/hko/__init__.py b/tests/components/hko/__init__.py new file mode 100644 index 00000000000..ff4447c26e5 --- /dev/null +++ b/tests/components/hko/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hong Kong Observatory integration.""" diff --git a/tests/components/hko/conftest.py b/tests/components/hko/conftest.py new file mode 100644 index 00000000000..fd2181ddfc9 --- /dev/null +++ b/tests/components/hko/conftest.py @@ -0,0 +1,17 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(name="hko_config_flow_connect", autouse=True) +def hko_config_flow_connect(): + """Mock valid config flow setup.""" + with patch( + "homeassistant.components.hko.config_flow.HKO.weather", + return_value=json.loads(load_fixture("hko/rhrread.json")), + ): + yield diff --git a/tests/components/hko/fixtures/rhrread.json b/tests/components/hko/fixtures/rhrread.json new file mode 100644 index 00000000000..f9c0090ef6a --- /dev/null +++ b/tests/components/hko/fixtures/rhrread.json @@ -0,0 +1,82 @@ +{ + "rainfall": { + "data": [ + { + "unit": "mm", + "place": "Central & Western District", + "max": 0, + "main": "FALSE" + }, + { "unit": "mm", "place": "Eastern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwai Tsing", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Islands District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "North District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sai Kung", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sha Tin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Southern District", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tai Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tsuen Wan", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Tuen Mun", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wan Chai", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yuen Long", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Yau Tsim Mong", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Sham Shui Po", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kowloon City", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Wong Tai Sin", "max": 0, "main": "FALSE" }, + { "unit": "mm", "place": "Kwun Tong", "max": 0, "main": "FALSE" } + ], + "startTime": "2023-08-19T15:45:00+08:00", + "endTime": "2023-08-19T16:45:00+08:00" + }, + "icon": [60], + "iconUpdateTime": "2023-08-19T16:40:00+08:00", + "uvindex": { + "data": [{ "place": "King's Park", "value": 2, "desc": "low" }], + "recordDesc": "During the past hour" + }, + "updateTime": "2023-08-19T17:02:00+08:00", + "temperature": { + "data": [ + { "place": "King's Park", "value": 30, "unit": "C" }, + { "place": "Hong Kong Observatory", "value": 29, "unit": "C" }, + { "place": "Wong Chuk Hang", "value": 29, "unit": "C" }, + { "place": "Ta Kwu Ling", "value": 31, "unit": "C" }, + { "place": "Lau Fau Shan", "value": 31, "unit": "C" }, + { "place": "Tai Po", "value": 29, "unit": "C" }, + { "place": "Sha Tin", "value": 31, "unit": "C" }, + { "place": "Tuen Mun", "value": 28, "unit": "C" }, + { "place": "Tseung Kwan O", "value": 29, "unit": "C" }, + { "place": "Sai Kung", "value": 29, "unit": "C" }, + { "place": "Cheung Chau", "value": 27, "unit": "C" }, + { "place": "Chek Lap Kok", "value": 30, "unit": "C" }, + { "place": "Tsing Yi", "value": 29, "unit": "C" }, + { "place": "Shek Kong", "value": 31, "unit": "C" }, + { "place": "Tsuen Wan Ho Koon", "value": 27, "unit": "C" }, + { "place": "Tsuen Wan Shing Mun Valley", "value": 29, "unit": "C" }, + { "place": "Hong Kong Park", "value": 29, "unit": "C" }, + { "place": "Shau Kei Wan", "value": 29, "unit": "C" }, + { "place": "Kowloon City", "value": 30, "unit": "C" }, + { "place": "Happy Valley", "value": 32, "unit": "C" }, + { "place": "Wong Tai Sin", "value": 31, "unit": "C" }, + { "place": "Stanley", "value": 29, "unit": "C" }, + { "place": "Kwun Tong", "value": 30, "unit": "C" }, + { "place": "Sham Shui Po", "value": 30, "unit": "C" }, + { "place": "Kai Tak Runway Park", "value": 30, "unit": "C" }, + { "place": "Yuen Long Park", "value": 29, "unit": "C" }, + { "place": "Tai Mei Tuk", "value": 29, "unit": "C" } + ], + "recordTime": "2023-08-19T17:00:00+08:00" + }, + "warningMessage": "", + "mintempFrom00To09": "", + "rainfallFrom00To12": "", + "rainfallLastMonth": "", + "rainfallJanuaryToLastMonth": "", + "tcmessage": "", + "humidity": { + "recordTime": "2023-08-19T17:00:00+08:00", + "data": [ + { "unit": "percent", "value": 74, "place": "Hong Kong Observatory" } + ] + } +} diff --git a/tests/components/hko/test_config_flow.py b/tests/components/hko/test_config_flow.py new file mode 100644 index 00000000000..ce32d2cd0da --- /dev/null +++ b/tests/components/hko/test_config_flow.py @@ -0,0 +1,112 @@ +"""Test the Hong Kong Observatory config flow.""" + +from unittest.mock import patch + +from hko import HKOError + +from homeassistant.components.hko.const import DEFAULT_LOCATION, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LOCATION +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_config_flow_default(hass: HomeAssistant) -> None: + """Test user config flow with default fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_LOCATION + assert result2["result"].unique_id == DEFAULT_LOCATION + assert result2["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test user config flow without connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = HKOError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_timeout(hass: HomeAssistant) -> None: + """Test user config flow with timedout connection to the API.""" + with patch("homeassistant.components.hko.config_flow.HKO.weather") as client_mock: + client_mock.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + client_mock.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_LOCATION: DEFAULT_LOCATION}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DEFAULT_LOCATION + assert result["data"][CONF_LOCATION] == DEFAULT_LOCATION + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test user config flow with two equal entries.""" + r1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r1["type"] == FlowResultType.FORM + assert r1["step_id"] == SOURCE_USER + assert "flow_id" in r1 + result1 = await hass.config_entries.flow.async_configure( + r1["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result1["type"] == FlowResultType.CREATE_ENTRY + + r2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert r2["type"] == FlowResultType.FORM + assert r2["step_id"] == SOURCE_USER + assert "flow_id" in r2 + result2 = await hass.config_entries.flow.async_configure( + r2["flow_id"], + user_input={CONF_LOCATION: DEFAULT_LOCATION}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" From 8bbfee7801ce015180f05b97de9c3331b541c090 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 5 Jan 2024 15:44:31 +0100 Subject: [PATCH 0283/1544] Make exceptions in rest_command services translatable (#107252) --- .../components/rest_command/__init__.py | 37 +++++++++++-------- .../components/rest_command/strings.json | 11 ++++++ tests/components/rest_command/test_init.py | 24 +++++++++--- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index a07ca03a258..7d566933b5f 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -169,28 +169,35 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: _content = await response.text() except (JSONDecodeError, AttributeError) as err: - _LOGGER.error("Response of `%s` has invalid JSON", request_url) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Response of '{request_url}' could not be decoded as JSON", + translation_domain=DOMAIN, + translation_key="decoding_error", + translation_placeholders={"decoding_type": "json"}, + ) from err except UnicodeDecodeError as err: - _LOGGER.error( - "Response of `%s` could not be interpreted as text", - request_url, - ) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Response of '{request_url}' could not be decoded as text", + translation_domain=DOMAIN, + translation_key="decoding_error", + translation_placeholders={"decoding_type": "text"}, + ) from err return {"content": _content, "status": response.status} except asyncio.TimeoutError as err: - _LOGGER.warning("Timeout call %s", request_url) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Timeout when calling resource '{request_url}'", + translation_domain=DOMAIN, + translation_key="timeout", + ) from err except aiohttp.ClientError as err: - _LOGGER.error( - "Client error. Url: %s. Error: %s", - request_url, - err, - ) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Client error occurred when calling resource '{request_url}'", + translation_domain=DOMAIN, + translation_key="client_error", + ) from err # register services hass.services.async_register( diff --git a/homeassistant/components/rest_command/strings.json b/homeassistant/components/rest_command/strings.json index 15f59ec8e29..8a48cddace3 100644 --- a/homeassistant/components/rest_command/strings.json +++ b/homeassistant/components/rest_command/strings.json @@ -4,5 +4,16 @@ "name": "[%key:common::action::reload%]", "description": "Reloads RESTful commands from the YAML-configuration." } + }, + "exceptions": { + "timeout": { + "message": "Timeout while waiting for response from the server" + }, + "client_error": { + "message": "An error occurred while requesting the resource" + }, + "decoding_error": { + "message": "The response from the server could not be decoded as {decoding_type}" + } } } diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index ce0359e0fdb..0e70f7bc52d 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -115,8 +115,11 @@ class TestRestCommandComponent: aioclient_mock.get(self.url, exc=asyncio.TimeoutError()) - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() + with pytest.raises( + HomeAssistantError, + match=r"^Timeout when calling resource 'https://example.com/'$", + ): + self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) assert len(aioclient_mock.mock_calls) == 1 @@ -127,8 +130,11 @@ class TestRestCommandComponent: aioclient_mock.get(self.url, exc=aiohttp.ClientError()) - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() + with pytest.raises( + HomeAssistantError, + match=r"^Client error occurred when calling resource 'https://example.com/'$", + ): + self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) assert len(aioclient_mock.mock_calls) == 1 @@ -412,7 +418,10 @@ class TestRestCommandComponent: assert not response # Throws error when requesting response - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as JSON$", + ): response = self.hass.services.call( rc.DOMAIN, "get_test", {}, blocking=True, return_response=True ) @@ -439,7 +448,10 @@ class TestRestCommandComponent: assert not response # Throws Decode error when requesting response - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as text$", + ): response = self.hass.services.call( rc.DOMAIN, "get_test", {}, blocking=True, return_response=True ) From d754ea7e225dfb9dba29fee8aa40e3d3073af3a1 Mon Sep 17 00:00:00 2001 From: Alexander Somov Date: Fri, 5 Jan 2024 18:34:28 +0300 Subject: [PATCH 0284/1544] Add new Rabbit Air integration (#66130) * Add new Rabbit Air integration * Remove py.typed file It is not needed and was just accidentally added to the commit. * Enable strict type checking for rabbitair component Keeping the code fully type hinted is a good idea. * Add missing type annotations * Remove translation file * Prevent data to be added to hass.data if refresh fails * Reload the config entry when the options change * Add missing type parameters for generics * Avoid using assert in production code * Move zeroconf to optional dependencies * Remove unnecessary logging Co-authored-by: Franck Nijhof * Remove unused keys from the manifest Co-authored-by: Franck Nijhof * Replace property with attr Co-authored-by: Franck Nijhof * Allow to return None for power The type of the is_on property now allows this. Co-authored-by: Franck Nijhof * Remove unnecessary method call Co-authored-by: Franck Nijhof * Update the python library The new version properly re-exports names from the package root. * Remove options flow Scan interval should not be part of integration configuration. This was the only option, so the options flow can be fully removed. * Replace properties with attrs * Remove multiline ternary operator * Use NamedTuple for hass.data * Remove unused logger variable * Move async_setup_entry up in the file * Adjust debouncer settings to use request_refresh * Prevent status updates during the cooldown period * Move device polling code to the update coordinator * Fix the problem with the switch jumping back and forth The UI seems to have a timeout of 2 seconds somewhere, which is just a little bit less than what we normally need to get an updated state. So the power switch would jump to its previous state and then immediately return to the new state. * Update the python library The new version fixes errors when multiple requests are executed simultaneously. * Fix incorrect check for pending call in debouncer This caused the polling to stop. * Fix tests * Update .coveragerc to exclude new file. * Remove test for Options Flow. * Update the existing entry when device access details change * Add Zeroconf discovery step * Fix tests The ZeroconfServiceInfo constructor now requires one more argument. * Fix typing for CoordinatorEntity * Fix signature of async_turn_on * Fix depreciation warnings * Fix manifest formatting * Fix warning about debouncer typing relates to 5ae5ae5392729b4c94a8004bd02e147d60227341 * Wait for config entry platform forwards * Apply some of the suggested changes * Do not put the MAC address in the title. Use a fixed title instead. * Do not format the MAC to use as a unique ID. * Do not catch exceptions in _async_update_data(). * Remove unused _entry field in the base entity class. * Use the standard attribute self._attr_is_on to keep the power state. * Store the MAC in the config entry data * Change the order of except clauses OSError is an ancestor class of TimeoutError, so TimeoutError should be handled first * Fix depreciation warnings * Fix tests The ZeroconfServiceInfo constructor arguments have changed. * Fix DeviceInfo import * Rename the method to make it clearer what it does * Apply suggestions from code review * Fix tests * Change speed/mode logic to use is_on from the base class * A zero value is more appropriate than None since None means "unknown", but we actually know that the speed is zero when the power is off. --------- Co-authored-by: Franck Nijhof Co-authored-by: Erik Montnemery --- .coveragerc | 5 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/rabbitair/__init__.py | 51 +++++ .../components/rabbitair/config_flow.py | 126 +++++++++++ homeassistant/components/rabbitair/const.py | 3 + .../components/rabbitair/coordinator.py | 74 ++++++ homeassistant/components/rabbitair/entity.py | 62 ++++++ homeassistant/components/rabbitair/fan.py | 147 ++++++++++++ .../components/rabbitair/manifest.json | 11 + .../components/rabbitair/strings.json | 22 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/rabbitair/__init__.py | 1 + .../components/rabbitair/test_config_flow.py | 210 ++++++++++++++++++ 19 files changed, 743 insertions(+) create mode 100644 homeassistant/components/rabbitair/__init__.py create mode 100644 homeassistant/components/rabbitair/config_flow.py create mode 100644 homeassistant/components/rabbitair/const.py create mode 100644 homeassistant/components/rabbitair/coordinator.py create mode 100644 homeassistant/components/rabbitair/entity.py create mode 100644 homeassistant/components/rabbitair/fan.py create mode 100644 homeassistant/components/rabbitair/manifest.json create mode 100644 homeassistant/components/rabbitair/strings.json create mode 100644 tests/components/rabbitair/__init__.py create mode 100644 tests/components/rabbitair/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index b2290556519..5e389b0e58f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1002,6 +1002,11 @@ omit = homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py homeassistant/components/qvr_pro/* + homeassistant/components/rabbitair/__init__.py + homeassistant/components/rabbitair/const.py + homeassistant/components/rabbitair/coordinator.py + homeassistant/components/rabbitair/entity.py + homeassistant/components/rabbitair/fan.py homeassistant/components/rachio/__init__.py homeassistant/components/rachio/binary_sensor.py homeassistant/components/rachio/device.py diff --git a/.strict-typing b/.strict-typing index be595c52a23..67d7866ba62 100644 --- a/.strict-typing +++ b/.strict-typing @@ -307,6 +307,7 @@ homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* +homeassistant.components.rabbitair.* homeassistant.components.radarr.* homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* diff --git a/CODEOWNERS b/CODEOWNERS index f52d810958b..26096d2247a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1036,6 +1036,8 @@ build.json @home-assistant/supervisor /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza +/homeassistant/components/rabbitair/ @rabbit-air +/tests/components/rabbitair/ @rabbit-air /homeassistant/components/rachio/ @bdraco @rfverbruggen /tests/components/rachio/ @bdraco @rfverbruggen /homeassistant/components/radarr/ @tkdrob diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py new file mode 100644 index 00000000000..97b37f6c03f --- /dev/null +++ b/homeassistant/components/rabbitair/__init__.py @@ -0,0 +1,51 @@ +"""The Rabbit Air integration.""" +from __future__ import annotations + +from rabbitair import Client, UdpClient + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.FAN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rabbit Air from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + host: str = entry.data[CONF_HOST] + token: str = entry.data[CONF_ACCESS_TOKEN] + + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + device: Client = UdpClient(host, token, zeroconf=zeroconf_instance) + + coordinator = RabbitAirDataUpdateCoordinator(hass, device) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py new file mode 100644 index 00000000000..70cd07f4d91 --- /dev/null +++ b/homeassistant/components/rabbitair/config_flow.py @@ -0,0 +1,126 @@ +"""Config flow for Rabbit Air integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from rabbitair import UdpClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + try: + try: + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + with UdpClient( + data[CONF_HOST], data[CONF_ACCESS_TOKEN], zeroconf=zeroconf_instance + ) as client: + info = await client.get_info() + except Exception as err: + _LOGGER.debug("Connection attempt failed: %s", err) + raise + except ValueError as err: + # Most likely caused by the invalid access token. + raise InvalidAccessToken from err + except asyncio.TimeoutError as err: + # Either the host doesn't respond or the auth failed. + raise TimeoutConnect from err + except OSError as err: + # Most likely caused by the invalid host. + raise InvalidHost from err + except Exception as err: + # Other possible errors. + raise CannotConnect from err + + # Return info to store in the config entry. + return {"mac": info.mac} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rabbit Air.""" + + VERSION = 1 + + _discovered_host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAccessToken: + errors["base"] = "invalid_access_token" + except InvalidHost: + errors["base"] = "invalid_host" + except TimeoutConnect: + errors["base"] = "timeout_connect" + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + user_input[CONF_MAC] = info["mac"] + await self.async_set_unique_id(dr.format_mac(info["mac"])) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry(title="Rabbit Air", data=user_input) + + user_input = user_input or {} + host = user_input.get(CONF_HOST, self._discovered_host) + token = user_input.get(CONF_ACCESS_TOKEN) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_ACCESS_TOKEN, default=token): vol.All( + str, vol.Length(min=32, max=32) + ), + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + mac = dr.format_mac(discovery_info.properties["id"]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + self._discovered_host = discovery_info.hostname.rstrip(".") + return await self.async_step_user() + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAccessToken(HomeAssistantError): + """Error to indicate the access token is not valid.""" + + +class InvalidHost(HomeAssistantError): + """Error to indicate the host is not valid.""" + + +class TimeoutConnect(HomeAssistantError): + """Error to indicate the connection attempt is timed out.""" diff --git a/homeassistant/components/rabbitair/const.py b/homeassistant/components/rabbitair/const.py new file mode 100644 index 00000000000..8428570faaa --- /dev/null +++ b/homeassistant/components/rabbitair/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rabbit Air integration.""" + +DOMAIN = "rabbitair" diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py new file mode 100644 index 00000000000..36c58f8700c --- /dev/null +++ b/homeassistant/components/rabbitair/coordinator.py @@ -0,0 +1,74 @@ +"""Rabbit Air Update Coordinator.""" +from collections.abc import Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from rabbitair import Client, State + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize debounce.""" + # We don't want an immediate refresh since the device needs some time + # to apply the changes and reflect the updated state. Two seconds + # should be sufficient, since the internal cycle of the device runs at + # one-second intervals. + super().__init__(hass, _LOGGER, cooldown=2.0, immediate=False) + + async def async_call(self) -> None: + """Call the function.""" + # Restart the timer. + self.async_cancel() + await super().async_call() + + def has_pending_call(self) -> bool: + """Indicate that the debouncer has a call waiting for cooldown.""" + return self._execute_at_end_of_timer + + +class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): + """Class to manage fetching data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, device: Client) -> None: + """Initialize global data updater.""" + self.device = device + super().__init__( + hass, + _LOGGER, + name="rabbitair", + update_interval=timedelta(seconds=10), + request_refresh_debouncer=RabbitAirDebouncer(hass), + ) + + async def _async_update_data(self) -> State: + return await self.device.get_state() + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + + # Skip a scheduled refresh if there is a pending requested refresh. + debouncer = cast(RabbitAirDebouncer, self._debounced_refresh) + if scheduled and debouncer.has_pending_call(): + return + + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) diff --git a/homeassistant/components/rabbitair/entity.py b/homeassistant/components/rabbitair/entity.py new file mode 100644 index 00000000000..07e49aae7cb --- /dev/null +++ b/homeassistant/components/rabbitair/entity.py @@ -0,0 +1,62 @@ +"""A base class for Rabbit Air entities.""" +from __future__ import annotations + +import logging +from typing import Any + +from rabbitair import Model + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +MODELS = { + Model.A3: "A3", + Model.BioGS: "BioGS 2.0", + Model.MinusA2: "MinusA2", + None: None, +} + + +class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]): + """Base class for Rabbit Air entity.""" + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_name = entry.title + self._attr_unique_id = entry.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.data[CONF_MAC])}, + manufacturer="Rabbit Air", + model=MODELS.get(coordinator.data.model), + name=entry.title, + sw_version=coordinator.data.wifi_firmware, + hw_version=coordinator.data.main_firmware, + ) + + def _is_model(self, model: Model | list[Model]) -> bool: + """Check the model of the device.""" + if isinstance(model, list): + return self.coordinator.data.model in model + return self.coordinator.data.model is model + + async def _set_state(self, **kwargs: Any) -> None: + """Change the state of the device.""" + _LOGGER.debug("Set state %s", kwargs) + await self.coordinator.device.set_state(**kwargs) + # Force polling of the device, because changing one parameter often + # causes other parameters to change as well. By getting updated status + # we provide a better user experience, especially if the default + # polling interval is set too long. + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py new file mode 100644 index 00000000000..46465163839 --- /dev/null +++ b/homeassistant/components/rabbitair/fan.py @@ -0,0 +1,147 @@ +"""Support for Rabbit Air fan entity.""" +from __future__ import annotations + +from typing import Any + +from rabbitair import Mode, Model, Speed + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator +from .entity import RabbitAirBaseEntity + +SPEED_LIST = [ + Speed.Silent, + Speed.Low, + Speed.Medium, + Speed.High, + Speed.Turbo, +] + +PRESET_MODE_AUTO = "Auto" +PRESET_MODE_MANUAL = "Manual" +PRESET_MODE_POLLEN = "Pollen" + +PRESET_MODES = { + PRESET_MODE_AUTO: Mode.Auto, + PRESET_MODE_MANUAL: Mode.Manual, + PRESET_MODE_POLLEN: Mode.Pollen, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RabbitAirFanEntity(coordinator, entry)]) + + +class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): + """Fan control functions of the Rabbit Air air purifier.""" + + _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, entry) + + if self._is_model(Model.MinusA2): + self._attr_preset_modes = list(PRESET_MODES) + elif self._is_model(Model.A3): + # A3 does not support Pollen mode + self._attr_preset_modes = [ + k for k in PRESET_MODES if k != PRESET_MODE_POLLEN + ] + + self._attr_speed_count = len(SPEED_LIST) + + self._get_state_from_coordinator_data() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._get_state_from_coordinator_data() + super()._handle_coordinator_update() + + def _get_state_from_coordinator_data(self) -> None: + """Populate the entity fields with values from the coordinator data.""" + data = self.coordinator.data + + # Speed as a percentage + if not data.power: + self._attr_percentage = 0 + elif data.speed is None: + self._attr_percentage = None + elif data.speed is Speed.SuperSilent: + self._attr_percentage = 1 + else: + self._attr_percentage = ordered_list_item_to_percentage( + SPEED_LIST, data.speed + ) + + # Preset mode + if not data.power or data.mode is None: + self._attr_preset_mode = None + else: + # Get key by value in dictionary + self._attr_preset_mode = next( + k for k, v in PRESET_MODES.items() if v == data.mode + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._set_state(power=True, mode=PRESET_MODES[preset_mode]) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage > 0: + value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, speed=value) + self._attr_percentage = percentage + else: + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode_value: Mode | None = None + if preset_mode is not None: + mode_value = PRESET_MODES[preset_mode] + speed_value: Speed | None = None + if percentage is not None: + speed_value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, mode=mode_value, speed=speed_value) + if percentage is not None: + self._attr_percentage = percentage + if preset_mode is not None: + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() diff --git a/homeassistant/components/rabbitair/manifest.json b/homeassistant/components/rabbitair/manifest.json new file mode 100644 index 00000000000..8f4df8afb7b --- /dev/null +++ b/homeassistant/components/rabbitair/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "rabbitair", + "name": "Rabbit Air", + "after_dependencies": ["zeroconf"], + "codeowners": ["@rabbit-air"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rabbitair", + "iot_class": "local_polling", + "requirements": ["python-rabbitair==0.0.8"], + "zeroconf": ["_rabbitair._udp.local."] +} diff --git a/homeassistant/components/rabbitair/strings.json b/homeassistant/components/rabbitair/strings.json new file mode 100644 index 00000000000..dd44a51d48f --- /dev/null +++ b/homeassistant/components/rabbitair/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8a71d51acf2..6a387da0d42 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -394,6 +394,7 @@ FLOWS = { "qingping", "qnap", "qnap_qsw", + "rabbitair", "rachio", "radarr", "radio_browser", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4738de291fa..a7cfea03be7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4664,6 +4664,12 @@ "config_flow": false, "iot_class": "local_push" }, + "rabbitair": { + "name": "Rabbit Air", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rachio": { "name": "Rachio", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fea1d4ec889..21d44317161 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -621,6 +621,11 @@ ZEROCONF = { "name": "brother*", }, ], + "_rabbitair._udp.local.": [ + { + "domain": "rabbitair", + }, + ], "_raop._tcp.local.": [ { "domain": "apple_tv", diff --git a/mypy.ini b/mypy.ini index 6e2630813e6..8d621949810 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2831,6 +2831,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rabbitair.*] +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.radarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8d68009e180..522c99dd572 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,6 +2237,9 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.ripple python-ripple-api==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7f526ad15f..bd030a294bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1695,6 +1695,9 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.roborock python-roborock==0.38.0 diff --git a/tests/components/rabbitair/__init__.py b/tests/components/rabbitair/__init__.py new file mode 100644 index 00000000000..04fae763f56 --- /dev/null +++ b/tests/components/rabbitair/__init__.py @@ -0,0 +1 @@ +"""Tests for the RabbitAir integration.""" diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py new file mode 100644 index 00000000000..75b97d01065 --- /dev/null +++ b/tests/components/rabbitair/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test the RabbitAir config flow.""" +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from ipaddress import ip_address +from unittest.mock import Mock, patch + +import pytest +from rabbitair import Mode, Model, Speed + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.rabbitair.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +TEST_HOST = "1.1.1.1" +TEST_NAME = "abcdef1234_123456789012345678" +TEST_TOKEN = "0123456789abcdef0123456789abcdef" +TEST_MAC = "01:23:45:67:89:AB" +TEST_FIRMWARE = "2.3.17" +TEST_HARDWARE = "1.0.0.4" +TEST_UNIQUE_ID = format_mac(TEST_MAC) +TEST_TITLE = "Rabbit Air" + +ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], + port=9009, + hostname=f"{TEST_NAME}.local.", + type="_rabbitair._udp.local.", + name=f"{TEST_NAME}._rabbitair._udp.local.", + properties={"id": TEST_MAC.replace(":", "")}, +) + + +@pytest.fixture(autouse=True) +def use_mocked_zeroconf(mock_async_zeroconf): + """Mock zeroconf in all tests.""" + + +@pytest.fixture +def rabbitair_connect() -> Generator[None, None, None]: + """Mock connection.""" + with patch("rabbitair.UdpClient.get_info", return_value=get_mock_info()), patch( + "rabbitair.UdpClient.get_state", return_value=get_mock_state() + ): + yield + + +def get_mock_info(mac: str = TEST_MAC) -> Mock: + """Return a mock device info instance.""" + mock_info = Mock() + mock_info.mac = mac + return mock_info + + +def get_mock_state( + model: Model | None = Model.A3, + main_firmware: str | None = TEST_HARDWARE, + power: bool | None = True, + mode: Mode | None = Mode.Auto, + speed: Speed | None = Speed.Low, + wifi_firmware: str | None = TEST_FIRMWARE, +) -> Mock: + """Return a mock device state instance.""" + mock_state = Mock() + mock_state.model = model + mock_state.main_firmware = main_firmware + mock_state.power = power + mock_state.mode = mode + mock_state.speed = speed + mock_state.wifi_firmware = wifi_firmware + return mock_state + + +@pytest.mark.usefixtures("rabbitair_connect") +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "base_value"), + [ + (ValueError, "invalid_access_token"), + (OSError, "invalid_host"), + (asyncio.TimeoutError, "timeout_connect"), + (Exception, "cannot_connect"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, error_type: type[Exception], base_value: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "rabbitair.UdpClient.get_info", + side_effect=error_type, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": base_value} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("rabbitair_connect") +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """Test zeroconf discovery setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 80a08199f89b06ab504dc38527cdbe41d76bb262 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 5 Jan 2024 16:35:20 +0100 Subject: [PATCH 0285/1544] Update Home Assistant Wheels action to 2024.01.0 (#107240) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d5c82c37e60..ded7e5d9adc 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -198,7 +198,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -213,7 +213,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -227,7 +227,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -241,7 +241,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.5 + uses: home-assistant/wheels@2024.01.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From be6cf7d3ae9eaeea746eb511ae27ead11f535613 Mon Sep 17 00:00:00 2001 From: wilburCforce <109390391+wilburCforce@users.noreply.github.com> Date: Fri, 5 Jan 2024 09:39:14 -0600 Subject: [PATCH 0286/1544] Add Lutron config flow (#98489) * rough in structure for config_flow * updated json files * initial conversion to config_flow * minor updates * Update binary_sensor.py * Update config_flow.py * Update __init__.py * updates beased on requested changes * Update const.py added doc note for ruff * updated based on suggestions * updated load xmldb for efficiency * updated references * removed unneeded file * updated config flow to use GUID from XML DB * minor update to change logging * updated based on PR feedback * reverted change for line 30 based on testing * corrected user_input * updated based on latest comments * exception handling * added raising of issues for config flow * updated issues strings * config flow test shell - needs work * minor changes * Update strings.json * Update config_flow.py * Update __init__.py * Create conftest.py * Update test_config_flow.py * Update test_config_flow.py * Update config_flow.py * Update strings.json * Update requirements_test_all.txt * Update strings.json * Update strings.json * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Create test_init.py * Update __init__.py * Delete tests/components/lutron/test_init.py * Update strings.json * updated import parts and tested * updated strings to improve user feedback --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 1 + homeassistant/components/lutron/__init__.py | 92 ++++++-- .../components/lutron/binary_sensor.py | 38 ++-- .../components/lutron/config_flow.py | 107 +++++++++ homeassistant/components/lutron/const.py | 3 + homeassistant/components/lutron/cover.py | 27 ++- homeassistant/components/lutron/light.py | 33 +-- homeassistant/components/lutron/manifest.json | 1 + homeassistant/components/lutron/scene.py | 24 +- homeassistant/components/lutron/strings.json | 35 +++ homeassistant/components/lutron/switch.py | 37 +-- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/lutron/__init__.py | 1 + tests/components/lutron/conftest.py | 15 ++ tests/components/lutron/test_config_flow.py | 214 ++++++++++++++++++ 17 files changed, 549 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/lutron/config_flow.py create mode 100644 homeassistant/components/lutron/const.py create mode 100644 homeassistant/components/lutron/strings.json create mode 100644 tests/components/lutron/__init__.py create mode 100644 tests/components/lutron/conftest.py create mode 100644 tests/components/lutron/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 26096d2247a..d4fd1302e46 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -748,6 +748,7 @@ build.json @home-assistant/supervisor /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lupusec/ @majuss /homeassistant/components/lutron/ @cdheiser +/tests/components/lutron/ @cdheiser /homeassistant/components/lutron_caseta/ @swails @bdraco @danaues /tests/components/lutron_caseta/ @swails @bdraco @danaues /homeassistant/components/lyric/ @timmo001 diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index c15f0ea075e..4d6dd795d75 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,6 +4,8 @@ import logging from pylutron import Button, Lutron import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ID, CONF_HOST, @@ -11,14 +13,15 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -DOMAIN = "lutron" +from .const import DOMAIN PLATFORMS = [ Platform.LIGHT, @@ -53,8 +56,59 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: +async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: + """Import a config entry from configuration.yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=base_config[DOMAIN], + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "single_instance_allowed" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Lutron", + }, + ) + return + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Lutron", + }, + ) + + +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Set up the Lutron component.""" + if DOMAIN in base_config: + hass.async_create_task(_async_import(hass, base_config)) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Lutron integration.""" + hass.data.setdefault(DOMAIN, {}) hass.data[LUTRON_BUTTONS] = [] hass.data[LUTRON_CONTROLLER] = None hass.data[LUTRON_DEVICES] = { @@ -64,19 +118,25 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: "scene": [], "binary_sensor": [], } + host = config_entry.data[CONF_HOST] + uid = config_entry.data[CONF_USERNAME] + pwd = config_entry.data[CONF_PASSWORD] - config = base_config[DOMAIN] - hass.data[LUTRON_CONTROLLER] = Lutron( - config[CONF_HOST], config[CONF_USERNAME], config[CONF_PASSWORD] - ) + def _load_db() -> bool: + hass.data[LUTRON_CONTROLLER].load_xml_db() + return True - hass.data[LUTRON_CONTROLLER].load_xml_db() + hass.data[LUTRON_CONTROLLER] = Lutron(host, uid, pwd) + await hass.async_add_executor_job(_load_db) hass.data[LUTRON_CONTROLLER].connect() - _LOGGER.info("Connected to main repeater at %s", config[CONF_HOST]) + _LOGGER.info("Connected to main repeater at %s", host) # Sort our devices into types + _LOGGER.debug("Start adding devices") for area in hass.data[LUTRON_CONTROLLER].areas: + _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: + _LOGGER.debug("Working on output %s", output.type) if output.type == "SYSTEM_SHADE": hass.data[LUTRON_DEVICES]["cover"].append((area.name, output)) elif output.is_dimmable: @@ -108,18 +168,22 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: hass.data[LUTRON_DEVICES]["binary_sensor"].append( (area.name, area.occupancy_group) ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, base_config) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Clean up resources and entities associated with the integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + class LutronDevice(Entity): """Representation of a Lutron device entity.""" _attr_should_poll = False - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the device.""" self._lutron_device = lutron_device self._controller = controller @@ -155,7 +219,7 @@ class LutronButton: represented as an entity; it simply fires events. """ - def __init__(self, hass, area_name, keypad, button): + def __init__(self, hass: HomeAssistant, area_name, keypad, button) -> None: """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" if button.name == "Unknown Button": diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 9f9851fb484..1b32b009f01 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -1,34 +1,42 @@ """Support for Lutron Powr Savr occupancy sensors.""" from __future__ import annotations +from collections.abc import Mapping +import logging +from typing import Any + from pylutron import OccupancyGroup from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +_LOGGER = logging.getLogger(__name__) -def setup_platform( + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Lutron occupancy sensors.""" - if discovery_info is None: - return - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: - dev = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron binary_sensor platform. - add_entities(devs) + Adds occupancy groups from the Main Repeater associated with the + config_entry as binary_sensor entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: + entity = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) class LutronOccupancySensor(LutronDevice, BinarySensorEntity): @@ -42,13 +50,13 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" # Error cases will end up treated as unoccupied. return self._lutron_device.state == OccupancyGroup.State.OCCUPIED @property - def name(self): + def name(self) -> str: """Return the name of the device.""" # The default LutronDevice naming would create 'Kitchen Occ Kitchen', # but since there can only be one OccupancyGroup per area we go @@ -56,6 +64,6 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): return f"{self._area_name} Occupancy" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py new file mode 100644 index 00000000000..04628849230 --- /dev/null +++ b/homeassistant/components/lutron/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow to configure the Lutron integration.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.error import HTTPError + +from pylutron import Lutron +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LutronConfigFlow(ConfigFlow, domain=DOMAIN): + """User prompt for Main Repeater configuration information.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """First step in the config flow.""" + + # Check if a configuration entry already exists + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + ip_address = user_input[CONF_HOST] + + main_repeater = Lutron( + ip_address, + user_input.get(CONF_USERNAME), + user_input.get(CONF_PASSWORD), + ) + + try: + await self.hass.async_add_executor_job(main_repeater.load_xml_db) + except HTTPError: + _LOGGER.exception("Http error") + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + guid = main_repeater.guid + + if len(guid) <= 10: + errors["base"] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Lutron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="lutron"): str, + vol.Required(CONF_PASSWORD, default="integration"): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Attempt to import the existing configuration.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + main_repeater = Lutron( + import_config[CONF_HOST], + import_config[CONF_USERNAME], + import_config[CONF_PASSWORD], + ) + + def _load_db() -> None: + main_repeater.load_xml_db() + + try: + await self.hass.async_add_executor_job(_load_db) + except HTTPError: + _LOGGER.exception("Http error") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return self.async_abort(reason="unknown") + + guid = main_repeater.guid + + if len(guid) <= 10: + return self.async_abort(reason="cannot_connect") + _LOGGER.debug("Main Repeater GUID: %s", main_repeater.guid) + + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Lutron", data=import_config) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py new file mode 100644 index 00000000000..3862f7eb1d8 --- /dev/null +++ b/homeassistant/components/lutron/const.py @@ -0,0 +1,3 @@ +"""Lutron constants.""" + +DOMAIN = "lutron" diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 57fd8ac9d5b..a3b977b9bb3 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -1,6 +1,7 @@ """Support for Lutron shades.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,28 +10,30 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice _LOGGER = logging.getLogger(__name__) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron shades.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: - dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron cover platform. - add_entities(devs, True) + Adds shades from the Main Repeater associated with the config_entry as + cover entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: + entity = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) class LutronCover(LutronDevice, CoverEntity): @@ -73,6 +76,6 @@ class LutronCover(LutronDevice, CoverEntity): _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 6bd556d36d1..c6e54675ffd 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -1,29 +1,32 @@ """Support for Lutron lights.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron lights.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["light"]: - dev = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron light platform. - add_entities(devs, True) + Adds dimmers from the Main Repeater associated with the config_entry as + light entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["light"]: + entity = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) def to_lutron_level(level): @@ -42,13 +45,13 @@ class LutronLight(LutronDevice, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the light.""" self._prev_brightness = None super().__init__(area_name, lutron_device, controller) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" new_brightness = to_hass_level(self._lutron_device.last_level()) if new_brightness != 0: @@ -71,12 +74,12 @@ class LutronLight(LutronDevice, LightEntity): self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_level() > 0 diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 029e18d574a..83b391fa9b5 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -2,6 +2,7 @@ "domain": "lutron", "name": "Lutron", "codeowners": ["@cdheiser"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index f2d008a1187..ed4e28d945a 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -4,29 +4,31 @@ from __future__ import annotations from typing import Any from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron scenes.""" - devs = [] + """Set up the Lutron scene platform. + + Adds scenes from the Main Repeater associated with the config_entry as + scene entities. + """ + entities = [] for scene_data in hass.data[LUTRON_DEVICES]["scene"]: (area_name, keypad_name, device, led) = scene_data - dev = LutronScene( + entity = LutronScene( area_name, keypad_name, device, led, hass.data[LUTRON_CONTROLLER] ) - devs.append(dev) - - add_entities(devs, True) + entities.append(entity) + async_add_entities(entities, True) class LutronScene(LutronDevice, Scene): diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json new file mode 100644 index 00000000000..20a8d9bf971 --- /dev/null +++ b/homeassistant/components/lutron/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of the Lutron main repeater." + }, + "description": "Please enter the main repeater login information", + "title": "Main repeater setup" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Lutron YAML configuration import cannot connect to server", + "description": "Configuring Lutron using YAML is being removed but there was an connection error importing your YAML configuration.\n\nThings you can try:\nMake sure your home assistant can reach the main repeater.\nRestart the main repeater by unplugging it for 60 seconds.\nTry logging into the main repeater at the IP address you specified in a web browser and the same login information.\n\nThen restart Home Assistant to try importing this integration again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Lutron YAML configuration import request failed due to an unknown error", + "description": "Configuring Lutron using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nThe specific error can be found in the logs. The most likely cause is a networking error or the Main Repeater is down or has an invalid configuration.\n\nVerify that your Lutron system is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + } + } +} diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 7d33a822087..572b599787a 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -1,29 +1,33 @@ """Support for Lutron switches.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron switches.""" - devs = [] + """Set up the Lutron switch platform. + + Adds switches from the Main Repeater associated with the config_entry as + switch entities. + """ + entities = [] # Add Lutron Switches for area_name, device in hass.data[LUTRON_DEVICES]["switch"]: - dev = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + entity = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) # Add the indicator LEDs for scenes (keypad buttons) for scene_data in hass.data[LUTRON_DEVICES]["scene"]: @@ -32,15 +36,14 @@ def setup_platform( led = LutronLed( area_name, keypad_name, scene, led, hass.data[LUTRON_CONTROLLER] ) - devs.append(led) - - add_entities(devs, True) + entities.append(led) + async_add_entities(entities, True) class LutronSwitch(LutronDevice, SwitchEntity): """Representation of a Lutron Switch.""" - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the switch.""" self._prev_state = None super().__init__(area_name, lutron_device, controller) @@ -54,12 +57,12 @@ class LutronSwitch(LutronDevice, SwitchEntity): self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_level() > 0 @@ -87,7 +90,7 @@ class LutronLed(LutronDevice, SwitchEntity): self._lutron_device.state = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return { "keypad": self._keypad_name, @@ -96,7 +99,7 @@ class LutronLed(LutronDevice, SwitchEntity): } @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_state diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6a387da0d42..f04ec579f91 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = { "lookin", "loqed", "luftdaten", + "lutron", "lutron_caseta", "lyric", "mailgun", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a7cfea03be7..5a66e7e1f44 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3280,7 +3280,7 @@ "integrations": { "lutron": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Lutron" }, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd030a294bb..b625bd390e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1450,6 +1450,9 @@ pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.3 +# homeassistant.components.lutron +pylutron==0.2.8 + # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/tests/components/lutron/__init__.py b/tests/components/lutron/__init__.py new file mode 100644 index 00000000000..6ffe0ee7d94 --- /dev/null +++ b/tests/components/lutron/__init__.py @@ -0,0 +1 @@ +"""Test for the lutron integration.""" diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py new file mode 100644 index 00000000000..e94e337ce1d --- /dev/null +++ b/tests/components/lutron/conftest.py @@ -0,0 +1,15 @@ +"""Provide common Lutron fixtures and mocks.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lutron.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py new file mode 100644 index 00000000000..feb5c77c9be --- /dev/null +++ b/tests/components/lutron/test_config_flow.py @@ -0,0 +1,214 @@ +"""Test the lutron config flow.""" +from unittest.mock import AsyncMock, patch +from urllib.error import HTTPError + +import pytest + +from homeassistant.components.lutron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (HTTPError("", 404, "", None, {}), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_incorrect_guid( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test configuring flow with incorrect guid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_flow_single_instance_allowed(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA_STEP, unique_id="12345678901") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +MOCK_DATA_IMPORT = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "reason"), + [ + (HTTPError("", 404, "", None, {}), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_flow_failure( + hass: HomeAssistant, raise_error: Exception, reason: str +) -> None: + """Test handling errors while importing.""" + + with patch( + "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_import_flow_guid_failure(hass: HomeAssistant) -> None: + """Test handling errors while importing.""" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "123" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_DATA_IMPORT, unique_id="12345678901" + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" From 9be9bbad6163fce7db10521ac7d4e11f393ac5ef Mon Sep 17 00:00:00 2001 From: SLaks Date: Fri, 5 Jan 2024 10:46:12 -0500 Subject: [PATCH 0287/1544] Allow selecting of counter entities in derivative/integration config flow (#105321) --- homeassistant/components/derivative/config_flow.py | 5 ++++- homeassistant/components/derivative/manifest.json | 1 + homeassistant/components/integration/config_flow.py | 5 ++++- homeassistant/components/integration/manifest.json | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 92fff3730a9..3b0b2425aac 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME, CONF_SOURCE, UnitOfTime @@ -66,7 +67,9 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]), + selector.EntitySelectorConfig( + domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] + ), ), } ).extend(OPTIONS_SCHEMA.schema) diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index 68f74dc2858..e1d8986c2dd 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -1,6 +1,7 @@ { "domain": "derivative", "name": "Derivative", + "after_dependencies": ["counter"], "codeowners": ["@afaucogney"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/derivative", diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 0b1eda7201e..3a9e1d15ffe 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_METHOD, CONF_NAME, UnitOfTime @@ -58,7 +59,9 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]) + selector.EntitySelectorConfig( + domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] + ), ), vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.SelectSelector( selector.SelectSelectorConfig( diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json index 5c15b33a34a..9e5c597bd1a 100644 --- a/homeassistant/components/integration/manifest.json +++ b/homeassistant/components/integration/manifest.json @@ -1,6 +1,7 @@ { "domain": "integration", "name": "Integration - Riemann sum integral", + "after_dependencies": ["counter"], "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/integration", From 3d338b57193fcf259a231095ae89d1852a4d53b5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 17:31:50 +0100 Subject: [PATCH 0288/1544] Sort Lutron platforms (#107257) --- homeassistant/components/lutron/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 4d6dd795d75..3a24c0eb0e8 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -24,11 +24,11 @@ from homeassistant.util import slugify from .const import DOMAIN PLATFORMS = [ - Platform.LIGHT, - Platform.COVER, - Platform.SWITCH, - Platform.SCENE, Platform.BINARY_SENSOR, + Platform.COVER, + Platform.LIGHT, + Platform.SCENE, + Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) From e80138dfdf0a44882267f77d7d91fabd2f0afdfd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 5 Jan 2024 17:36:55 +0100 Subject: [PATCH 0289/1544] Remove duplicate assignment of `median` and `statistical_mode` jinja2 filter (#106953) --- homeassistant/helpers/template.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ad8c4ba771a..6c6fbeb2aac 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2474,8 +2474,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains - self.filters["median"] = median - self.filters["statistical_mode"] = statistical_mode self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2511,8 +2509,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean self.globals["version"] = version - self.globals["median"] = median - self.globals["statistical_mode"] = statistical_mode self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set From 221fa48ea5ac87b831f6f9246b9a92de4b2200bd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:34:32 +0100 Subject: [PATCH 0290/1544] Improve denonavr typing (#106907) --- .../components/denonavr/media_player.py | 34 +++++++++---------- homeassistant/components/denonavr/receiver.py | 3 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index b0454784ca1..125fec7caaa 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -276,7 +276,7 @@ class DenonDevice(MediaPlayerEntity): self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone, event, parameter) -> None: + async def _telnet_callback(self, zone: str, event: str, parameter: str) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine if zone not in (self._receiver.zone, ALL_ZONES): @@ -287,7 +287,7 @@ class DenonDevice(MediaPlayerEntity): # We skip every event except the last one if event == "NSE" and not parameter.startswith("4"): return - if event == "TA" and not parameter.startwith("ANNAME"): + if event == "TA" and not parameter.startswith("ANNAME"): return if event == "HD" and not parameter.startswith("ALBUM"): return @@ -333,17 +333,17 @@ class DenonDevice(MediaPlayerEntity): return DENON_STATE_MAPPING.get(self._receiver.state) @property - def source_list(self): + def source_list(self) -> list[str]: """Return a list of available input sources.""" return self._receiver.input_func_list @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Return boolean if volume is currently muted.""" return self._receiver.muted @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" # Volume is sent in a format like -50.0. Minimum is -80.0, # maximum is 18.0 @@ -352,12 +352,12 @@ class DenonDevice(MediaPlayerEntity): return (float(self._receiver.volume) + 80) / 100 @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" return self._receiver.input_func @property - def sound_mode(self): + def sound_mode(self) -> str | None: """Return the current matched sound mode.""" return self._receiver.sound_mode @@ -376,14 +376,14 @@ class DenonDevice(MediaPlayerEntity): return MediaType.CHANNEL @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self._receiver.input_func in self._receiver.playing_func_list: return self._receiver.image_url return None @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" if self._receiver.input_func not in self._receiver.playing_func_list: return self._receiver.input_func @@ -392,26 +392,26 @@ class DenonDevice(MediaPlayerEntity): return self._receiver.frequency @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" if self._receiver.artist is not None: return self._receiver.artist return self._receiver.band @property - def media_album_name(self): + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" if self._receiver.album is not None: return self._receiver.album return self._receiver.station @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" receiver = self._receiver if receiver.power != POWER_ON: return {} - state_attributes = {} + state_attributes: dict[str, Any] = {} if ( sound_mode_raw := receiver.sound_mode_raw ) is not None and receiver.support_sound_mode: @@ -421,7 +421,7 @@ class DenonDevice(MediaPlayerEntity): return state_attributes @property - def dynamic_eq(self): + def dynamic_eq(self) -> bool | None: """Status of DynamicEQ.""" return self._receiver.dynamic_eq @@ -499,17 +499,17 @@ class DenonDevice(MediaPlayerEntity): await self._receiver.async_mute(mute) @async_log_errors - async def async_get_command(self, command: str, **kwargs): + async def async_get_command(self, command: str, **kwargs: Any) -> str: """Send generic command.""" return await self._receiver.async_get_command(command) @async_log_errors - async def async_update_audyssey(self): + async def async_update_audyssey(self) -> None: """Get the latest audyssey information from device.""" await self._receiver.async_update_audyssey() @async_log_errors - async def async_set_dynamic_eq(self, dynamic_eq: bool): + async def async_set_dynamic_eq(self, dynamic_eq: bool) -> None: """Turn DynamicEQ on or off.""" if dynamic_eq: await self._receiver.async_dynamic_eq_on() diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index 71fa77718e6..c400ed0bcce 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -5,6 +5,7 @@ from collections.abc import Callable import logging from denonavr import DenonAVR +import httpx _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class ConnectDenonAVR: zone3: bool, use_telnet: bool, update_audyssey: bool, - async_client_getter: Callable, + async_client_getter: Callable[[], httpx.AsyncClient], ) -> None: """Initialize the class.""" self._async_client_getter = async_client_getter From f0b47bf00c0b0e5b12beb2af46a0bb733a9d686b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:35:05 +0100 Subject: [PATCH 0291/1544] Enable strict typing for downloader (#107263) --- .strict-typing | 1 + homeassistant/components/downloader/__init__.py | 4 +++- mypy.ini | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 67d7866ba62..db30bfb3494 100644 --- a/.strict-typing +++ b/.strict-typing @@ -141,6 +141,7 @@ homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* +homeassistant.components.downloader.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.efergy.* diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 9aca80b04da..1922fde3102 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -1,4 +1,6 @@ """Support for functionality to download files.""" +from __future__ import annotations + from http import HTTPStatus import logging import os @@ -61,7 +63,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def download_file(service: ServiceCall) -> None: """Start thread to download file specified in the URL.""" - def do_download(): + def do_download() -> None: """Download the file.""" try: url = service.data[ATTR_URL] diff --git a/mypy.ini b/mypy.ini index 8d621949810..e5326bf16af 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1171,6 +1171,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.downloader.*] +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.dsmr.*] check_untyped_defs = true disallow_incomplete_defs = true From 3a94dd6578758a76da8987897d8ba7ec6a4fcdd5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 18:36:21 +0100 Subject: [PATCH 0292/1544] Migrate Suez Water to has entity name (#107251) --- homeassistant/components/suez_water/sensor.py | 15 +++++++++++---- homeassistant/components/suez_water/strings.json | 7 +++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 4602df27748..6df2e3870d7 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -89,21 +90,27 @@ async def async_setup_entry( ) -> None: """Set up Suez Water sensor from a config entry.""" client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezSensor(client)], True) + async_add_entities([SuezSensor(client, entry.data[CONF_COUNTER_ID])], True) class SuezSensor(SensorEntity): """Representation of a Sensor.""" - _attr_name = "Suez Water Client" - _attr_icon = "mdi:water-pump" + _attr_has_entity_name = True + _attr_translation_key = "water_usage_yesterday" _attr_native_unit_of_measurement = UnitOfVolume.LITERS _attr_device_class = SensorDeviceClass.WATER - def __init__(self, client: SuezClient) -> None: + def __init__(self, client: SuezClient, counter_id: int) -> None: """Initialize the data object.""" self.client = client self._attr_extra_state_attributes = {} + self._attr_unique_id = f"{counter_id}_water_usage_yesterday" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(counter_id))}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Suez", + ) def _fetch_data(self) -> None: """Fetch latest data from Suez.""" diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index 09df3ead17f..b4b81a788b5 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -18,6 +18,13 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "sensor": { + "water_usage_yesterday": { + "name": "Water usage yesterday" + } + } + }, "issues": { "deprecated_yaml_import_issue_invalid_auth": { "title": "The Suez water YAML configuration import failed", From de72bbfaad441ba972c9bf9406b43e9f0757ed3a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:38:31 +0100 Subject: [PATCH 0293/1544] Enable strict typing for minecraft_server (#107262) --- .strict-typing | 1 + homeassistant/components/minecraft_server/api.py | 2 +- .../components/minecraft_server/config_flow.py | 15 ++++++++++++--- .../components/minecraft_server/sensor.py | 2 +- mypy.ini | 10 ++++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index db30bfb3494..e67ea8d60d8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -263,6 +263,7 @@ homeassistant.components.media_source.* homeassistant.components.metoffice.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* +homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index fc872d37bde..e44a02c9c78 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -128,7 +128,7 @@ class MinecraftServer: self, status_response: JavaStatusResponse ) -> MinecraftServerData: """Extract Java Edition server data out of status response.""" - players_list = [] + players_list: list[str] = [] if players := status_response.players.sample: for player in players: diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 045133421fb..4f4c89fb0e6 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Minecraft Server integration.""" +from __future__ import annotations + import logging +from typing import Any import voluptuous as vol @@ -20,9 +23,11 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 3 - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input: address = user_input[CONF_ADDRESS] @@ -57,7 +62,11 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) - def _show_config_form(self, user_input=None, errors=None) -> FlowResult: + def _show_config_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: """Show the setup form to the user.""" if user_input is None: user_input = {} diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 671bbdb7a05..606d6085fda 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -61,7 +61,7 @@ def get_extra_state_attributes_players_list( data: MinecraftServerData, ) -> dict[str, list[str]]: """Return players list as extra state attributes, if available.""" - extra_state_attributes = {} + extra_state_attributes: dict[str, Any] = {} players_list = data.players_list if players_list is not None and len(players_list) != 0: diff --git a/mypy.ini b/mypy.ini index e5326bf16af..97927cf83aa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2391,6 +2391,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.minecraft_server.*] +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.mjpeg.*] check_untyped_defs = true disallow_incomplete_defs = true From b2a4de6eed0c2e51100b86690ebc32617510dba7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:39:18 +0100 Subject: [PATCH 0294/1544] Enable strict typing for duotecno (#107261) --- .strict-typing | 1 + homeassistant/components/duotecno/binary_sensor.py | 4 +++- homeassistant/components/duotecno/climate.py | 5 ++++- homeassistant/components/duotecno/cover.py | 13 +++++-------- homeassistant/components/duotecno/entity.py | 5 ++--- homeassistant/components/duotecno/light.py | 3 ++- homeassistant/components/duotecno/switch.py | 3 ++- mypy.ini | 10 ++++++++++ 8 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index e67ea8d60d8..6e862159680 100644 --- a/.strict-typing +++ b/.strict-typing @@ -144,6 +144,7 @@ homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* +homeassistant.components.duotecno.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index 5867e2d634e..60578adf6a7 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -1,5 +1,7 @@ """Support for Duotecno binary sensors.""" +from __future__ import annotations +from duotecno.controller import PyDuotecno from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity @@ -17,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno binary sensor on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoBinarySensor(channel) for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index dc10e0a61d9..22be40a812e 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -1,6 +1,9 @@ """Support for Duotecno climate devices.""" +from __future__ import annotations + from typing import Any, Final +from duotecno.controller import PyDuotecno from duotecno.unit import SensUnit from homeassistant.components.climate import ( @@ -33,7 +36,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno climate based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) ) diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 0be9daf572b..b8802c77304 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import DuoswitchUnit from homeassistant.components.cover import CoverEntity, CoverEntityFeature @@ -20,7 +21,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the duoswitch endities.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit") ) @@ -30,13 +31,9 @@ class DuotecnoCover(DuotecnoEntity, CoverEntity): """Representation a Velbus cover.""" _unit: DuoswitchUnit - - def __init__(self, unit: DuoswitchUnit) -> None: - """Initialize the cover.""" - super().__init__(unit) - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) @property def is_closed(self) -> bool | None: diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index 8d905979bfe..85566b3ebad 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -17,10 +17,9 @@ from .const import DOMAIN class DuotecnoEntity(Entity): """Representation of a Duotecno entity.""" - _attr_should_poll: bool = False - _unit: BaseUnit + _attr_should_poll = False - def __init__(self, unit) -> None: + def __init__(self, unit: BaseUnit) -> None: """Initialize a Duotecno entity.""" self._unit = unit self._attr_name = unit.get_name() diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py index 9aee4513fca..851dd64bfb2 100644 --- a/homeassistant/components/duotecno/light.py +++ b/homeassistant/components/duotecno/light.py @@ -1,6 +1,7 @@ """Support for Duotecno lights.""" from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import DimUnit from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity @@ -18,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Duotecno light based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit")) diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index 63bab750543..d43f82fc657 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -1,6 +1,7 @@ """Support for Duotecno switches.""" from typing import Any +from duotecno.controller import PyDuotecno from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity @@ -18,7 +19,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" - cntrl = hass.data[DOMAIN][entry.entry_id] + cntrl: PyDuotecno = hass.data[DOMAIN][entry.entry_id] async_add_entities( DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") ) diff --git a/mypy.ini b/mypy.ini index 97927cf83aa..b84c183cdf1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1201,6 +1201,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.duotecno.*] +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.efergy.*] check_untyped_defs = true disallow_incomplete_defs = true From 833cddc8f5a83f2ce17a31182a416a0f84bd6d25 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 5 Jan 2024 18:40:34 +0100 Subject: [PATCH 0295/1544] Improve conversation typing (#106905) --- .../components/conversation/__init__.py | 9 +++--- .../components/conversation/default_agent.py | 30 +++++++++++-------- homeassistant/components/conversation/util.py | 4 ++- homeassistant/helpers/area_registry.py | 9 +++++- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 193bd45bba0..9caabc6e8d4 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -8,6 +8,7 @@ import logging import re from typing import Any, Literal +from aiohttp import web from hassil.recognize import RecognizeResult import voluptuous as vol @@ -108,7 +109,7 @@ def async_set_agent( hass: core.HomeAssistant, config_entry: ConfigEntry, agent: AbstractConversationAgent, -): +) -> None: """Set the agent to handle the conversations.""" _get_agent_manager(hass).async_set_agent(config_entry.entry_id, agent) @@ -118,7 +119,7 @@ def async_set_agent( def async_unset_agent( hass: core.HomeAssistant, config_entry: ConfigEntry, -): +) -> None: """Set the agent to handle the conversations.""" _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) @@ -133,7 +134,7 @@ async def async_get_conversation_languages( all conversation agents. """ agent_manager = _get_agent_manager(hass) - languages = set() + languages: set[str] = set() agent_ids: Iterable[str] if agent_id is None: @@ -408,7 +409,7 @@ class ConversationProcessView(http.HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Send a request for processing.""" hass = request.app["hass"] diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index e66c246dc44..f80c7ae7bbf 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -34,7 +34,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_listen_entity_updates, async_should_expose, ) -from homeassistant.const import MATCH_ALL +from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -145,7 +145,7 @@ class DefaultAgent(AbstractConversationAgent): """Return a list of supported languages.""" return get_domains_and_languages()["homeassistant"] - async def async_initialize(self, config_intents): + async def async_initialize(self, config_intents: dict[str, Any] | None) -> None: """Initialize the default agent.""" if "intent" not in self.hass.config.components: await setup.async_setup_component(self.hass, "intent", {}) @@ -156,17 +156,17 @@ class DefaultAgent(AbstractConversationAgent): self.hass.bus.async_listen( ar.EVENT_AREA_REGISTRY_UPDATED, - self._async_handle_area_registry_changed, + self._async_handle_area_registry_changed, # type: ignore[arg-type] run_immediately=True, ) self.hass.bus.async_listen( er.EVENT_ENTITY_REGISTRY_UPDATED, - self._async_handle_entity_registry_changed, + self._async_handle_entity_registry_changed, # type: ignore[arg-type] run_immediately=True, ) self.hass.bus.async_listen( - core.EVENT_STATE_CHANGED, - self._async_handle_state_changed, + EVENT_STATE_CHANGED, + self._async_handle_state_changed, # type: ignore[arg-type] run_immediately=True, ) async_listen_entity_updates( @@ -433,7 +433,7 @@ class DefaultAgent(AbstractConversationAgent): return speech - async def async_reload(self, language: str | None = None): + async def async_reload(self, language: str | None = None) -> None: """Clear cached intents for a language.""" if language is None: self._lang_intents.clear() @@ -442,7 +442,7 @@ class DefaultAgent(AbstractConversationAgent): self._lang_intents.pop(language, None) _LOGGER.debug("Cleared intents for language: %s", language) - async def async_prepare(self, language: str | None = None): + async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" if language is None: language = self.hass.config.language @@ -594,12 +594,16 @@ class DefaultAgent(AbstractConversationAgent): return lang_intents @core.callback - def _async_handle_area_registry_changed(self, event: core.Event) -> None: + def _async_handle_area_registry_changed( + self, event: EventType[ar.EventAreaRegistryUpdatedData] + ) -> None: """Clear area area cache when the area registry has changed.""" self._slot_lists = None @core.callback - def _async_handle_entity_registry_changed(self, event: core.Event) -> None: + def _async_handle_entity_registry_changed( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: """Clear names list cache when an entity registry entry has changed.""" if event.data["action"] != "update" or not any( field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS @@ -608,9 +612,11 @@ class DefaultAgent(AbstractConversationAgent): self._slot_lists = None @core.callback - def _async_handle_state_changed(self, event: core.Event) -> None: + def _async_handle_state_changed( + self, event: EventType[EventStateChangedData] + ) -> None: """Clear names list cache when a state is added or removed from the state machine.""" - if event.data.get("old_state") and event.data.get("new_state"): + if event.data["old_state"] and event.data["new_state"]: return self._slot_lists = None diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index 2e931d835a8..78fb4bd0ef5 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -1,8 +1,10 @@ """Util for Conversation.""" +from __future__ import annotations + import re -def create_matcher(utterance): +def create_matcher(utterance: str) -> re.Pattern[str]: """Create a regex that matches the utterance.""" # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 499e548ce90..1d785fd0cee 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Container, Iterable, MutableMapping -from typing import Any, cast +from typing import Any, Literal, TypedDict, cast import attr @@ -22,6 +22,13 @@ STORAGE_VERSION_MINOR = 3 SAVE_DELAY = 10 +class EventAreaRegistryUpdatedData(TypedDict): + """EventAreaRegistryUpdated data.""" + + action: Literal["create", "remove", "update"] + area_id: str + + @attr.s(slots=True, frozen=True) class AreaEntry: """Area Registry Entry.""" From 9a15a5b6c2f18b22f412143faf99f759919c54ae Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 5 Jan 2024 18:53:25 +0100 Subject: [PATCH 0296/1544] Cleanup and migrate rest_command tests to be async (#107264) Migrate rest_command tests to be async --- tests/components/rest_command/conftest.py | 42 ++ tests/components/rest_command/test_init.py | 754 +++++++++------------ 2 files changed, 379 insertions(+), 417 deletions(-) create mode 100644 tests/components/rest_command/conftest.py diff --git a/tests/components/rest_command/conftest.py b/tests/components/rest_command/conftest.py new file mode 100644 index 00000000000..1a624b7534f --- /dev/null +++ b/tests/components/rest_command/conftest.py @@ -0,0 +1,42 @@ +"""Fixtures for the trend component tests.""" +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.rest_command import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +ComponentSetup = Callable[[dict[str, Any] | None], Awaitable[None]] + +TEST_URL = "https://example.com/" +TEST_CONFIG = { + "get_test": {"url": TEST_URL, "method": "get"}, + "patch_test": {"url": TEST_URL, "method": "patch"}, + "post_test": {"url": TEST_URL, "method": "post", "payload": "test"}, + "put_test": {"url": TEST_URL, "method": "put"}, + "delete_test": {"url": TEST_URL, "method": "delete"}, + "auth_test": { + "url": TEST_URL, + "method": "get", + "username": "test", + "password": "123456", + }, +} + + +@pytest.fixture(name="setup_component") +async def mock_setup_component( + hass: HomeAssistant, +) -> ComponentSetup: + """Set up the rest_command component.""" + + async def _setup_func(alternative_config: dict[str, Any] | None = None) -> None: + config = alternative_config or TEST_CONFIG + with assert_setup_component(len(config)): + await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + + return _setup_func diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 0e70f7bc52d..b9e5070d457 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -7,454 +7,374 @@ from unittest.mock import patch import aiohttp import pytest -import homeassistant.components.rest_command as rc +from homeassistant.components.rest_command import DOMAIN from homeassistant.const import ( CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, SERVICE_RELOAD, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import setup_component -from tests.common import assert_setup_component, get_test_home_assistant +from .conftest import TEST_URL, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker -class TestRestCommandSetup: - """Test the rest command component.""" +async def test_reload(hass: HomeAssistant, setup_component: ComponentSetup) -> None: + """Verify we can reload rest_command integration.""" + await setup_component() - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert hass.services.has_service(DOMAIN, "get_test") + assert not hass.services.has_service(DOMAIN, "new_test") - self.config = {rc.DOMAIN: {"test_get": {"url": "http://example.com/"}}} - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Test setup component.""" - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - def test_setup_component_timeout(self): - """Test setup component timeout.""" - self.config[rc.DOMAIN]["test_get"]["timeout"] = 10 - - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - def test_setup_component_test_service(self): - """Test setup component and check if service exits.""" - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "test_get") - - def test_reload(self): - """Verify we can reload rest_command integration.""" - - with assert_setup_component(1): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "test_get") - assert not self.hass.services.has_service(rc.DOMAIN, "new_test") - - new_config = { - rc.DOMAIN: { - "new_test": {"url": "https://example.org", "method": "get"}, - } + new_config = { + DOMAIN: { + "new_test": {"url": "https://example.org", "method": "get"}, } - with patch( - "homeassistant.config.load_yaml_config_file", - autospec=True, - return_value=new_config, - ): - self.hass.services.call(rc.DOMAIN, SERVICE_RELOAD, blocking=True) + } + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=new_config, + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) - assert self.hass.services.has_service(rc.DOMAIN, "new_test") - assert not self.hass.services.has_service(rc.DOMAIN, "get_test") + assert hass.services.has_service(DOMAIN, "new_test") + assert not hass.services.has_service(DOMAIN, "get_test") -class TestRestCommandComponent: - """Test the rest command component.""" +async def test_setup_tests( + hass: HomeAssistant, setup_component: ComponentSetup +) -> None: + """Set up test config and test it.""" + await setup_component() - def setup_method(self): - """Set up things to be run when tests are started.""" - self.url = "https://example.com/" - self.config = { - rc.DOMAIN: { - "get_test": {"url": self.url, "method": "get"}, - "patch_test": {"url": self.url, "method": "patch"}, - "post_test": {"url": self.url, "method": "post"}, - "put_test": {"url": self.url, "method": "put"}, - "delete_test": {"url": self.url, "method": "delete"}, + assert hass.services.has_service(DOMAIN, "get_test") + assert hass.services.has_service(DOMAIN, "post_test") + assert hass.services.has_service(DOMAIN, "put_test") + assert hass.services.has_service(DOMAIN, "delete_test") + + +async def test_rest_command_timeout( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with timeout.""" + await setup_component() + + aioclient_mock.get(TEST_URL, exc=asyncio.TimeoutError()) + + with pytest.raises( + HomeAssistantError, + match=r"^Timeout when calling resource 'https://example.com/'$", + ): + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_aiohttp_error( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with aiohttp exception.""" + await setup_component() + + aioclient_mock.get(TEST_URL, exc=aiohttp.ClientError()) + + with pytest.raises( + HomeAssistantError, + match=r"^Client error occurred when calling resource 'https://example.com/'$", + ): + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_http_error( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with status code 400.""" + await setup_component() + + aioclient_mock.get(TEST_URL, status=HTTPStatus.BAD_REQUEST) + + await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_auth( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with auth credential.""" + await setup_component() + + aioclient_mock.get(TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, "auth_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_form_data( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with post form data.""" + await setup_component() + + aioclient_mock.post(TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, "post_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == b"test" + + +@pytest.mark.parametrize( + "method", + [ + "get", + "patch", + "post", + "put", + "delete", + ], +) +async def test_rest_command_methods( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + method: str, +): + """Test various http methods.""" + await setup_component() + + aioclient_mock.request(method=method, url=TEST_URL, content=b"success") + + await hass.services.async_call(DOMAIN, f"{method}_test", {}, blocking=True) + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_rest_command_headers( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call a rest command with custom headers and content types.""" + header_config_variations = { + "no_headers_test": {}, + "content_type_test": {"content_type": CONTENT_TYPE_TEXT_PLAIN}, + "headers_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/5.0", } - } - - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_tests(self): - """Set up test config and test it.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - assert self.hass.services.has_service(rc.DOMAIN, "get_test") - assert self.hass.services.has_service(rc.DOMAIN, "post_test") - assert self.hass.services.has_service(rc.DOMAIN, "put_test") - assert self.hass.services.has_service(rc.DOMAIN, "delete_test") - - def test_rest_command_timeout(self, aioclient_mock): - """Call a rest command with timeout.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, exc=asyncio.TimeoutError()) - - with pytest.raises( - HomeAssistantError, - match=r"^Timeout when calling resource 'https://example.com/'$", - ): - self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_aiohttp_error(self, aioclient_mock): - """Call a rest command with aiohttp exception.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, exc=aiohttp.ClientError()) - - with pytest.raises( - HomeAssistantError, - match=r"^Client error occurred when calling resource 'https://example.com/'$", - ): - self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_http_error(self, aioclient_mock): - """Call a rest command with status code 400.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, status=HTTPStatus.BAD_REQUEST) - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_auth(self, aioclient_mock): - """Call a rest command with auth credential.""" - data = {"username": "test", "password": "123456"} - self.config[rc.DOMAIN]["get_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_form_data(self, aioclient_mock): - """Call a rest command with post form data.""" - data = {"payload": "test"} - self.config[rc.DOMAIN]["post_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.post(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "post_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"test" - - def test_rest_command_get(self, aioclient_mock): - """Call a rest command with get.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "get_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_delete(self, aioclient_mock): - """Call a rest command with delete.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.delete(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "delete_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - - def test_rest_command_patch(self, aioclient_mock): - """Call a rest command with patch.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["patch_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.patch(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "patch_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_post(self, aioclient_mock): - """Call a rest command with post.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["post_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.post(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "post_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_put(self, aioclient_mock): - """Call a rest command with put.""" - data = {"payload": "data"} - self.config[rc.DOMAIN]["put_test"].update(data) - - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.put(self.url, content=b"success") - - self.hass.services.call(rc.DOMAIN, "put_test", {}) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b"data" - - def test_rest_command_headers(self, aioclient_mock): - """Call a rest command with custom headers and content types.""" - header_config_variations = { - rc.DOMAIN: { - "no_headers_test": {}, - "content_type_test": {"content_type": CONTENT_TYPE_TEXT_PLAIN}, - "headers_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - "User-Agent": "Mozilla/5.0", - } - }, - "headers_and_content_type_test": { - "headers": {"Accept": CONTENT_TYPE_JSON}, - "content_type": CONTENT_TYPE_TEXT_PLAIN, - }, - "headers_and_content_type_override_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - aiohttp.hdrs.CONTENT_TYPE: "application/pdf", - }, - "content_type": CONTENT_TYPE_TEXT_PLAIN, - }, - "headers_template_test": { - "headers": { - "Accept": CONTENT_TYPE_JSON, - "User-Agent": "Mozilla/{{ 3 + 2 }}.0", - } - }, - "headers_and_content_type_override_template_test": { - "headers": { - "Accept": "application/{{ 1 + 1 }}json", - aiohttp.hdrs.CONTENT_TYPE: "application/pdf", - }, - "content_type": "text/json", - }, + }, + "headers_and_content_type_test": { + "headers": {"Accept": CONTENT_TYPE_JSON}, + "content_type": CONTENT_TYPE_TEXT_PLAIN, + }, + "headers_and_content_type_override_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + aiohttp.hdrs.CONTENT_TYPE: "application/pdf", + }, + "content_type": CONTENT_TYPE_TEXT_PLAIN, + }, + "headers_template_test": { + "headers": { + "Accept": CONTENT_TYPE_JSON, + "User-Agent": "Mozilla/{{ 3 + 2 }}.0", } - } + }, + "headers_and_content_type_override_template_test": { + "headers": { + "Accept": "application/{{ 1 + 1 }}json", + aiohttp.hdrs.CONTENT_TYPE: "application/pdf", + }, + "content_type": "text/json", + }, + } - # add common parameters - for variation in header_config_variations[rc.DOMAIN].values(): - variation.update( - {"url": self.url, "method": "post", "payload": "test data"} - ) + # add common parameters + for variation in header_config_variations.values(): + variation.update({"url": TEST_URL, "method": "post", "payload": "test data"}) - with assert_setup_component(7): - setup_component(self.hass, rc.DOMAIN, header_config_variations) + await setup_component(header_config_variations) - # provide post request data - aioclient_mock.post(self.url, content=b"success") + # provide post request data + aioclient_mock.post(TEST_URL, content=b"success") - for test_service in [ - "no_headers_test", - "content_type_test", - "headers_test", - "headers_and_content_type_test", - "headers_and_content_type_override_test", - "headers_template_test", - "headers_and_content_type_override_template_test", - ]: - self.hass.services.call(rc.DOMAIN, test_service, {}) + for test_service in [ + "no_headers_test", + "content_type_test", + "headers_test", + "headers_and_content_type_test", + "headers_and_content_type_override_test", + "headers_template_test", + "headers_and_content_type_override_template_test", + ]: + await hass.services.async_call(DOMAIN, test_service, {}, blocking=True) - self.hass.block_till_done() - assert len(aioclient_mock.mock_calls) == 7 + await hass.async_block_till_done() + assert len(aioclient_mock.mock_calls) == 7 - # no_headers_test - assert aioclient_mock.mock_calls[0][3] is None + # no_headers_test + assert aioclient_mock.mock_calls[0][3] is None - # content_type_test - assert len(aioclient_mock.mock_calls[1][3]) == 1 - assert ( - aioclient_mock.mock_calls[1][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN + # content_type_test + assert len(aioclient_mock.mock_calls[1][3]) == 1 + assert ( + aioclient_mock.mock_calls[1][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + + # headers_test + assert len(aioclient_mock.mock_calls[2][3]) == 2 + assert aioclient_mock.mock_calls[2][3].get("Accept") == CONTENT_TYPE_JSON + assert aioclient_mock.mock_calls[2][3].get("User-Agent") == "Mozilla/5.0" + + # headers_and_content_type_test + assert len(aioclient_mock.mock_calls[3][3]) == 2 + assert ( + aioclient_mock.mock_calls[3][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + assert aioclient_mock.mock_calls[3][3].get("Accept") == CONTENT_TYPE_JSON + + # headers_and_content_type_override_test + assert len(aioclient_mock.mock_calls[4][3]) == 2 + assert ( + aioclient_mock.mock_calls[4][3].get(aiohttp.hdrs.CONTENT_TYPE) + == CONTENT_TYPE_TEXT_PLAIN + ) + assert aioclient_mock.mock_calls[4][3].get("Accept") == CONTENT_TYPE_JSON + + # headers_template_test + assert len(aioclient_mock.mock_calls[5][3]) == 2 + assert aioclient_mock.mock_calls[5][3].get("Accept") == CONTENT_TYPE_JSON + assert aioclient_mock.mock_calls[5][3].get("User-Agent") == "Mozilla/5.0" + + # headers_and_content_type_override_template_test + assert len(aioclient_mock.mock_calls[6][3]) == 2 + assert aioclient_mock.mock_calls[6][3].get(aiohttp.hdrs.CONTENT_TYPE) == "text/json" + assert aioclient_mock.mock_calls[6][3].get("Accept") == "application/2json" + + +async def test_rest_command_get_response_plaintext( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, text.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, content=b"success", headers={"content-type": "text/plain"} + ) + + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"] == "success" + assert response["status"] == 200 + + +async def test_rest_command_get_response_json( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, json.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, + json={"status": "success", "number": 42}, + headers={"content-type": "application/json"}, + ) + + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True + ) + + assert len(aioclient_mock.mock_calls) == 1 + assert response["content"]["status"] == "success" + assert response["content"]["number"] == 42 + assert response["status"] == 200 + + +async def test_rest_command_get_response_malformed_json( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, malformed json.""" + await setup_component() + + aioclient_mock.get( + TEST_URL, + content='{"status": "failure", 42', + headers={"content-type": "application/json"}, + ) + + # No problem without 'return_response' + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + assert not response + + # Throws error when requesting response + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as JSON$", + ): + await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True ) - # headers_test - assert len(aioclient_mock.mock_calls[2][3]) == 2 - assert aioclient_mock.mock_calls[2][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[2][3].get("User-Agent") == "Mozilla/5.0" - # headers_and_content_type_test - assert len(aioclient_mock.mock_calls[3][3]) == 2 - assert ( - aioclient_mock.mock_calls[3][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN - ) - assert aioclient_mock.mock_calls[3][3].get("Accept") == CONTENT_TYPE_JSON +async def test_rest_command_get_response_none( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Get rest_command response, other.""" + await setup_component() - # headers_and_content_type_override_test - assert len(aioclient_mock.mock_calls[4][3]) == 2 - assert ( - aioclient_mock.mock_calls[4][3].get(aiohttp.hdrs.CONTENT_TYPE) - == CONTENT_TYPE_TEXT_PLAIN - ) - assert aioclient_mock.mock_calls[4][3].get("Accept") == CONTENT_TYPE_JSON + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) - # headers_template_test - assert len(aioclient_mock.mock_calls[5][3]) == 2 - assert aioclient_mock.mock_calls[5][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[5][3].get("User-Agent") == "Mozilla/5.0" + aioclient_mock.get( + TEST_URL, + content=png, + headers={"content-type": "text/plain"}, + ) - # headers_and_content_type_override_template_test - assert len(aioclient_mock.mock_calls[6][3]) == 2 - assert ( - aioclient_mock.mock_calls[6][3].get(aiohttp.hdrs.CONTENT_TYPE) - == "text/json" - ) - assert aioclient_mock.mock_calls[6][3].get("Accept") == "application/2json" + # No problem without 'return_response' + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + assert not response - def test_rest_command_get_response_plaintext(self, aioclient_mock): - """Get rest_command response, text.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get( - self.url, content=b"success", headers={"content-type": "text/plain"} + # Throws Decode error when requesting response + with pytest.raises( + HomeAssistantError, + match=r"^Response of 'https://example.com/' could not be decoded as text$", + ): + response = await hass.services.async_call( + DOMAIN, "get_test", {}, blocking=True, return_response=True ) - response = self.hass.services.call( - rc.DOMAIN, "get_test", {}, blocking=True, return_response=True - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert response["content"] == "success" - assert response["status"] == 200 - - def test_rest_command_get_response_json(self, aioclient_mock): - """Get rest_command response, json.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get( - self.url, - json={"status": "success", "number": 42}, - headers={"content-type": "application/json"}, - ) - - response = self.hass.services.call( - rc.DOMAIN, "get_test", {}, blocking=True, return_response=True - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert response["content"]["status"] == "success" - assert response["content"]["number"] == 42 - assert response["status"] == 200 - - def test_rest_command_get_response_malformed_json(self, aioclient_mock): - """Get rest_command response, malformed json.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - aioclient_mock.get( - self.url, - content='{"status": "failure", 42', - headers={"content-type": "application/json"}, - ) - - # No problem without 'return_response' - response = self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) - self.hass.block_till_done() - assert not response - - # Throws error when requesting response - with pytest.raises( - HomeAssistantError, - match=r"^Response of 'https://example.com/' could not be decoded as JSON$", - ): - response = self.hass.services.call( - rc.DOMAIN, "get_test", {}, blocking=True, return_response=True - ) - self.hass.block_till_done() - - def test_rest_command_get_response_none(self, aioclient_mock): - """Get rest_command response, other.""" - with assert_setup_component(5): - setup_component(self.hass, rc.DOMAIN, self.config) - - png = base64.decodebytes( - b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" - ) - - aioclient_mock.get( - self.url, - content=png, - headers={"content-type": "text/plain"}, - ) - - # No problem without 'return_response' - response = self.hass.services.call(rc.DOMAIN, "get_test", {}, blocking=True) - self.hass.block_till_done() - assert not response - - # Throws Decode error when requesting response - with pytest.raises( - HomeAssistantError, - match=r"^Response of 'https://example.com/' could not be decoded as text$", - ): - response = self.hass.services.call( - rc.DOMAIN, "get_test", {}, blocking=True, return_response=True - ) - self.hass.block_till_done() - - assert not response + assert not response From 24ee64e20c8b6e3a7be2c78579d8b7e2eebe6a95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 08:03:53 -1000 Subject: [PATCH 0297/1544] Convert cert_expiry to use asyncio (#106919) --- .../components/cert_expiry/helper.py | 34 ++++++++++++------- .../cert_expiry/test_config_flow.py | 13 +++---- tests/components/cert_expiry/test_sensors.py | 9 ++--- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 0817025c703..6618dbc8a01 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,7 +1,10 @@ """Helper functions for the Cert Expiry platform.""" +import asyncio +import datetime from functools import cache import socket import ssl +from typing import Any from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -21,31 +24,38 @@ def _get_default_ssl_context(): return ssl.create_default_context() -def get_cert( +async def async_get_cert( + hass: HomeAssistant, host: str, port: int, -): +) -> dict[str, Any]: """Get the certificate for the host and port combination.""" - ctx = _get_default_ssl_context() - address = (host, port) - with socket.create_connection(address, timeout=TIMEOUT) as sock, ctx.wrap_socket( - sock, server_hostname=address[0] - ) as ssock: - cert = ssock.getpeercert() - return cert + async with asyncio.timeout(TIMEOUT): + transport, _ = await hass.loop.create_connection( + asyncio.Protocol, + host, + port, + ssl=_get_default_ssl_context(), + happy_eyeballs_delay=0.25, + server_hostname=host, + ) + try: + return transport.get_extra_info("peercert") + finally: + transport.close() async def get_cert_expiry_timestamp( hass: HomeAssistant, hostname: str, port: int, -): +) -> datetime.datetime: """Return the certificate's expiration timestamp.""" try: - cert = await hass.async_add_executor_job(get_cert, hostname, port) + cert = await async_get_cert(hass, hostname, port) except socket.gaierror as err: raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err - except socket.timeout as err: + except asyncio.TimeoutError as err: raise ConnectionTimeout( f"Connection timeout with server: {hostname}:{port}" ) from err diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index f950fce6a68..800a3ce54da 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Cert Expiry config flow.""" +import asyncio import socket import ssl from unittest.mock import patch @@ -48,7 +49,7 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("some error"), ): result = await hass.config_entries.flow.async_configure( @@ -153,7 +154,7 @@ async def test_import_with_name(hass: HomeAssistant) -> None: async def test_bad_import(hass: HomeAssistant) -> None: """Test import step.""" with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ConnectionRefusedError(), ): result = await hass.config_entries.flow.async_init( @@ -198,7 +199,7 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=socket.gaierror(), ): result = await hass.config_entries.flow.async_configure( @@ -208,8 +209,8 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_HOST: "resolve_failed"} with patch( - "homeassistant.components.cert_expiry.helper.get_cert", - side_effect=socket.timeout(), + "homeassistant.components.cert_expiry.helper.async_get_cert", + side_effect=asyncio.TimeoutError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: HOST} @@ -218,7 +219,7 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_HOST: "connection_timeout"} with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ConnectionRefusedError, ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 18a70fa9ab6..1c66a1c91ff 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -57,7 +57,7 @@ async def test_async_setup_entry_bad_cert(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("some error"), ): entry.add_to_hass(hass) @@ -146,7 +146,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=socket.gaierror, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) @@ -174,7 +174,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", + "homeassistant.components.cert_expiry.helper.async_get_cert", side_effect=ssl.SSLError("something bad"), ): async_fire_time_changed(hass, utcnow() + timedelta(hours=72)) @@ -189,7 +189,8 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) with freeze_time(next_update), patch( - "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() + "homeassistant.components.cert_expiry.helper.async_get_cert", + side_effect=Exception(), ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) await hass.async_block_till_done() From a600a0e023ad66103c5ed68053b11be1a443b845 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 5 Jan 2024 12:45:41 -0600 Subject: [PATCH 0298/1544] Expose all areas to Assist and ignore empty aliases (#107267) * Expose all areas to Assist * Skip empty entity/area aliases --- .../components/conversation/default_agent.py | 26 +++----- .../conversation/test_default_agent.py | 65 +++++++++++++++++-- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index f80c7ae7bbf..6af75b2f0a8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -630,14 +630,12 @@ class DefaultAgent(AbstractConversationAgent): if self._slot_lists is not None: return self._slot_lists - area_ids_with_entities: set[str] = set() entity_registry = er.async_get(self.hass) states = [ state for state in self.hass.states.async_all() if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] @@ -660,34 +658,26 @@ class DefaultAgent(AbstractConversationAgent): if entity.aliases: for alias in entity.aliases: + if not alias.strip(): + continue + entity_names.append((alias, alias, context)) # Default name entity_names.append((state.name, state.name, context)) - if entity.area_id: - # Expose area too - area_ids_with_entities.add(entity.area_id) - elif entity.device_id: - # Check device for area as well - device = devices.async_get(entity.device_id) - if (device is not None) and device.area_id: - area_ids_with_entities.add(device.area_id) - - # Gather areas from exposed entities + # Expose all areas areas = ar.async_get(self.hass) area_names = [] - for area_id in area_ids_with_entities: - area = areas.async_get_area(area_id) - if area is None: - continue - + for area in areas.async_list_areas(): area_names.append((area.name, area.id)) if area.aliases: for alias in area.aliases: + if not alias.strip(): + continue + area_names.append((alias, area.id)) - _LOGGER.debug("Exposed areas: %s", area_names) _LOGGER.debug("Exposed entities: %s", entity_names) self._slot_lists = { diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 4c1d395a2cc..ac126aa7c6b 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -83,7 +83,7 @@ async def test_exposed_areas( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that only expose areas with an exposed entity/device.""" + """Test that all areas are exposed.""" area_kitchen = area_registry.async_get_or_create("kitchen") area_bedroom = area_registry.async_get_or_create("bedroom") @@ -122,14 +122,20 @@ async def test_exposed_areas( # All is well for the exposed kitchen light assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - # Bedroom is not exposed because it has no exposed entities + # Bedroom has no exposed entities result = await conversation.async_converse( hass, "turn on lights in the bedroom", None, Context(), None ) - # This should be a match failure because the area isn't in the slot list + # This should be an error because the lights in that area are not exposed assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + + # But we can still ask questions about the bedroom, even with no exposed entities + result = await conversation.async_converse( + hass, "how many lights are on in the bedroom?", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER async def test_conversation_agent( @@ -463,3 +469,54 @@ async def test_error_match_failure(hass: HomeAssistant, init_components) -> None assert ( result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH ) + + +async def test_empty_aliases( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that empty aliases are not added to slot lists.""" + area_kitchen = area_registry.async_get_or_create("kitchen") + assert area_kitchen.id is not None + area_registry.async_update(area_kitchen.id, aliases={" "}) + + 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") + entity_registry.async_update_entity( + kitchen_light.entity_id, device_id=kitchen_device.id, aliases={" "} + ) + hass.states.async_set( + kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + + with patch( + "homeassistant.components.conversation.DefaultAgent._recognize", + return_value=None, + ) as mock_recognize_all: + await conversation.async_converse( + hass, "turn on lights in the kitchen", None, Context(), None + ) + + assert mock_recognize_all.call_count > 0 + slot_lists = mock_recognize_all.call_args[0][2] + + # Slot lists should only contain non-empty text + assert slot_lists.keys() == {"area", "name"} + areas = slot_lists["area"] + assert len(areas.values) == 1 + assert areas.values[0].value_out == "kitchen" + + names = slot_lists["name"] + assert len(names.values) == 1 + assert names.values[0].value_out == "kitchen light" From c81f909ee31e191cd8ce23840934ad5372774429 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 5 Jan 2024 19:45:56 +0100 Subject: [PATCH 0299/1544] Use call_soon_threadsafe in mqtt client unsubscribe callback (#107266) --- homeassistant/components/mqtt/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e9ef92ddbf8..7d102e3a32f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -38,7 +38,6 @@ from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception from .const import ( @@ -217,7 +216,7 @@ def subscribe( def remove() -> None: """Remove listener convert.""" - run_callback_threadsafe(hass.loop, async_remove).result() + hass.loop.call_soon_threadsafe(async_remove) return remove From 81458dbf6f5bbeac9299182067a7762cd02d2717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 08:51:49 -1000 Subject: [PATCH 0300/1544] Add test coverage for ESPHome state subscription (#107045) --- homeassistant/components/esphome/manager.py | 54 +++++++++--------- tests/components/esphome/conftest.py | 23 ++++++++ tests/components/esphome/test_manager.py | 63 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index f0263bdc48b..b897ffc9408 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -285,42 +285,44 @@ class ESPHomeManager: await self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) + async def _send_home_assistant_state_event( + self, + attribute: str | None, + event: EventType[EventStateChangedData], + ) -> None: + """Forward Home Assistant states updates to ESPHome.""" + event_data = event.data + new_state = event_data["new_state"] + old_state = event_data["old_state"] + + if new_state is None or old_state is None: + return + + # Only communicate changes to the state or attribute tracked + if (not attribute and old_state.state == new_state.state) or ( + attribute + and old_state.attributes.get(attribute) + == new_state.attributes.get(attribute) + ): + return + + await self._send_home_assistant_state( + event.data["entity_id"], attribute, new_state + ) + @callback def async_on_state_subscription( self, entity_id: str, attribute: str | None = None ) -> None: """Subscribe and forward states for requested entities.""" hass = self.hass - - async def send_home_assistant_state_event( - event: EventType[EventStateChangedData], - ) -> None: - """Forward Home Assistant states updates to ESPHome.""" - event_data = event.data - new_state = event_data["new_state"] - old_state = event_data["old_state"] - - if new_state is None or old_state is None: - return - - # Only communicate changes to the state or attribute tracked - if (not attribute and old_state.state == new_state.state) or ( - attribute - and old_state.attributes.get(attribute) - == new_state.attributes.get(attribute) - ): - return - - await self._send_home_assistant_state( - event.data["entity_id"], attribute, new_state - ) - self.entry_data.disconnect_callbacks.add( async_track_state_change_event( - hass, [entity_id], send_home_assistant_state_event + hass, + [entity_id], + partial(self._send_home_assistant_state_event, attribute), ) ) - # Send initial state hass.async_create_task( self._send_home_assistant_state( diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 3acc5112720..0ac940018d7 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -180,6 +180,9 @@ class MockESPHomeDevice: self.service_call_callback: Callable[[HomeassistantServiceCall], None] self.on_disconnect: Callable[[bool], None] self.on_connect: Callable[[bool], None] + self.home_assistant_state_subscription_callback: Callable[ + [str, str | None], None + ] def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -215,6 +218,19 @@ class MockESPHomeDevice: """Mock connecting.""" await self.on_connect() + def set_home_assistant_state_subscription_callback( + self, + on_state_sub: Callable[[str, str | None], None], + ) -> None: + """Set the state call callback.""" + self.home_assistant_state_subscription_callback = on_state_sub + + def mock_home_assistant_state_subscription( + self, entity_id: str, attribute: str | None + ) -> None: + """Mock a state subscription.""" + self.home_assistant_state_subscription_callback(entity_id, attribute) + async def _mock_generic_device_entry( hass: HomeAssistant, @@ -260,6 +276,12 @@ async def _mock_generic_device_entry( """Subscribe to service calls.""" mock_device.set_service_call_callback(callback) + async def _subscribe_home_assistant_states( + on_state_sub: Callable[[str, str | None], None], + ) -> None: + """Subscribe to home assistant states.""" + mock_device.set_home_assistant_state_subscription_callback(on_state_sub) + mock_client.device_info = AsyncMock(return_value=device_info) mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) mock_client.list_entities_services = AsyncMock( @@ -267,6 +289,7 @@ async def _mock_generic_device_entry( ) mock_client.subscribe_states = _subscribe_states mock_client.subscribe_service_calls = _subscribe_service_calls + mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states try_connect_done = Event() diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index a1ba05d4a94..96a8a341308 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -533,6 +533,69 @@ async def test_connection_aborted_wrong_device( assert "Unexpected device found at" not in caplog.text +async def test_state_subscription( + mock_client: APIClient, + hass: HomeAssistant, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test ESPHome subscribes to state changes.""" + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 3.0}) + device.mock_home_assistant_state_subscription("binary_sensor.test", None) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "on") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "off", {"bool": True, "float": 3.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "off") + ] + mock_client.send_home_assistant_state.reset_mock() + device.mock_home_assistant_state_subscription("binary_sensor.test", "bool") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "bool", "on") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "off", {"bool": False, "float": 3.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "bool", "off") + ] + mock_client.send_home_assistant_state.reset_mock() + device.mock_home_assistant_state_subscription("binary_sensor.test", "float") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", "float", "3.0") + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "on", {"bool": True, "float": 4.0}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [ + call("binary_sensor.test", None, "on"), + call("binary_sensor.test", "bool", "on"), + call("binary_sensor.test", "float", "4.0"), + ] + mock_client.send_home_assistant_state.reset_mock() + hass.states.async_set("binary_sensor.test", "on", {}) + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [] + hass.states.async_remove("binary_sensor.test") + await hass.async_block_till_done() + assert mock_client.send_home_assistant_state.mock_calls == [] + + async def test_debug_logging( mock_client: APIClient, hass: HomeAssistant, From 52653220e372b5c8292164d69c4c0505c137cde7 Mon Sep 17 00:00:00 2001 From: wilburCforce <109390391+wilburCforce@users.noreply.github.com> Date: Fri, 5 Jan 2024 13:07:08 -0600 Subject: [PATCH 0301/1544] Add code owner for Lutron (#107280) * hassfest enable * update code owner * fix code owner typo * removed garbage --- CODEOWNERS | 4 ++-- homeassistant/components/lutron/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d4fd1302e46..8dc66535be1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -747,8 +747,8 @@ build.json @home-assistant/supervisor /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lupusec/ @majuss -/homeassistant/components/lutron/ @cdheiser -/tests/components/lutron/ @cdheiser +/homeassistant/components/lutron/ @cdheiser @wilburCForce +/tests/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron_caseta/ @swails @bdraco @danaues /tests/components/lutron_caseta/ @swails @bdraco @danaues /homeassistant/components/lyric/ @timmo001 diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 83b391fa9b5..6444aa306a2 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -1,7 +1,7 @@ { "domain": "lutron", "name": "Lutron", - "codeowners": ["@cdheiser"], + "codeowners": ["@cdheiser", "@wilburCForce"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", From 9672ca5719ea06d761e59022515c0f8175e11651 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 20:38:02 +0100 Subject: [PATCH 0302/1544] Extract LutronDevice into separate file (#107285) --- homeassistant/components/lutron/__init__.py | 34 ------------------ .../components/lutron/binary_sensor.py | 3 +- homeassistant/components/lutron/cover.py | 3 +- homeassistant/components/lutron/entity.py | 35 +++++++++++++++++++ homeassistant/components/lutron/light.py | 3 +- homeassistant/components/lutron/scene.py | 3 +- homeassistant/components/lutron/switch.py | 3 +- 7 files changed, 45 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/lutron/entity.py diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 3a24c0eb0e8..27d55b8c936 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -16,7 +16,6 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -178,39 +177,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class LutronDevice(Entity): - """Representation of a Lutron device entity.""" - - _attr_should_poll = False - - def __init__(self, area_name, lutron_device, controller) -> None: - """Initialize the device.""" - self._lutron_device = lutron_device - self._controller = controller - self._area_name = area_name - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self._lutron_device.subscribe(self._update_callback, None) - - def _update_callback(self, _device, _context, _event, _params): - """Run when invoked by pylutron when the device state changes.""" - self.schedule_update_ha_state() - - @property - def name(self) -> str: - """Return the name of the device.""" - return f"{self._area_name} {self._lutron_device.name}" - - @property - def unique_id(self): - """Return a unique ID.""" - # Temporary fix for https://github.com/thecynic/pylutron/issues/70 - if self._lutron_device.uuid is None: - return None - return f"{self._controller.guid}_{self._lutron_device.uuid}" - - class LutronButton: """Representation of a button on a Lutron keypad. diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 1b32b009f01..1a552c539e5 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -16,7 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index a3b977b9bb3..230722a7618 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -14,7 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py new file mode 100644 index 00000000000..238c2e3c552 --- /dev/null +++ b/homeassistant/components/lutron/entity.py @@ -0,0 +1,35 @@ +"""Base class for Lutron devices.""" +from homeassistant.helpers.entity import Entity + + +class LutronDevice(Entity): + """Representation of a Lutron device entity.""" + + _attr_should_poll = False + + def __init__(self, area_name, lutron_device, controller) -> None: + """Initialize the device.""" + self._lutron_device = lutron_device + self._controller = controller + self._area_name = area_name + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._lutron_device.subscribe(self._update_callback, None) + + def _update_callback(self, _device, _context, _event, _params): + """Run when invoked by pylutron when the device state changes.""" + self.schedule_update_ha_state() + + @property + def name(self) -> str: + """Return the name of the device.""" + return f"{self._area_name} {self._lutron_device.name}" + + @property + def unique_id(self): + """Return a unique ID.""" + # Temporary fix for https://github.com/thecynic/pylutron/issues/70 + if self._lutron_device.uuid is None: + return None + return f"{self._controller.guid}_{self._lutron_device.uuid}" diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index c6e54675ffd..2c794314fc0 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from .entity import LutronDevice async def async_setup_entry( diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index ed4e28d945a..5e0b7920270 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -8,7 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from .entity import LutronDevice async def async_setup_entry( diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 572b599787a..45b6f1bcc25 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from .entity import LutronDevice async def async_setup_entry( From bb03579bd9aff6ab213ea4c75e3f1fd4db48c466 Mon Sep 17 00:00:00 2001 From: Roman Sivriver Date: Fri, 5 Jan 2024 14:39:04 -0500 Subject: [PATCH 0303/1544] Fix typo in recorder strings.json (#107278) --- homeassistant/components/recorder/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 24f0d806edd..eb162628727 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -29,7 +29,7 @@ }, "apply_filter": { "name": "Apply filter", - "description": "Applys `entity_id` and `event_type` filters in addition to time-based purge." + "description": "Apply `entity_id` and `event_type` filters in addition to time-based purge." } } }, From 2b43271c3bf9f9906231c9e9d42aaaae0b072fc0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 5 Jan 2024 22:04:10 +0100 Subject: [PATCH 0304/1544] Move Lutron entry data to typed class (#107256) * Move Lutron entry data to typed class * Move Lutron entry data to typed class * Exclude file * Update homeassistant/components/lutron/__init__.py Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- .coveragerc | 1 + homeassistant/components/lutron/__init__.py | 156 +++++++++--------- .../components/lutron/binary_sensor.py | 17 +- homeassistant/components/lutron/cover.py | 15 +- homeassistant/components/lutron/light.py | 15 +- homeassistant/components/lutron/scene.py | 19 ++- homeassistant/components/lutron/switch.py | 18 +- 7 files changed, 125 insertions(+), 116 deletions(-) diff --git a/.coveragerc b/.coveragerc index 5e389b0e58f..614aad827cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -699,6 +699,7 @@ omit = homeassistant/components/lutron/__init__.py homeassistant/components/lutron/binary_sensor.py homeassistant/components/lutron/cover.py + homeassistant/components/lutron/entity.py homeassistant/components/lutron/light.py homeassistant/components/lutron/switch.py homeassistant/components/lutron_caseta/__init__.py diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 27d55b8c936..b5b7f4d1b31 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -1,7 +1,8 @@ """Component for interacting with a Lutron RadioRA 2 system.""" +from dataclasses import dataclass import logging -from pylutron import Button, Lutron +from pylutron import Button, Led, Lutron, OccupancyGroup, Output import voluptuous as vol from homeassistant import config_entries @@ -32,10 +33,6 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -LUTRON_BUTTONS = "lutron_buttons" -LUTRON_CONTROLLER = "lutron_controller" -LUTRON_DEVICES = "lutron_devices" - # Attribute on events that indicates what action was taken with the button. ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" @@ -105,78 +102,6 @@ async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Set up the Lutron integration.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[LUTRON_BUTTONS] = [] - hass.data[LUTRON_CONTROLLER] = None - hass.data[LUTRON_DEVICES] = { - "light": [], - "cover": [], - "switch": [], - "scene": [], - "binary_sensor": [], - } - host = config_entry.data[CONF_HOST] - uid = config_entry.data[CONF_USERNAME] - pwd = config_entry.data[CONF_PASSWORD] - - def _load_db() -> bool: - hass.data[LUTRON_CONTROLLER].load_xml_db() - return True - - hass.data[LUTRON_CONTROLLER] = Lutron(host, uid, pwd) - await hass.async_add_executor_job(_load_db) - hass.data[LUTRON_CONTROLLER].connect() - _LOGGER.info("Connected to main repeater at %s", host) - - # Sort our devices into types - _LOGGER.debug("Start adding devices") - for area in hass.data[LUTRON_CONTROLLER].areas: - _LOGGER.debug("Working on area %s", area.name) - for output in area.outputs: - _LOGGER.debug("Working on output %s", output.type) - if output.type == "SYSTEM_SHADE": - hass.data[LUTRON_DEVICES]["cover"].append((area.name, output)) - elif output.is_dimmable: - hass.data[LUTRON_DEVICES]["light"].append((area.name, output)) - else: - hass.data[LUTRON_DEVICES]["switch"].append((area.name, output)) - for keypad in area.keypads: - for button in keypad.buttons: - # If the button has a function assigned to it, add it as a scene - if button.name != "Unknown Button" and button.button_type in ( - "SingleAction", - "Toggle", - "SingleSceneRaiseLower", - "MasterRaiseLower", - ): - # Associate an LED with a button if there is one - led = next( - (led for led in keypad.leds if led.number == button.number), - None, - ) - hass.data[LUTRON_DEVICES]["scene"].append( - (area.name, keypad.name, button, led) - ) - - hass.data[LUTRON_BUTTONS].append( - LutronButton(hass, area.name, keypad, button) - ) - if area.occupancy_group is not None: - hass.data[LUTRON_DEVICES]["binary_sensor"].append( - (area.name, area.occupancy_group) - ) - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Clean up resources and entities associated with the integration.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - class LutronButton: """Representation of a button on a Lutron keypad. @@ -227,3 +152,80 @@ class LutronButton: ATTR_UUID: self._uuid, } self._hass.bus.fire(self._event, data) + + +@dataclass(slots=True) +class LutronData: + """Storage class for platform global data.""" + + client: Lutron + covers: list[tuple[str, Output]] + lights: list[tuple[str, Output]] + switches: list[tuple[str, Output]] + scenes: list[tuple[str, str, Button, Led]] + binary_sensors: list[tuple[str, OccupancyGroup]] + buttons: list[LutronButton] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the Lutron integration.""" + + host = config_entry.data[CONF_HOST] + uid = config_entry.data[CONF_USERNAME] + pwd = config_entry.data[CONF_PASSWORD] + + lutron_client = Lutron(host, uid, pwd) + await hass.async_add_executor_job(lutron_client.load_xml_db) + lutron_client.connect() + _LOGGER.info("Connected to main repeater at %s", host) + + entry_data = LutronData( + client=lutron_client, + covers=[], + lights=[], + switches=[], + scenes=[], + binary_sensors=[], + buttons=[], + ) + # Sort our devices into types + _LOGGER.debug("Start adding devices") + for area in lutron_client.areas: + _LOGGER.debug("Working on area %s", area.name) + for output in area.outputs: + _LOGGER.debug("Working on output %s", output.type) + if output.type == "SYSTEM_SHADE": + entry_data.covers.append((area.name, output)) + elif output.is_dimmable: + entry_data.lights.append((area.name, output)) + else: + entry_data.switches.append((area.name, output)) + for keypad in area.keypads: + for button in keypad.buttons: + # If the button has a function assigned to it, add it as a scene + if button.name != "Unknown Button" and button.button_type in ( + "SingleAction", + "Toggle", + "SingleSceneRaiseLower", + "MasterRaiseLower", + ): + # Associate an LED with a button if there is one + led = next( + (led for led in keypad.leds if led.number == button.number), + None, + ) + entry_data.scenes.append((area.name, keypad.name, button, led)) + + entry_data.buttons.append(LutronButton(hass, area.name, keypad, button)) + if area.occupancy_group is not None: + entry_data.binary_sensors.append((area.name, area.occupancy_group)) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Clean up resources and entities associated with the integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 1a552c539e5..65f9bd4d390 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -14,9 +14,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType -from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from . import DOMAIN, LutronData from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) @@ -26,18 +25,20 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Lutron binary_sensor platform. Adds occupancy groups from the Main Repeater associated with the config_entry as binary_sensor entities. """ - entities = [] - for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: - entity = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) - entities.append(entity) - async_add_entities(entities, True) + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronOccupancySensor(area_name, device, entry_data.client) + for area_name, device in entry_data.binary_sensors + ], + True, + ) class LutronOccupancySensor(LutronDevice, BinarySensorEntity): diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 230722a7618..1658d92b79c 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from . import DOMAIN, LutronData from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) @@ -30,11 +30,14 @@ async def async_setup_entry( Adds shades from the Main Repeater associated with the config_entry as cover entities. """ - entities = [] - for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: - entity = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) - entities.append(entity) - async_add_entities(entities, True) + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronCover(area_name, device, entry_data.covers) + for area_name, device in entry_data.covers + ], + True, + ) class LutronCover(LutronDevice, CoverEntity): diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 2c794314fc0..a04006de61f 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from . import DOMAIN, LutronData from .entity import LutronDevice @@ -23,11 +23,14 @@ async def async_setup_entry( Adds dimmers from the Main Repeater associated with the config_entry as light entities. """ - entities = [] - for area_name, device in hass.data[LUTRON_DEVICES]["light"]: - entity = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) - entities.append(entity) - async_add_entities(entities, True) + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronLight(area_name, device, entry_data.client) + for area_name, device in entry_data.lights + ], + True, + ) def to_lutron_level(level): diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 5e0b7920270..2033a9024bf 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from . import DOMAIN, LutronData from .entity import LutronDevice @@ -22,14 +22,15 @@ async def async_setup_entry( Adds scenes from the Main Repeater associated with the config_entry as scene entities. """ - entities = [] - for scene_data in hass.data[LUTRON_DEVICES]["scene"]: - (area_name, keypad_name, device, led) = scene_data - entity = LutronScene( - area_name, keypad_name, device, led, hass.data[LUTRON_CONTROLLER] - ) - entities.append(entity) - async_add_entities(entities, True) + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + [ + LutronScene(area_name, keypad_name, device, led, entry_data.client) + for area_name, keypad_name, device, led in entry_data.scenes + ], + True, + ) class LutronScene(LutronDevice, Scene): diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 45b6f1bcc25..b418c9c481c 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LUTRON_CONTROLLER, LUTRON_DEVICES +from . import DOMAIN, LutronData from .entity import LutronDevice @@ -23,21 +23,19 @@ async def async_setup_entry( Adds switches from the Main Repeater associated with the config_entry as switch entities. """ - entities = [] + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entities: list[SwitchEntity] = [] # Add Lutron Switches - for area_name, device in hass.data[LUTRON_DEVICES]["switch"]: - entity = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) - entities.append(entity) + for area_name, device in entry_data.switches: + entities.append(LutronSwitch(area_name, device, entry_data.client)) # Add the indicator LEDs for scenes (keypad buttons) - for scene_data in hass.data[LUTRON_DEVICES]["scene"]: - (area_name, keypad_name, scene, led) = scene_data + for area_name, keypad_name, scene, led in entry_data.scenes: if led is not None: - led = LutronLed( - area_name, keypad_name, scene, led, hass.data[LUTRON_CONTROLLER] + entities.append( + LutronLed(area_name, keypad_name, scene, led, entry_data.client) ) - entities.append(led) async_add_entities(entities, True) From 2ed93976117c2bf93a382253ff6a09fe35f3638c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 5 Jan 2024 16:53:43 -0500 Subject: [PATCH 0305/1544] Fix assertion error when unloading ZHA with pollable entities (#107311) --- homeassistant/components/zha/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 027e710e30c..ea5d09dd6f4 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -216,9 +216,9 @@ class PollableSensor(Sensor): async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" - assert self._cancel_refresh_handle - self._cancel_refresh_handle() - self._cancel_refresh_handle = None + if self._cancel_refresh_handle is not None: + self._cancel_refresh_handle() + self._cancel_refresh_handle = None self.debug("stopped polling during device removal") await super().async_will_remove_from_hass() From ad3c78f848febb5161130afcac51a5f5c28bf677 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 6 Jan 2024 01:32:04 +0200 Subject: [PATCH 0306/1544] Fix Shelly missing Gen value for older devices (#107294) --- homeassistant/components/shelly/config_flow.py | 7 ++++--- homeassistant/components/shelly/coordinator.py | 9 ++++++--- tests/components/shelly/__init__.py | 6 ++++-- tests/components/shelly/test_init.py | 8 ++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 29daf050163..59ae6eed196 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -36,6 +36,7 @@ from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, + get_device_entry_gen, get_info_auth, get_info_gen, get_model_name, @@ -322,7 +323,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") - if self.entry.data.get(CONF_GEN, 1) != 1: + if get_device_entry_gen(self.entry) != 1: user_input[CONF_USERNAME] = "admin" try: await validate_input(self.hass, host, info, user_input) @@ -335,7 +336,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - if self.entry.data.get(CONF_GEN, 1) in BLOCK_GENERATIONS: + if get_device_entry_gen(self.entry) in BLOCK_GENERATIONS: schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -364,7 +365,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return ( - config_entry.data.get(CONF_GEN) in RPC_GENERATIONS + get_device_entry_gen(config_entry) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 77fa0bd2efd..7f88cce1134 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -33,7 +33,6 @@ from .const import ( ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, CONF_BLE_SCANNER_MODE, - CONF_GEN, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DOMAIN, @@ -58,7 +57,11 @@ from .const import ( UPDATE_PERIOD_MULTIPLIER, BLEScannerMode, ) -from .utils import get_rpc_device_wakeup_period, update_device_fw_info +from .utils import ( + get_device_entry_gen, + get_rpc_device_wakeup_period, + update_device_fw_info, +) _DeviceT = TypeVar("_DeviceT", bound="BlockDevice|RpcDevice") @@ -136,7 +139,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): manufacturer="Shelly", model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, - hw_version=f"gen{self.entry.data[CONF_GEN]} ({self.model})", + hw_version=f"gen{get_device_entry_gen(self.entry)} ({self.model})", configuration_url=f"http://{self.entry.data[CONF_HOST]}", ) self.device_id = device_entry.id diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 0384e9255a3..26040e13557 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -12,6 +12,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.shelly.const import ( + CONF_GEN, CONF_SLEEP_PERIOD, DOMAIN, REST_SENSORS_UPDATE_INTERVAL, @@ -30,7 +31,7 @@ MOCK_MAC = "123456789ABC" async def init_integration( hass: HomeAssistant, - gen: int, + gen: int | None, model=MODEL_25, sleep_period=0, options: dict[str, Any] | None = None, @@ -41,8 +42,9 @@ async def init_integration( CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: sleep_period, "model": model, - "gen": gen, } + if gen is not None: + data[CONF_GEN] = gen entry = MockConfigEntry( domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 8f6599b39e4..643fc775cc4 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -301,3 +301,11 @@ async def test_no_attempt_to_stop_scanner_with_sleepy_devices( mock_rpc_device.mock_update() await hass.async_block_till_done() assert not mock_stop_scanner.call_count + + +async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device) -> None: + """Test successful Gen1 device init when gen is missing in entry data.""" + entry = await init_integration(hass, None) + + assert entry.state is ConfigEntryState.LOADED + assert hass.states.get("switch.test_name_channel_1").state is STATE_ON From 49284fb469ee889d271b8f0c95348634ccbad119 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 6 Jan 2024 00:33:04 +0100 Subject: [PATCH 0307/1544] Fix duplicate unique_ids in emonitor (#107320) --- homeassistant/components/emonitor/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 5600cca308e..1c3011ee28d 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant 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 UNDEFINED, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -83,7 +83,7 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): mac_address = self.emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) label = self.channel_data.label or str(channel_number) - if description.name is not UNDEFINED: + if description.translation_key is not None: self._attr_translation_placeholders = {"label": label} self._attr_unique_id = f"{mac_address}_{channel_number}_{description.key}" else: From 133fd6ea5df561211e14fe06539d25e8fa031eb1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 00:51:17 +0100 Subject: [PATCH 0308/1544] Fix lutron test AttributeError (#107323) --- tests/components/lutron/test_config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index feb5c77c9be..85a5851aa74 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -1,4 +1,5 @@ """Test the lutron config flow.""" +from email.message import Message from unittest.mock import AsyncMock, patch from urllib.error import HTTPError @@ -45,7 +46,7 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No @pytest.mark.parametrize( ("raise_error", "text_error"), [ - (HTTPError("", 404, "", None, {}), "cannot_connect"), + (HTTPError("", 404, "", Message(), None), "cannot_connect"), (Exception, "unknown"), ], ) From 4e62dacc00fa2596eb94e66f053af8882363839f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 01:15:50 +0100 Subject: [PATCH 0309/1544] Fix lutron test AttributeError (2) (#107324) --- tests/components/lutron/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 85a5851aa74..b1f4b3365c9 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -164,7 +164,7 @@ async def test_import( @pytest.mark.parametrize( ("raise_error", "reason"), [ - (HTTPError("", 404, "", None, {}), "cannot_connect"), + (HTTPError("", 404, "", Message(), None), "cannot_connect"), (Exception, "unknown"), ], ) From c86d1b03fcd09f728811d880425efb33e34f86d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 16:06:31 -1000 Subject: [PATCH 0310/1544] Disable thermobeacon voltage sensors by default (#107326) --- homeassistant/components/thermobeacon/sensor.py | 2 ++ tests/components/thermobeacon/test_sensor.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 6db5ff2a554..c6fb978923e 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -76,6 +76,8 @@ SENSOR_DESCRIPTIONS = { device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } diff --git a/tests/components/thermobeacon/test_sensor.py b/tests/components/thermobeacon/test_sensor.py index 788426f605a..e8d77e3a487 100644 --- a/tests/components/thermobeacon/test_sensor.py +++ b/tests/components/thermobeacon/test_sensor.py @@ -24,7 +24,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, THERMOBEACON_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 4 + assert len(hass.states.async_all("sensor")) == 3 humid_sensor = hass.states.get("sensor.lanyard_mini_hygrometer_eeff_humidity") humid_sensor_attrs = humid_sensor.attributes From 6201e81eca53b55dcbcd4e01088946cf0c486694 Mon Sep 17 00:00:00 2001 From: nic <31355096+nabbi@users.noreply.github.com> Date: Fri, 5 Jan 2024 21:19:35 -0600 Subject: [PATCH 0311/1544] Bump zm-py version to v0.5.3 for zoneminder (#107331) zm-py version bump for zoneminder Signed-off-by: Nic Boet --- CODEOWNERS | 2 +- homeassistant/components/zoneminder/manifest.json | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8dc66535be1..0a38fce05f6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1559,7 +1559,7 @@ build.json @home-assistant/supervisor /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core -/homeassistant/components/zoneminder/ @rohankapoorcom +/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json index 80ecbe53315..309ce43101c 100644 --- a/homeassistant/components/zoneminder/manifest.json +++ b/homeassistant/components/zoneminder/manifest.json @@ -1,9 +1,9 @@ { "domain": "zoneminder", "name": "ZoneMinder", - "codeowners": ["@rohankapoorcom"], + "codeowners": ["@rohankapoorcom", "@nabbi"], "documentation": "https://www.home-assistant.io/integrations/zoneminder", "iot_class": "local_polling", "loggers": ["zoneminder"], - "requirements": ["zm-py==0.5.2"] + "requirements": ["zm-py==0.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 522c99dd572..6889e6fbf28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ zigpy-znp==0.12.1 zigpy==0.60.4 # homeassistant.components.zoneminder -zm-py==0.5.2 +zm-py==0.5.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From f1d2868fd0e04e4e5a3d6d8d062ab09704efd355 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 5 Jan 2024 23:30:18 -0500 Subject: [PATCH 0312/1544] Add API to fetch Assist devices (#107333) * Add API to fetch Assist devices * Revert some changes to fixture, make a single fixture for an Assist device --- .../components/assist_pipeline/pipeline.py | 14 +++- .../components/assist_pipeline/select.py | 17 ++-- .../assist_pipeline/websocket_api.py | 32 +++++++- homeassistant/components/esphome/select.py | 3 +- homeassistant/components/voip/select.py | 2 +- homeassistant/components/wyoming/select.py | 2 +- tests/components/assist_pipeline/conftest.py | 81 ++++++++++++++++++- .../components/assist_pipeline/test_select.py | 19 +++-- .../assist_pipeline/test_websocket.py | 19 +++++ 9 files changed, 166 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 71136dcdecb..a98f184094f 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1703,7 +1703,7 @@ class PipelineRuns: pipeline_run.abort_wake_word_detection = True -@dataclass +@dataclass(slots=True) class DeviceAudioQueue: """Audio capture queue for a satellite device.""" @@ -1717,6 +1717,14 @@ class DeviceAudioQueue: """Flag to be set if audio samples were dropped because the queue was full.""" +@dataclass(slots=True) +class AssistDevice: + """Assist device.""" + + domain: str + unique_id_prefix: str + + class PipelineData: """Store and debug data stored in hass.data.""" @@ -1724,12 +1732,12 @@ class PipelineData: """Initialize.""" self.pipeline_store = pipeline_store self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} - self.pipeline_devices: set[str] = set() + self.pipeline_devices: dict[str, AssistDevice] = {} self.pipeline_runs = PipelineRuns(pipeline_store) self.device_audio_queues: dict[str, DeviceAudioQueue] = {} -@dataclass +@dataclass(slots=True) class PipelineRunDebug: """Debug data for a pipelinerun.""" diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 83e1bd3ab36..43ed003f65d 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state from .const import DOMAIN -from .pipeline import PipelineData, PipelineStorageCollection +from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection from .vad import VadSensitivity OPTION_PREFERRED = "preferred" @@ -70,8 +70,10 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): _attr_current_option = OPTION_PREFERRED _attr_options = [OPTION_PREFERRED] - def __init__(self, hass: HomeAssistant, unique_id_prefix: str) -> None: + def __init__(self, hass: HomeAssistant, domain: str, unique_id_prefix: str) -> None: """Initialize a pipeline selector.""" + self._domain = domain + self._unique_id_prefix = unique_id_prefix self._attr_unique_id = f"{unique_id_prefix}-pipeline" self.hass = hass self._update_options() @@ -91,11 +93,16 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): self._attr_current_option = state.state if self.registry_entry and (device_id := self.registry_entry.device_id): - pipeline_data.pipeline_devices.add(device_id) - self.async_on_remove( - lambda: pipeline_data.pipeline_devices.discard(device_id) + pipeline_data.pipeline_devices[device_id] = AssistDevice( + self._domain, self._unique_id_prefix ) + def cleanup() -> None: + """Clean up registered device.""" + pipeline_data.pipeline_devices.pop(device_id) + + self.async_on_remove(cleanup) + async def async_select_option(self, option: str) -> None: """Select an option.""" self._attr_current_option = option diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 89cced519df..bfba8563875 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.util import language as language_util from .const import ( @@ -53,6 +53,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) websocket_api.async_register_command(hass, websocket_list_languages) websocket_api.async_register_command(hass, websocket_list_runs) + websocket_api.async_register_command(hass, websocket_list_devices) websocket_api.async_register_command(hass, websocket_get_run) websocket_api.async_register_command(hass, websocket_device_capture) @@ -287,6 +288,35 @@ def websocket_list_runs( ) +@callback +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_pipeline/device/list", + } +) +def websocket_list_devices( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List assist devices.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + ent_reg = er.async_get(hass) + connection.send_result( + msg["id"], + [ + { + "device_id": device_id, + "pipeline_entity": ent_reg.async_get_entity_id( + "select", info.domain, f"{info.unique_id_prefix}-pipeline" + ), + } + for device_id, info in pipeline_data.pipeline_devices.items() + ], + ) + + @callback @websocket_api.require_admin @websocket_api.websocket_command( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index a3464b137dc..3d4d296bb87 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN from .domain_data import DomainData from .entity import ( EsphomeAssistEntity, @@ -75,7 +76,7 @@ class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) - AssistPipelineSelect.__init__(self, hass, self._device_info.mac_address) + AssistPipelineSelect.__init__(self, hass, DOMAIN, self._device_info.mac_address) class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index 94a3aacc0fd..f145f866ae3 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -51,7 +51,7 @@ class VoipPipelineSelect(VoIPEntity, AssistPipelineSelect): def __init__(self, hass: HomeAssistant, device: VoIPDevice) -> None: """Initialize a pipeline selector.""" VoIPEntity.__init__(self, device) - AssistPipelineSelect.__init__(self, hass, device.voip_id) + AssistPipelineSelect.__init__(self, hass, DOMAIN, device.voip_id) class VoipVadSensitivitySelect(VoIPEntity, VadSensitivitySelect): diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index c04bad4bef8..99f26c3e440 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -57,7 +57,7 @@ class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelec self.device = device WyomingSatelliteEntity.__init__(self, device) - AssistPipelineSelect.__init__(self, hass, device.satellite_id) + AssistPipelineSelect.__init__(self, hass, DOMAIN, device.satellite_id) async def async_select_option(self, option: str) -> None: """Select an option.""" diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 97f80a33d1d..38c96871ed3 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -8,13 +8,15 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components import stt, tts, wake_word -from homeassistant.components.assist_pipeline import DOMAIN +from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, PipelineStorageCollection, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -288,7 +290,7 @@ async def init_supporting_components( ) -> bool: """Set up test config entry.""" await hass.config_entries.async_forward_entry_setups( - config_entry, [stt.DOMAIN, wake_word.DOMAIN] + config_entry, [Platform.STT, Platform.WAKE_WORD] ) return True @@ -297,7 +299,7 @@ async def init_supporting_components( ) -> bool: """Unload up test config entry.""" await hass.config_entries.async_unload_platforms( - config_entry, [stt.DOMAIN, wake_word.DOMAIN] + config_entry, [Platform.STT, Platform.WAKE_WORD] ) return True @@ -369,6 +371,79 @@ async def init_components(hass: HomeAssistant, init_supporting_components): assert await async_setup_component(hass, "assist_pipeline", {}) +@pytest.fixture +async def assist_device(hass: HomeAssistant, init_components) -> dr.DeviceEntry: + """Create an assist device.""" + config_entry = MockConfigEntry(domain="test_assist_device") + config_entry.add_to_hass(hass) + + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + name="Test Device", + config_entry_id=config_entry.entry_id, + identifiers={("test_assist_device", "test")}, + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.SELECT] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_unload_platforms( + config_entry, [Platform.SELECT] + ) + return True + + async def async_setup_entry_select_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test select platform via config entry.""" + entities = [ + assist_select.AssistPipelineSelect( + hass, "test_assist_device", "test-prefix" + ), + assist_select.VadSensitivitySelect(hass, "test-prefix"), + ] + for ent in entities: + ent._attr_device_info = dr.DeviceInfo( + identifiers={("test_assist_device", "test")}, + ) + async_add_entities(entities) + + mock_integration( + hass, + MockModule( + "test_assist_device", + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + mock_platform( + hass, + "test_assist_device.select", + MockPlatform( + async_setup_entry=async_setup_entry_select_platform, + ), + ) + mock_platform(hass, "test_assist_device.config_flow") + + with mock_config_flow("test_assist_device", ConfigFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return device + + @pytest.fixture def pipeline_data(hass: HomeAssistant, init_components) -> PipelineData: """Return pipeline data.""" diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index c4e750e1019..73c069ddd04 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.assist_pipeline import Pipeline from homeassistant.components.assist_pipeline.pipeline import ( + AssistDevice, PipelineData, PipelineStorageCollection, ) @@ -33,7 +34,7 @@ class SelectPlatform(MockPlatform): async_add_entities: AddEntitiesCallback, ) -> None: """Set up fake select platform.""" - pipeline_entity = AssistPipelineSelect(hass, "test") + pipeline_entity = AssistPipelineSelect(hass, "test-domain", "test-prefix") pipeline_entity._attr_device_info = DeviceInfo( identifiers={("test", "test")}, ) @@ -109,13 +110,15 @@ async def test_select_entity_registering_device( assert device is not None # Test device is registered - assert pipeline_data.pipeline_devices == {device.id} + assert pipeline_data.pipeline_devices == { + device.id: AssistDevice("test-domain", "test-prefix") + } await hass.config_entries.async_remove(init_select.entry_id) await hass.async_block_till_done() # Test device is removed - assert pipeline_data.pipeline_devices == set() + assert pipeline_data.pipeline_devices == {} async def test_select_entity_changing_pipelines( @@ -128,7 +131,7 @@ async def test_select_entity_changing_pipelines( """Test entity tracking pipeline changes.""" config_entry = init_select # nicer naming - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ @@ -143,13 +146,13 @@ async def test_select_entity_changing_pipelines( "select", "select_option", { - "entity_id": "select.assist_pipeline_test_pipeline", + "entity_id": "select.assist_pipeline_test_prefix_pipeline", "option": pipeline_2.name, }, blocking=True, ) - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == pipeline_2.name @@ -157,14 +160,14 @@ async def test_select_entity_changing_pipelines( assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == pipeline_2.name # Remove selected pipeline await pipeline_storage.async_delete_item(pipeline_2.id) - state = hass.states.get("select.assist_pipeline_test_pipeline") + state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None assert state.state == "preferred" assert state.attributes["options"] == [ diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 458320a9a90..3ea6be028c1 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2502,3 +2502,22 @@ async def test_pipeline_empty_tts_output( assert msg["event"]["type"] == "run-end" assert msg["event"]["data"] == snapshot events.append(msg["event"]) + + +async def test_pipeline_list_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + assist_device, +) -> None: + """Test list devices.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "assist_pipeline/device/list"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == [ + { + "device_id": assist_device.id, + "pipeline_entity": "select.test_assist_device_test_prefix_pipeline", + } + ] From ee67c97274e6b628a66acf71a6037b8ccc784ae7 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Sat, 6 Jan 2024 09:27:34 +0100 Subject: [PATCH 0313/1544] Add time entity to Renson (#105031) * Add time entity to Renson * Update homeassistant/components/renson/time.py Co-authored-by: Robert Resch * remove deleted sensors from strings.json * Fix Ruff issue * Fixed loading issue * Try to fix frozen error * Revert "Try to fix frozen error" This reverts commit 803104c2925e6d5acecc0a9d45170a0c85ee7f0e. * Try to fix frozen error * Revert "Try to fix frozen error" This reverts commit 8ba2dcce9444fadcf6bf79e86295f93359b6d7b8. * Update homeassistant/components/renson/time.py Co-authored-by: Robert Resch * Change import + api argument * use _attr_has_entity_name * Update homeassistant/components/renson/time.py Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Robert Resch Co-authored-by: Jan-Philipp Benecke --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/sensor.py | 16 --- homeassistant/components/renson/strings.json | 14 +-- homeassistant/components/renson/time.py | 100 +++++++++++++++++++ 5 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/renson/time.py diff --git a/.coveragerc b/.coveragerc index 614aad827cf..0724ff80394 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1040,6 +1040,7 @@ omit = homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/number.py + homeassistant/components/renson/time.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 2a9c13be543..aee5dec0599 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.FAN, Platform.NUMBER, Platform.SENSOR, + Platform.TIME, ] diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 380a83b6818..801c25e6ab2 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -17,13 +17,11 @@ from renson_endura_delta.field_enum import ( CURRENT_AIRFLOW_INGOING_FIELD, CURRENT_LEVEL_FIELD, DAY_POLLUTION_FIELD, - DAYTIME_FIELD, FILTER_REMAIN_FIELD, HUMIDITY_FIELD, INDOOR_TEMP_FIELD, MANUAL_LEVEL_FIELD, NIGHT_POLLUTION_FIELD, - NIGHTTIME_FIELD, OUTDOOR_TEMP_FIELD, FieldEnum, ) @@ -185,20 +183,6 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=["off", "level1", "level2", "level3", "level4", "breeze"], ), - RensonSensorEntityDescription( - key="DAYTIME_FIELD", - translation_key="start_day_time", - field=DAYTIME_FIELD, - raw_format=False, - entity_registry_enabled_default=False, - ), - RensonSensorEntityDescription( - key="NIGHTTIME_FIELD", - translation_key="start_night_time", - field=NIGHTTIME_FIELD, - raw_format=False, - entity_registry_enabled_default=False, - ), RensonSensorEntityDescription( key="DAY_POLLUTION_FIELD", translation_key="day_pollution_level", diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index a826b5a3dd3..da385ef07bd 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -24,6 +24,14 @@ "name": "Reset filter counter" } }, + "time": { + "day_time": { + "name": "Start time of the day" + }, + "night_time": { + "name": "Start time of the night" + } + }, "number": { "filter_change": { "name": "Filter clean/replacement" @@ -125,12 +133,6 @@ "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]" } }, - "start_day_time": { - "name": "Start day time" - }, - "start_night_time": { - "name": "Start night time" - }, "day_pollution_level": { "name": "Day pollution level", "state": { diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py new file mode 100644 index 00000000000..57d6869a72c --- /dev/null +++ b/homeassistant/components/renson/time.py @@ -0,0 +1,100 @@ +"""Renson ventilation unit time.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, time + +from renson_endura_delta.field_enum import DAYTIME_FIELD, NIGHTTIME_FIELD, FieldEnum +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +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 RensonData +from .const import DOMAIN +from .coordinator import RensonCoordinator +from .entity import RensonEntity + + +@dataclass(kw_only=True, frozen=True) +class RensonTimeEntityDescription(TimeEntityDescription): + """Class describing Renson time entity.""" + + action_fn: Callable[[RensonVentilation, str], None] + field: FieldEnum + + +ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = ( + RensonTimeEntityDescription( + key="day_time", + entity_category=EntityCategory.CONFIG, + translation_key="day_time", + action_fn=lambda api, time: api.set_day_time(time), + field=DAYTIME_FIELD, + ), + RensonTimeEntityDescription( + key="night_time", + translation_key="night_time", + entity_category=EntityCategory.CONFIG, + action_fn=lambda api, time: api.set_night_time(time), + field=NIGHTTIME_FIELD, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson time platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonTime(description, data.coordinator) for description in ENTITY_DESCRIPTIONS + ] + + async_add_entities(entities) + + +class RensonTime(RensonEntity, TimeEntity): + """Representation of a Renson time entity.""" + + entity_description: RensonTimeEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + description: RensonTimeEntityDescription, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, coordinator.api, coordinator) + + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + all_data = self.coordinator.data + + value = self.api.get_field_value(all_data, self.entity_description.field.name) + + self._attr_native_value = datetime.strptime( + value, + "%H:%M", + ).time() + + super()._handle_coordinator_update() + + def set_value(self, value: time) -> None: + """Triggers the action.""" + + string_value = value.strftime("%H:%M") + self.entity_description.action_fn(self.api, string_value) From ba6029043107e8ccb3c65508126e26122d33e1de Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sat, 6 Jan 2024 09:06:23 +0000 Subject: [PATCH 0314/1544] Fix support for play/pause functionality in System Bridge (#103423) Fix support for play/pause functionality --- homeassistant/components/system_bridge/media_player.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index ea9e8ab070d..02670d36fe3 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -118,10 +118,8 @@ class SystemBridgeMediaPlayer(SystemBridgeEntity, MediaPlayerEntity): features |= MediaPlayerEntityFeature.PREVIOUS_TRACK if data.media.is_next_enabled: features |= MediaPlayerEntityFeature.NEXT_TRACK - if data.media.is_pause_enabled: - features |= MediaPlayerEntityFeature.PAUSE - if data.media.is_play_enabled: - features |= MediaPlayerEntityFeature.PLAY + if data.media.is_pause_enabled or data.media.is_play_enabled: + features |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY if data.media.is_stop_enabled: features |= MediaPlayerEntityFeature.STOP From 44018a4183144b174b3ded174bafb4e10e1a05cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 23:12:31 -1000 Subject: [PATCH 0315/1544] Use faster identity checks for SupportsResponse Enum (#107351) --- homeassistant/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 6f71e5513f1..fed54689ab7 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2109,11 +2109,11 @@ class ServiceRegistry: raise ValueError( "Invalid argument return_response=True when blocking=False" ) - if handler.supports_response == SupportsResponse.NONE: + if handler.supports_response is SupportsResponse.NONE: raise ValueError( "Invalid argument return_response=True when handler does not support responses" ) - elif handler.supports_response == SupportsResponse.ONLY: + elif handler.supports_response is SupportsResponse.ONLY: raise ValueError( "Service call requires responses but caller did not ask for responses" ) From 851ad21d116f6964179da718d85bb3d67bfa06a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 23:16:57 -1000 Subject: [PATCH 0316/1544] Small cleanup to zeroconf properties matcher (#107342) * Small cleanup to zeroconf properties matcher - Switch to dict.items() to avoid dict key lookup - return early when a match is rejected * tweak --- homeassistant/components/zeroconf/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index e12a7599d4d..2e058c4067c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -321,12 +321,11 @@ async def _async_register_hass_zc_service( def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: """Check a matcher to ensure all values in props.""" - return not any( - key - for key in matcher - if key not in props - or not _memorized_fnmatch((props[key] or "").lower(), matcher[key]) - ) + for key, value in matcher.items(): + prop_val = props.get(key) + if prop_val is None or not _memorized_fnmatch(prop_val.lower(), value): + return False + return True def is_homekit_paired(props: dict[str, Any]) -> bool: From 6ff990e2c2f6a9f49af17a477318789ec8dd883a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jan 2024 23:20:30 -1000 Subject: [PATCH 0317/1544] Avoid fetching logger in check_if_deprecated_constant if there is nothing to log (#107341) getLogger needs a threading lock so its nice to avoid calling it if we are not going to log anything --- homeassistant/helpers/deprecation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 18a42ce9bcf..cf76bc78aa5 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -252,7 +252,6 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A Otherwise raise AttributeError. """ module_name = module_globals.get("__name__") - logger = logging.getLogger(module_name) value = replacement = None if (deprecated_const := module_globals.get(_PREFIX_DEPRECATED + name)) is None: raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") @@ -273,7 +272,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) - logger.debug(msg) + logging.getLogger(module_name).debug(msg) # PEP 562 -- Module __getattr__ and __dir__ # specifies that __getattr__ should raise AttributeError if the attribute is not # found. From 28c0c2d2ad616c568f7470ece05645879a0443fe Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 10:31:55 +0100 Subject: [PATCH 0318/1544] Enable strict typing for easyenergy (#107299) --- .strict-typing | 1 + homeassistant/components/easyenergy/services.py | 3 ++- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 6e862159680..0db793ace7a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -145,6 +145,7 @@ homeassistant.components.downloader.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* +homeassistant.components.easyenergy.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index a68dfcb791c..95763e5db25 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -111,7 +111,8 @@ def __get_coordinator( }, ) - return hass.data[DOMAIN][entry_id] + coordinator: EasyEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + return coordinator async def __get_prices( diff --git a/mypy.ini b/mypy.ini index b84c183cdf1..30b4ee9f048 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1211,6 +1211,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.easyenergy.*] +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.efergy.*] check_untyped_defs = true disallow_incomplete_defs = true From 135a718a0e92718949b36241c94ab0c8741e9c3f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 10:33:22 +0100 Subject: [PATCH 0319/1544] Enable strict typing for energyzero (#107300) --- .strict-typing | 1 + homeassistant/components/energyzero/services.py | 3 ++- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 0db793ace7a..6eeb39ceddb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -153,6 +153,7 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* +homeassistant.components.energyzero.* homeassistant.components.enigma2.* homeassistant.components.esphome.* homeassistant.components.event.* diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index d8e548c22f8..325c443375e 100644 --- a/homeassistant/components/energyzero/services.py +++ b/homeassistant/components/energyzero/services.py @@ -109,7 +109,8 @@ def __get_coordinator( }, ) - return hass.data[DOMAIN][entry_id] + coordinator: EnergyZeroDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + return coordinator async def __get_prices( diff --git a/mypy.ini b/mypy.ini index 30b4ee9f048..fc020a7b209 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1291,6 +1291,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.energyzero.*] +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.enigma2.*] check_untyped_defs = true disallow_incomplete_defs = true From 902d5a79ca207a45662b4d7dce5979c6f29f8e17 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 10:38:50 +0100 Subject: [PATCH 0320/1544] Enable strict typing for p1_monitor (#107301) --- .strict-typing | 1 + homeassistant/components/p1_monitor/sensor.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 6eeb39ceddb..9c70e27895d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -297,6 +297,7 @@ homeassistant.components.opensky.* homeassistant.components.openuv.* homeassistant.components.otbr.* homeassistant.components.overkiz.* +homeassistant.components.p1_monitor.* homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 17fba104c7a..587dc980e41 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -321,4 +321,4 @@ class P1MonitorSensorEntity( ) if isinstance(value, str): return value.lower() - return value + return value # type: ignore[no-any-return] diff --git a/mypy.ini b/mypy.ini index fc020a7b209..3f478afaa82 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2731,6 +2731,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.p1_monitor.*] +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.peco.*] check_untyped_defs = true disallow_incomplete_defs = true From 3086d332612824c1b79ee55722ec3a2875c55f51 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 10:41:25 +0100 Subject: [PATCH 0321/1544] Fix rainforest_raven typing (#107309) --- homeassistant/components/rainforest_raven/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 731b511fe90..d1f1aebb0f3 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -27,11 +27,11 @@ from .const import DOMAIN from .coordinator import RAVEnDataCoordinator -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RAVEnSensorEntityDescription(SensorEntityDescription): """A class that describes RAVEn sensor entities.""" - message_key: str | None = None + message_key: str attribute_keys: list[str] | None = None From a03ac3ddcd7a9ba080c898e3809df93840089979 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 6 Jan 2024 10:50:06 +0100 Subject: [PATCH 0322/1544] Fix passing correct location id to streamlabs water (#107291) --- homeassistant/components/streamlabswater/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 82e8777a7e1..c3bbe5a96d4 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -107,9 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def set_away_mode(service: ServiceCall) -> None: """Set the StreamLabsWater Away Mode.""" away_mode = service.data.get(ATTR_AWAY_MODE) - location_id = ( - service.data.get(CONF_LOCATION_ID) or list(coordinator.data.values())[0] - ) + location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0] client.update_location(location_id, away_mode) hass.services.async_register( From 9b1a8a1129daabe3c96b1437af1c5dc194ab208e Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 6 Jan 2024 11:18:32 +0100 Subject: [PATCH 0323/1544] enigma2: fix exception when device in deep sleep, fix previous track (#107296) enigma2: fix exception when device in deep sleep; previous track --- homeassistant/components/enigma2/media_player.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 598ab1afffe..aa1b5270557 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,6 +1,7 @@ """Support for Enigma2 media players.""" from __future__ import annotations +from aiohttp.client_exceptions import ClientConnectorError from openwebif.api import OpenWebIfDevice from openwebif.enums import RemoteControlCodes, SetVolumeOption import voluptuous as vol @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -96,9 +98,13 @@ async def async_setup_platform( source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) - async_add_entities( - [Enigma2Device(config[CONF_NAME], device, await device.get_about())] - ) + try: + about = await device.get_about() + except ClientConnectorError as err: + await device.close() + raise PlatformNotReady from err + + async_add_entities([Enigma2Device(config[CONF_NAME], device, about)]) class Enigma2Device(MediaPlayerEntity): @@ -165,8 +171,8 @@ class Enigma2Device(MediaPlayerEntity): await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP) async def async_media_previous_track(self) -> None: - """Send next track command.""" - self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN) + """Send previous track command.""" + await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN) async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute.""" From 65985c4e0bb88ac64c1441c4b0fb5c03d21bd293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Jan 2024 00:30:20 -1000 Subject: [PATCH 0324/1544] Fix name of 64bit intel/amd arch in builder and wheels workflow (#107335) --- .github/workflows/builder.yml | 2 +- .github/workflows/wheels.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9d1ab9e8a49..8bfebbee85e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -180,7 +180,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Adjustments for 64-bit - if: matrix.arch == 'x86_64' || matrix.arch == 'aarch64' + if: matrix.arch == 'amd64' || matrix.arch == 'aarch64' run: | # Some speedups are only available on 64-bit, and since # we build 32bit images on 64bit hosts, we only enable diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ded7e5d9adc..c9b1a76cc37 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -162,7 +162,7 @@ jobs: fi # Some speedups are only for 64-bit - if [ "${{ matrix.arch }}" = "x86_64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then + if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file} fi From d6aaaf1f1add7d84454c3c5b10824e0bd8c29cac Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 6 Jan 2024 11:46:12 +0100 Subject: [PATCH 0325/1544] Only mock config_entries.HANDLERS for the current test in mock_config_flow (#107357) --- tests/common.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/common.py b/tests/common.py index 85193022e4f..c6a0660be73 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1316,12 +1316,8 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, @contextmanager def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: """Mock a config flow handler.""" - handler = config_entries.HANDLERS.get(domain) - config_entries.HANDLERS[domain] = config_flow - _LOGGER.info("Adding mock config flow: %s", domain) - yield - if handler: - config_entries.HANDLERS[domain] = handler + with patch.dict(config_entries.HANDLERS, {domain: config_flow}): + yield def mock_integration( From 7385da626e0c5fd7996e99d2bd9acd6616cc7eee Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 6 Jan 2024 16:02:07 +0100 Subject: [PATCH 0326/1544] Add new locks automatically to tedee integration (#107372) * remove removed locks * move duplicated code to function * remove entities by removing device * add new locks automatically * add locks from coordinator * remove other PR stuff * add pullspring lock to test for coverage * requested changes --- .../components/tedee/binary_sensor.py | 11 +++++++ homeassistant/components/tedee/coordinator.py | 11 +++++++ homeassistant/components/tedee/lock.py | 9 ++++++ homeassistant/components/tedee/sensor.py | 11 +++++++ .../tedee/snapshots/test_binary_sensor.ambr | 2 +- .../components/tedee/snapshots/test_init.ambr | 2 +- tests/components/tedee/test_binary_sensor.py | 27 +++++++++++++++++ tests/components/tedee/test_lock.py | 29 +++++++++++++++++++ tests/components/tedee/test_sensor.py | 27 +++++++++++++++++ 9 files changed, 127 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 9bb2cd0410c..7efa25fa245 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -66,6 +66,17 @@ async def async_setup_entry( ] ) + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + async_add_entities( + [ + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES + ] + ) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): """Tedee sensor entity.""" diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 2b4f3c6d26b..6b4ecdae026 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -50,6 +50,8 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): ) self._next_get_locks = time.time() + self._current_locks: set[int] = set() + self.new_lock_callbacks: list[Callable[[int], None]] = [] @property def bridge(self) -> TedeeBridge: @@ -82,6 +84,15 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): ", ".join(map(str, self.tedee_client.locks_dict.keys())), ) + if not self._current_locks: + self._current_locks = set(self.tedee_client.locks_dict.keys()) + + if new_locks := set(self.tedee_client.locks_dict.keys()) - self._current_locks: + _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) + for lock_id in new_locks: + for callback in self.new_lock_callbacks: + callback(lock_id) + return self.tedee_client.locks_dict async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None: diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 751dfb446b7..1025942d787 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -29,6 +29,15 @@ async def async_setup_entry( else: entities.append(TedeeLockEntity(lock, coordinator)) + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + if lock.is_enabled_pullspring: + async_add_entities([TedeeLockWithLatchEntity(lock, coordinator)]) + else: + async_add_entities([TedeeLockEntity(lock, coordinator)]) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + async_add_entities(entities) diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 9eb61e624c7..9880f73746d 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -62,6 +62,17 @@ async def async_setup_entry( ] ) + def _async_add_new_lock(lock_id: int) -> None: + lock = coordinator.data[lock_id] + async_add_entities( + [ + TedeeSensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES + ] + ) + + coordinator.new_lock_callbacks.append(_async_add_new_lock) + class TedeeSensorEntity(TedeeDescriptionEntity, SensorEntity): """Tedee sensor entity.""" diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index 16be8aafd0e..a632ea3d57b 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -128,4 +128,4 @@ 'last_updated': , 'state': 'off', }) -# --- +# --- \ No newline at end of file diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index e10a9f298bb..2a89b1fe7ef 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -26,4 +26,4 @@ 'sw_version': None, 'via_device_id': None, }) -# --- +# --- \ No newline at end of file diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index bdb66c9c0a9..ee8c318d2dd 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -1,13 +1,18 @@ """Tests for the Tedee Binary Sensors.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from tests.common import async_fire_time_changed + pytestmark = pytest.mark.usefixtures("init_integration") BINARY_SENSORS = ( @@ -32,3 +37,25 @@ async def test_binary_sensors( entry = entity_registry.async_get(state.entity_id) assert entry assert entry == snapshot(name=f"entry-{key}") + + +async def test_new_binary_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure binary sensors for new lock are added automatically.""" + + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_4e5f_{key}") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for key in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.lock_4e5f_{key}") + assert state diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 995d036fba7..95a57078f56 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock from pytedee_async.exception import ( TedeeClientException, TedeeDataUpdateException, @@ -207,3 +208,31 @@ async def test_update_failed( state = hass.states.get("lock.lock_1a2b") assert state is not None assert state.state == STATE_UNAVAILABLE + + +async def test_new_lock( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure new lock is added automatically.""" + + state = hass.states.get("lock.lock_4e5f") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + mock_tedee.locks_dict[777777] = TedeeLock( + "Lock-6G7H", + 777777, + 4, + is_enabled_pullspring=True, + ) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("lock.lock_4e5f") + assert state + state = hass.states.get("lock.lock_6g7h") + assert state diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index 95cde20a82f..274048082c0 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -1,14 +1,19 @@ """Tests for the Tedee Sensors.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from tests.common import async_fire_time_changed + pytestmark = pytest.mark.usefixtures("init_integration") @@ -34,3 +39,25 @@ async def test_sensors( assert entry assert entry.device_id assert entry == snapshot(name=f"entry-{key}") + + +async def test_new_sensors( + hass: HomeAssistant, + mock_tedee: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure sensors for new lock are added automatically.""" + + for key in SENSORS: + state = hass.states.get(f"sensor.lock_4e5f_{key}") + assert state is None + + mock_tedee.locks_dict[666666] = TedeeLock("Lock-4E5F", 666666, 2) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for key in SENSORS: + state = hass.states.get(f"sensor.lock_4e5f_{key}") + assert state From 94885b9868fe75b924a541b4c313fc24b09ddacb Mon Sep 17 00:00:00 2001 From: Marc-Olivier Arsenault Date: Sat, 6 Jan 2024 10:29:50 -0500 Subject: [PATCH 0327/1544] remove marcolivierarsenault from ecobee codeowners (#107377) --- CODEOWNERS | 2 -- homeassistant/components/ecobee/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0a38fce05f6..dabd49b34e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -317,8 +317,6 @@ build.json @home-assistant/supervisor /tests/components/eafm/ @Jc2k /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas -/homeassistant/components/ecobee/ @marcolivierarsenault -/tests/components/ecobee/ @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 1160cd946d9..f3f5b59a36f 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,7 +1,7 @@ { "domain": "ecobee", "name": "ecobee", - "codeowners": ["@marcolivierarsenault"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { From 4af47a4815b65dca782290e2361c84e62176ab28 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sat, 6 Jan 2024 11:16:24 -0500 Subject: [PATCH 0328/1544] Add diagnostics to A. O. Smith integration (#106343) * Add diagnostics to A. O. Smith integration * Bump py-aosmith to 1.0.4 * remove redactions from test fixture --- .../components/aosmith/diagnostics.py | 39 +++ .../components/aosmith/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aosmith/conftest.py | 6 + .../aosmith/fixtures/get_all_device_info.json | 247 +++++++++++++++++ .../aosmith/snapshots/test_diagnostics.ambr | 252 ++++++++++++++++++ tests/components/aosmith/test_diagnostics.py | 23 ++ 8 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/aosmith/diagnostics.py create mode 100644 tests/components/aosmith/fixtures/get_all_device_info.json create mode 100644 tests/components/aosmith/snapshots/test_diagnostics.ambr create mode 100644 tests/components/aosmith/test_diagnostics.py diff --git a/homeassistant/components/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py new file mode 100644 index 00000000000..a821c980faa --- /dev/null +++ b/homeassistant/components/aosmith/diagnostics.py @@ -0,0 +1,39 @@ +"""Diagnostics support for A. O. Smith.""" +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 . import AOSmithData +from .const import DOMAIN + +TO_REDACT = { + "address", + "city", + "contactId", + "dsn", + "email", + "firstName", + "heaterSsid", + "id", + "lastName", + "phone", + "postalCode", + "registeredOwner", + "serial", + "ssid", + "state", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: AOSmithData = hass.data[DOMAIN][config_entry.entry_id] + + all_device_info = await data.client.get_all_device_info() + return async_redact_data(all_device_info, TO_REDACT) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 895b03cf7fd..7651086e138 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.1"] + "requirements": ["py-aosmith==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6889e6fbf28..cb331e8d9e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1554,7 +1554,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.1 +py-aosmith==1.0.4 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b625bd390e8..1929a2ceca4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1201,7 +1201,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.1 +py-aosmith==1.0.4 # homeassistant.components.canary py-canary==0.5.3 diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 61c1fc9a562..f2c3ffc9c3c 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -54,10 +54,16 @@ async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, No get_energy_use_fixture = load_json_object_fixture( "get_energy_use_data.json", DOMAIN ) + get_all_device_info_fixture = load_json_object_fixture( + "get_all_device_info.json", DOMAIN + ) client_mock = MagicMock(AOSmithAPIClient) client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) + client_mock.get_all_device_info = AsyncMock( + return_value=get_all_device_info_fixture + ) return client_mock diff --git a/tests/components/aosmith/fixtures/get_all_device_info.json b/tests/components/aosmith/fixtures/get_all_device_info.json new file mode 100644 index 00000000000..4d19a80a3ad --- /dev/null +++ b/tests/components/aosmith/fixtures/get_all_device_info.json @@ -0,0 +1,247 @@ +{ + "devices": [ + { + "alertSettings": { + "faultCode": { + "major": { + "email": true, + "sms": false + }, + "minor": { + "email": false, + "sms": false + } + }, + "operatingSetPoint": { + "email": false, + "sms": false + }, + "tankTemperature": { + "highTemperature": { + "email": false, + "sms": false, + "value": 160 + }, + "lowTemperature": { + "email": false, + "sms": false, + "value": 120 + } + } + }, + "brand": "aosmith", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "hardware": { + "hasBluetooth": true, + "interface": "CONTROL_PANEL" + }, + "id": "id", + "install": { + "address": "sample_address", + "city": "sample_city", + "country": "United States", + "date": "2023-09-29", + "email": "sample_email", + "group": "Residential", + "location": "Basement", + "phone": "sample_phone", + "postalCode": "sample_postal_code", + "professional": false, + "registeredOwner": "sample_owner", + "registrationDate": "2023-12-24", + "state": "sample_state" + }, + "isRegistered": true, + "junctionId": "junctionId", + "lastUpdate": 1703386473737, + "model": "HPTS-50 200 202172000", + "name": "Water Heater", + "permissions": "USER", + "productId": "100350404", + "serial": "sample_serial", + "users": [ + { + "contactId": "sample_contact_id", + "email": "sample_email", + "firstName": "sample_first_name", + "isSelf": true, + "lastName": "sample_last_name", + "permissions": "USER" + } + ], + "data": { + "activeAlerts": [], + "alertHistory": [], + "isOnline": true, + "isWifi": true, + "lastUpdate": 1703138389000, + "signalStrength": null, + "heaterSsid": "sample_heater_ssid", + "ssid": "sample_ssid", + "temperatureSetpoint": 145, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 145, + "temperatureSetpointMaximum": 145, + "error": "", + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "firmwareVersion": "2.14", + "hotWaterStatus": "HIGH", + "isAdvancedLoadUpMore": false, + "isCtaUcmPresent": false, + "isDemandResponsePaused": false, + "isEnrolled": false, + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 100, + "isLowes": false, + "canEditTimeOfUse": false, + "timeOfUseData": null, + "consumerScheduleData": null + } + } + ], + "energy_use_data": { + "junctionId": { + "average": 2.4744000000000006, + "graphData": [ + { + "date": "2023-11-26T04:00:00.000Z", + "kwh": 0.936 + }, + { + "date": "2023-11-27T04:00:00.000Z", + "kwh": 4.248 + }, + { + "date": "2023-11-28T04:00:00.000Z", + "kwh": 1.002 + }, + { + "date": "2023-11-29T04:00:00.000Z", + "kwh": 3.078 + }, + { + "date": "2023-11-30T04:00:00.000Z", + "kwh": 1.896 + }, + { + "date": "2023-12-01T04:00:00.000Z", + "kwh": 1.98 + }, + { + "date": "2023-12-02T04:00:00.000Z", + "kwh": 2.112 + }, + { + "date": "2023-12-03T04:00:00.000Z", + "kwh": 3.222 + }, + { + "date": "2023-12-04T04:00:00.000Z", + "kwh": 4.254 + }, + { + "date": "2023-12-05T04:00:00.000Z", + "kwh": 4.05 + }, + { + "date": "2023-12-06T04:00:00.000Z", + "kwh": 3.312 + }, + { + "date": "2023-12-07T04:00:00.000Z", + "kwh": 2.334 + }, + { + "date": "2023-12-08T04:00:00.000Z", + "kwh": 2.418 + }, + { + "date": "2023-12-09T04:00:00.000Z", + "kwh": 2.19 + }, + { + "date": "2023-12-10T04:00:00.000Z", + "kwh": 3.786 + }, + { + "date": "2023-12-11T04:00:00.000Z", + "kwh": 5.292 + }, + { + "date": "2023-12-12T04:00:00.000Z", + "kwh": 1.38 + }, + { + "date": "2023-12-13T04:00:00.000Z", + "kwh": 3.324 + }, + { + "date": "2023-12-14T04:00:00.000Z", + "kwh": 1.092 + }, + { + "date": "2023-12-15T04:00:00.000Z", + "kwh": 0.606 + }, + { + "date": "2023-12-16T04:00:00.000Z", + "kwh": 0 + }, + { + "date": "2023-12-17T04:00:00.000Z", + "kwh": 2.838 + }, + { + "date": "2023-12-18T04:00:00.000Z", + "kwh": 2.382 + }, + { + "date": "2023-12-19T04:00:00.000Z", + "kwh": 2.904 + }, + { + "date": "2023-12-20T04:00:00.000Z", + "kwh": 1.914 + }, + { + "date": "2023-12-21T04:00:00.000Z", + "kwh": 3.93 + }, + { + "date": "2023-12-22T04:00:00.000Z", + "kwh": 3.666 + }, + { + "date": "2023-12-23T04:00:00.000Z", + "kwh": 2.766 + }, + { + "date": "2023-12-24T04:00:00.000Z", + "kwh": 1.32 + } + ], + "lifetimeKwh": 203.259, + "startDate": "Nov 26" + } + } +} diff --git a/tests/components/aosmith/snapshots/test_diagnostics.ambr b/tests/components/aosmith/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8704cdaa214 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_diagnostics.ambr @@ -0,0 +1,252 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': list([ + dict({ + 'alertSettings': dict({ + 'faultCode': dict({ + 'major': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + 'minor': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + }), + 'operatingSetPoint': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + 'tankTemperature': dict({ + 'highTemperature': dict({ + 'email': '**REDACTED**', + 'sms': False, + 'value': 160, + }), + 'lowTemperature': dict({ + 'email': '**REDACTED**', + 'sms': False, + 'value': 120, + }), + }), + }), + 'brand': 'aosmith', + 'data': dict({ + 'activeAlerts': list([ + ]), + 'alertHistory': list([ + ]), + 'canEditTimeOfUse': False, + 'consumerScheduleData': None, + 'electricModeRemainingDays': 100, + 'error': '', + 'firmwareVersion': '2.14', + 'heaterSsid': '**REDACTED**', + 'hotWaterStatus': 'HIGH', + 'isAdvancedLoadUpMore': False, + 'isCtaUcmPresent': False, + 'isDemandResponsePaused': False, + 'isEnrolled': False, + 'isLowes': False, + 'isOnline': True, + 'isWifi': True, + 'lastUpdate': 1703138389000, + 'mode': 'HEAT_PUMP', + 'modePending': False, + 'modes': list([ + dict({ + 'controls': None, + 'mode': 'HYBRID', + }), + dict({ + 'controls': None, + 'mode': 'HEAT_PUMP', + }), + dict({ + 'controls': 'SELECT_DAYS', + 'mode': 'ELECTRIC', + }), + dict({ + 'controls': 'SELECT_DAYS', + 'mode': 'VACATION', + }), + ]), + 'signalStrength': None, + 'ssid': '**REDACTED**', + 'temperatureSetpoint': 145, + 'temperatureSetpointMaximum': 145, + 'temperatureSetpointPending': False, + 'temperatureSetpointPrevious': 145, + 'timeOfUseData': None, + 'vacationModeRemainingDays': 0, + }), + 'deviceType': 'NEXT_GEN_HEAT_PUMP', + 'dsn': '**REDACTED**', + 'hardware': dict({ + 'hasBluetooth': True, + 'interface': 'CONTROL_PANEL', + }), + 'id': '**REDACTED**', + 'install': dict({ + 'address': '**REDACTED**', + 'city': '**REDACTED**', + 'country': 'United States', + 'date': '2023-09-29', + 'email': '**REDACTED**', + 'group': 'Residential', + 'location': 'Basement', + 'phone': '**REDACTED**', + 'postalCode': '**REDACTED**', + 'professional': False, + 'registeredOwner': '**REDACTED**', + 'registrationDate': '2023-12-24', + 'state': '**REDACTED**', + }), + 'isRegistered': True, + 'junctionId': 'junctionId', + 'lastUpdate': 1703386473737, + 'model': 'HPTS-50 200 202172000', + 'name': 'Water Heater', + 'permissions': 'USER', + 'productId': '100350404', + 'serial': '**REDACTED**', + 'users': list([ + dict({ + 'contactId': '**REDACTED**', + 'email': '**REDACTED**', + 'firstName': '**REDACTED**', + 'isSelf': True, + 'lastName': '**REDACTED**', + 'permissions': 'USER', + }), + ]), + }), + ]), + 'energy_use_data': dict({ + 'junctionId': dict({ + 'average': 2.4744000000000006, + 'graphData': list([ + dict({ + 'date': '2023-11-26T04:00:00.000Z', + 'kwh': 0.936, + }), + dict({ + 'date': '2023-11-27T04:00:00.000Z', + 'kwh': 4.248, + }), + dict({ + 'date': '2023-11-28T04:00:00.000Z', + 'kwh': 1.002, + }), + dict({ + 'date': '2023-11-29T04:00:00.000Z', + 'kwh': 3.078, + }), + dict({ + 'date': '2023-11-30T04:00:00.000Z', + 'kwh': 1.896, + }), + dict({ + 'date': '2023-12-01T04:00:00.000Z', + 'kwh': 1.98, + }), + dict({ + 'date': '2023-12-02T04:00:00.000Z', + 'kwh': 2.112, + }), + dict({ + 'date': '2023-12-03T04:00:00.000Z', + 'kwh': 3.222, + }), + dict({ + 'date': '2023-12-04T04:00:00.000Z', + 'kwh': 4.254, + }), + dict({ + 'date': '2023-12-05T04:00:00.000Z', + 'kwh': 4.05, + }), + dict({ + 'date': '2023-12-06T04:00:00.000Z', + 'kwh': 3.312, + }), + dict({ + 'date': '2023-12-07T04:00:00.000Z', + 'kwh': 2.334, + }), + dict({ + 'date': '2023-12-08T04:00:00.000Z', + 'kwh': 2.418, + }), + dict({ + 'date': '2023-12-09T04:00:00.000Z', + 'kwh': 2.19, + }), + dict({ + 'date': '2023-12-10T04:00:00.000Z', + 'kwh': 3.786, + }), + dict({ + 'date': '2023-12-11T04:00:00.000Z', + 'kwh': 5.292, + }), + dict({ + 'date': '2023-12-12T04:00:00.000Z', + 'kwh': 1.38, + }), + dict({ + 'date': '2023-12-13T04:00:00.000Z', + 'kwh': 3.324, + }), + dict({ + 'date': '2023-12-14T04:00:00.000Z', + 'kwh': 1.092, + }), + dict({ + 'date': '2023-12-15T04:00:00.000Z', + 'kwh': 0.606, + }), + dict({ + 'date': '2023-12-16T04:00:00.000Z', + 'kwh': 0, + }), + dict({ + 'date': '2023-12-17T04:00:00.000Z', + 'kwh': 2.838, + }), + dict({ + 'date': '2023-12-18T04:00:00.000Z', + 'kwh': 2.382, + }), + dict({ + 'date': '2023-12-19T04:00:00.000Z', + 'kwh': 2.904, + }), + dict({ + 'date': '2023-12-20T04:00:00.000Z', + 'kwh': 1.914, + }), + dict({ + 'date': '2023-12-21T04:00:00.000Z', + 'kwh': 3.93, + }), + dict({ + 'date': '2023-12-22T04:00:00.000Z', + 'kwh': 3.666, + }), + dict({ + 'date': '2023-12-23T04:00:00.000Z', + 'kwh': 2.766, + }), + dict({ + 'date': '2023-12-24T04:00:00.000Z', + 'kwh': 1.32, + }), + ]), + 'lifetimeKwh': 203.259, + 'startDate': 'Nov 26', + }), + }), + }) +# --- diff --git a/tests/components/aosmith/test_diagnostics.py b/tests/components/aosmith/test_diagnostics.py new file mode 100644 index 00000000000..9090ef5e7b7 --- /dev/null +++ b/tests/components/aosmith/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the A. O. Smith integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 427f7a786681903e921cd862c237f50443649559 Mon Sep 17 00:00:00 2001 From: Ben Morton Date: Sat, 6 Jan 2024 16:22:46 +0000 Subject: [PATCH 0329/1544] Add support for the Spotify DJ (#107268) * Add support for the Spotify DJ playlist by mocking the playlist response Add error handling for playlist lookup to ensure it doesn't break current playback state loading * Run linters Add exception type to playlist lookup error handling * Fix typo in comment Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/spotify/media_player.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 6ef2697ba77..0204cc30fbb 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -61,6 +61,10 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } +# This is a minimal representation of the DJ playlist that Spotify now offers +# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality +SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} + async def async_setup_entry( hass: HomeAssistant, @@ -423,7 +427,19 @@ class SpotifyMediaPlayer(MediaPlayerEntity): if context and (self._playlist is None or self._playlist["uri"] != uri): self._playlist = None if context["type"] == MediaType.PLAYLIST: - self._playlist = self.data.client.playlist(uri) + # The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object + if uri == SPOTIFY_DJ_PLAYLIST["uri"]: + self._playlist = SPOTIFY_DJ_PLAYLIST + else: + # Make sure any playlist lookups don't break the current playback state update + try: + self._playlist = self.data.client.playlist(uri) + except SpotifyException: + _LOGGER.debug( + "Unable to load spotify playlist '%s'. Continuing without playlist data", + uri, + ) + self._playlist = None device = self._currently_playing.get("device") if device is not None: From 4ea8c174f529e54ecb484fbfb7882b9fccc9d8ce Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 6 Jan 2024 22:02:30 +0100 Subject: [PATCH 0330/1544] Improve homekit_controller typing (#107381) --- homeassistant/components/homekit_controller/connection.py | 8 +++++--- .../components/homekit_controller/device_trigger.py | 8 +++++--- homeassistant/components/homekit_controller/entity.py | 4 ++-- homeassistant/components/homekit_controller/event.py | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index ef806cb52bc..4a5a4953c4b 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -641,7 +641,9 @@ class HKDevice: await self.async_add_new_entities() @callback - def async_entity_key_removed(self, entity_key: tuple[int, int | None, int | None]): + def async_entity_key_removed( + self, entity_key: tuple[int, int | None, int | None] + ) -> None: """Handle an entity being removed. Releases the entity from self.entities so it can be added again. @@ -666,7 +668,7 @@ class HKDevice: self.char_factories.append(add_entities_cb) self._add_new_entities_for_char([add_entities_cb]) - def _add_new_entities_for_char(self, handlers) -> None: + def _add_new_entities_for_char(self, handlers: list[AddCharacteristicCb]) -> None: for accessory in self.entity_map.accessories: for service in accessory.services: for char in service.characteristics: @@ -768,7 +770,7 @@ class HKDevice: """Request an debounced update from the accessory.""" await self._debounced_update.async_call() - async def async_update(self, now=None): + async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" if not self.pollable_characteristics: self.async_update_available_state() diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index fa4c1c171c2..6dc97bf6821 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -115,7 +115,7 @@ class TriggerSource: trigger_callbacks.append(event_handler) - def async_remove_handler(): + def async_remove_handler() -> None: trigger_callbacks.remove(event_handler) return async_remove_handler @@ -215,7 +215,7 @@ async def async_setup_triggers_for_entry( conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback - def async_add_characteristic(service: Service): + def async_add_characteristic(service: Service) -> bool: aid = service.accessory.aid service_type = service.type @@ -257,7 +257,9 @@ def async_get_or_create_trigger_source( return source -def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], dict[str, Any]]): +def async_fire_triggers( + conn: HKDevice, events: dict[tuple[int, int], dict[str, Any]] +) -> None: """Process events generated by a HomeKit accessory into automation triggers.""" trigger_sources: dict[str, TriggerSource] = conn.hass.data.get(TRIGGERS, {}) if not trigger_sources: diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index ba0cad8d666..496866299d6 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -109,7 +109,7 @@ class HomeKitEntity(Entity): self._accessory.async_entity_key_removed(self._entity_key) @callback - def _async_unsubscribe_chars(self): + def _async_unsubscribe_chars(self) -> None: """Handle unsubscribing from characteristics.""" if self._char_subscription: self._char_subscription() @@ -118,7 +118,7 @@ class HomeKitEntity(Entity): self._accessory.remove_watchable_characteristics(self.watchable_characteristics) @callback - def _async_subscribe_chars(self): + def _async_subscribe_chars(self) -> None: """Handle registering characteristics to watch and subscribe.""" self._accessory.add_pollable_characteristics(self.pollable_characteristics) self._accessory.add_watchable_characteristics(self.watchable_characteristics) diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index 86046415e35..8f3d71682f1 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -72,7 +72,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): ) @callback - def _handle_event(self): + def _handle_event(self) -> None: if self._char.value is None: # For IP backed devices the characteristic is marked as # pollable, but always returns None when polled From 3f2170bd0677f4e9a64f0548efa501cf1edf6c3d Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Sat, 6 Jan 2024 17:01:11 -0500 Subject: [PATCH 0331/1544] Bump py-aosmith to 1.0.6 (#107409) --- homeassistant/components/aosmith/__init__.py | 12 +- homeassistant/components/aosmith/const.py | 11 -- .../components/aosmith/coordinator.py | 17 +-- homeassistant/components/aosmith/entity.py | 19 +-- .../components/aosmith/manifest.json | 2 +- homeassistant/components/aosmith/sensor.py | 15 ++- .../components/aosmith/water_heater.py | 60 ++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aosmith/conftest.py | 127 ++++++++++++++++-- .../aosmith/fixtures/get_devices.json | 46 ------- .../fixtures/get_devices_mode_pending.json | 46 ------- .../get_devices_no_vacation_mode.json | 42 ------ .../get_devices_setpoint_pending.json | 46 ------- .../aosmith/fixtures/get_energy_use_data.json | 19 --- tests/components/aosmith/test_init.py | 25 ++-- tests/components/aosmith/test_water_heater.py | 21 ++- 17 files changed, 197 insertions(+), 315 deletions(-) delete mode 100644 tests/components/aosmith/fixtures/get_devices.json delete mode 100644 tests/components/aosmith/fixtures/get_devices_mode_pending.json delete mode 100644 tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json delete mode 100644 tests/components/aosmith/fixtures/get_devices_setpoint_pending.json delete mode 100644 tests/components/aosmith/fixtures/get_energy_use_data.json diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index b75a4ad7295..4da390685ab 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -37,16 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await status_coordinator.async_config_entry_first_refresh() device_registry = dr.async_get(hass) - for junction_id, status_data in status_coordinator.data.items(): + for junction_id, aosmith_device in status_coordinator.data.items(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, junction_id)}, manufacturer="A. O. Smith", - name=status_data.get("name"), - model=status_data.get("model"), - serial_number=status_data.get("serial"), - suggested_area=status_data.get("install", {}).get("location"), - sw_version=status_data.get("data", {}).get("firmwareVersion"), + name=aosmith_device.name, + model=aosmith_device.model, + serial_number=aosmith_device.serial, + suggested_area=aosmith_device.install_location, + sw_version=aosmith_device.status.firmware_version, ) energy_coordinator = AOSmithEnergyCoordinator( diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py index c0c693e0dac..ba9980293dc 100644 --- a/homeassistant/components/aosmith/const.py +++ b/homeassistant/components/aosmith/const.py @@ -4,11 +4,6 @@ from datetime import timedelta DOMAIN = "aosmith" -AOSMITH_MODE_ELECTRIC = "ELECTRIC" -AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" -AOSMITH_MODE_HYBRID = "HYBRID" -AOSMITH_MODE_VACATION = "VACATION" - # Update interval to be used for normal background updates. REGULAR_INTERVAL = timedelta(seconds=30) @@ -17,9 +12,3 @@ FAST_INTERVAL = timedelta(seconds=1) # Update interval to be used for energy usage data. ENERGY_USAGE_INTERVAL = timedelta(minutes=10) - -HOT_WATER_STATUS_MAP = { - "LOW": "low", - "MEDIUM": "medium", - "HIGH": "high", -} diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 7d6053cc86e..a0dd703b800 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,12 +1,12 @@ """The data update coordinator for the A. O. Smith integration.""" import logging -from typing import Any from py_aosmith import ( AOSmithAPIClient, AOSmithInvalidCredentialsException, AOSmithUnknownException, ) +from py_aosmith.models import Device as AOSmithDevice from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -17,7 +17,7 @@ from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVA _LOGGER = logging.getLogger(__name__) -class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): +class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): """Coordinator for device status, updating with a frequent interval.""" def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: @@ -25,7 +25,7 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) self.client = client - async def _async_update_data(self) -> dict[str, dict[str, Any]]: + async def _async_update_data(self) -> dict[str, AOSmithDevice]: """Fetch latest data from the device status endpoint.""" try: devices = await self.client.get_devices() @@ -34,12 +34,9 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) except AOSmithUnknownException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - mode_pending = any( - device.get("data", {}).get("modePending") for device in devices - ) + mode_pending = any(device.status.mode_change_pending for device in devices) setpoint_pending = any( - device.get("data", {}).get("temperatureSetpointPending") - for device in devices + device.status.temperature_setpoint_pending for device in devices ) if mode_pending or setpoint_pending: @@ -47,7 +44,7 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]) else: self.update_interval = REGULAR_INTERVAL - return {device.get("junctionId"): device for device in devices} + return {device.junction_id: device for device in devices} class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): @@ -78,6 +75,6 @@ class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): except AOSmithUnknownException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - energy_usage_by_junction_id[junction_id] = energy_usage.get("lifetimeKwh") + energy_usage_by_junction_id[junction_id] = energy_usage.lifetime_kwh return energy_usage_by_junction_id diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py index 107e5d7e944..7407fbac3cb 100644 --- a/homeassistant/components/aosmith/entity.py +++ b/homeassistant/components/aosmith/entity.py @@ -2,6 +2,7 @@ from typing import TypeVar from py_aosmith import AOSmithAPIClient +from py_aosmith.models import Device as AOSmithDevice from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,26 +38,20 @@ class AOSmithStatusEntity(AOSmithEntity[AOSmithStatusCoordinator]): """Base entity for entities that use data from the status coordinator.""" @property - def device(self): - """Shortcut to get the device status from the coordinator data.""" - return self.coordinator.data.get(self.junction_id) - - @property - def device_data(self): - """Shortcut to get the device data within the device status.""" - device = self.device - return None if device is None else device.get("data", {}) + def device(self) -> AOSmithDevice: + """Shortcut to get the device from the coordinator data.""" + return self.coordinator.data[self.junction_id] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.device_data.get("isOnline") is True + return super().available and self.device.status.is_online class AOSmithEnergyEntity(AOSmithEntity[AOSmithEnergyCoordinator]): """Base entity for entities that use data from the energy coordinator.""" @property - def energy_usage(self) -> float | None: + def energy_usage(self) -> float: """Shortcut to get the energy usage from the coordinator data.""" - return self.coordinator.data.get(self.junction_id) + return self.coordinator.data[self.junction_id] diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 7651086e138..436918ae772 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.4"] + "requirements": ["py-aosmith==1.0.6"] } diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index b0606d2dca4..e4a99a340de 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -2,7 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any + +from py_aosmith.models import Device as AOSmithDevice, HotWaterStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AOSmithData -from .const import DOMAIN, HOT_WATER_STATUS_MAP +from .const import DOMAIN from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator from .entity import AOSmithEnergyEntity, AOSmithStatusEntity @@ -25,7 +26,7 @@ from .entity import AOSmithEnergyEntity, AOSmithStatusEntity class AOSmithStatusSensorEntityDescription(SensorEntityDescription): """Entity description class for sensors using data from the status coordinator.""" - value_fn: Callable[[dict[str, Any]], str | int | None] + value_fn: Callable[[AOSmithDevice], str | int | None] STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( @@ -36,11 +37,17 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENUM, options=["low", "medium", "high"], value_fn=lambda device: HOT_WATER_STATUS_MAP.get( - device.get("data", {}).get("hotWaterStatus") + device.status.hot_water_status ), ), ) +HOT_WATER_STATUS_MAP: dict[HotWaterStatus, str] = { + HotWaterStatus.LOW: "low", + HotWaterStatus.MEDIUM: "medium", + HotWaterStatus.HIGH: "high", +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index 8c42048d439..9522d06e062 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -2,6 +2,8 @@ from typing import Any +from py_aosmith.models import OperationMode as AOSmithOperationMode + from homeassistant.components.water_heater import ( STATE_ECO, STATE_ELECTRIC, @@ -16,31 +18,25 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AOSmithData -from .const import ( - AOSMITH_MODE_ELECTRIC, - AOSMITH_MODE_HEAT_PUMP, - AOSMITH_MODE_HYBRID, - AOSMITH_MODE_VACATION, - DOMAIN, -) +from .const import DOMAIN from .coordinator import AOSmithStatusCoordinator from .entity import AOSmithStatusEntity MODE_HA_TO_AOSMITH = { - STATE_OFF: AOSMITH_MODE_VACATION, - STATE_ECO: AOSMITH_MODE_HYBRID, - STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, - STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, + STATE_ECO: AOSmithOperationMode.HYBRID, + STATE_ELECTRIC: AOSmithOperationMode.ELECTRIC, + STATE_HEAT_PUMP: AOSmithOperationMode.HEAT_PUMP, + STATE_OFF: AOSmithOperationMode.VACATION, } MODE_AOSMITH_TO_HA = { - AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, - AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, - AOSMITH_MODE_HYBRID: STATE_ECO, - AOSMITH_MODE_VACATION: STATE_OFF, + AOSmithOperationMode.ELECTRIC: STATE_ELECTRIC, + AOSmithOperationMode.HEAT_PUMP: STATE_HEAT_PUMP, + AOSmithOperationMode.HYBRID: STATE_ECO, + AOSmithOperationMode.VACATION: STATE_OFF, } # Operation mode to use when exiting away mode -DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID +DEFAULT_OPERATION_MODE = AOSmithOperationMode.HYBRID DEFAULT_SUPPORT_FLAGS = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE @@ -79,23 +75,22 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): @property def operation_list(self) -> list[str]: """Return the list of supported operation modes.""" - op_modes = [] - for mode_dict in self.device_data.get("modes", []): - mode_name = mode_dict.get("mode") - ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + ha_modes = [] + for supported_mode in self.device.supported_modes: + ha_mode = MODE_AOSMITH_TO_HA.get(supported_mode.mode) # Filtering out STATE_OFF since it is handled by away mode if ha_mode is not None and ha_mode != STATE_OFF: - op_modes.append(ha_mode) + ha_modes.append(ha_mode) - return op_modes + return ha_modes @property def supported_features(self) -> WaterHeaterEntityFeature: """Return the list of supported features.""" supports_vacation_mode = any( - mode_dict.get("mode") == AOSMITH_MODE_VACATION - for mode_dict in self.device_data.get("modes", []) + supported_mode.mode == AOSmithOperationMode.VACATION + for supported_mode in self.device.supported_modes ) if supports_vacation_mode: @@ -106,22 +101,22 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.device_data.get("temperatureSetpoint") + return self.device.status.temperature_setpoint @property def max_temp(self) -> float: """Return the maximum temperature.""" - return self.device_data.get("temperatureSetpointMaximum") + return self.device.status.temperature_setpoint_maximum @property def current_operation(self) -> str: """Return the current operation mode.""" - return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + return MODE_AOSMITH_TO_HA.get(self.device.status.current_mode, STATE_OFF) @property def is_away_mode_on(self): """Return True if away mode is on.""" - return self.device_data.get("mode") == AOSMITH_MODE_VACATION + return self.device.status.current_mode == AOSmithOperationMode.VACATION async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" @@ -129,18 +124,19 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): if aosmith_mode is not None: await self.client.update_mode(self.junction_id, aosmith_mode) - await self.coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get("temperature") - await self.client.update_setpoint(self.junction_id, temperature) + if temperature is not None: + await self.client.update_setpoint(self.junction_id, temperature) - await self.coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + await self.client.update_mode(self.junction_id, AOSmithOperationMode.VACATION) await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index cb331e8d9e0..1d9fb84075b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1554,7 +1554,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.4 +py-aosmith==1.0.6 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1929a2ceca4..14235bb399a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1201,7 +1201,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.4 +py-aosmith==1.0.6 # homeassistant.components.canary py-canary==0.5.3 diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index f2c3ffc9c3c..157b58cb902 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -3,6 +3,16 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from py_aosmith import AOSmithAPIClient +from py_aosmith.models import ( + Device, + DeviceStatus, + DeviceType, + EnergyUseData, + EnergyUseHistoryEntry, + HotWaterStatus, + OperationMode, + SupportedOperationModeInfo, +) import pytest from homeassistant.components.aosmith.const import DOMAIN @@ -10,11 +20,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, load_json_object_fixture FIXTURE_USER_INPUT = { CONF_EMAIL: "testemail@example.com", @@ -22,6 +28,80 @@ FIXTURE_USER_INPUT = { } +def build_device_fixture( + mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool +): + """Build a fixture for a device.""" + supported_modes: list[SupportedOperationModeInfo] = [ + SupportedOperationModeInfo( + mode=OperationMode.HYBRID, + original_name="HYBRID", + has_day_selection=False, + ), + SupportedOperationModeInfo( + mode=OperationMode.HEAT_PUMP, + original_name="HEAT_PUMP", + has_day_selection=False, + ), + SupportedOperationModeInfo( + mode=OperationMode.ELECTRIC, + original_name="ELECTRIC", + has_day_selection=True, + ), + ] + + if has_vacation_mode: + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.VACATION, + original_name="VACATION", + has_day_selection=True, + ) + ) + + return Device( + brand="aosmith", + model="HPTS-50 200 202172000", + device_type=DeviceType.NEXT_GEN_HEAT_PUMP, + dsn="dsn", + junction_id="junctionId", + name="My water heater", + serial="serial", + install_location="Basement", + supported_modes=supported_modes, + status=DeviceStatus( + firmware_version="2.14", + is_online=True, + current_mode=OperationMode.HEAT_PUMP, + mode_change_pending=mode_pending, + temperature_setpoint=130, + temperature_setpoint_pending=setpoint_pending, + temperature_setpoint_previous=130, + temperature_setpoint_maximum=130, + hot_water_status=HotWaterStatus.LOW, + ), + ) + + +ENERGY_USE_FIXTURE = EnergyUseData( + lifetime_kwh=132.825, + history=[ + EnergyUseHistoryEntry( + date="2023-10-30T04:00:00.000Z", + energy_use_kwh=2.01, + ), + EnergyUseHistoryEntry( + date="2023-10-31T04:00:00.000Z", + energy_use_kwh=1.542, + ), + EnergyUseHistoryEntry( + date="2023-11-01T04:00:00.000Z", + energy_use_kwh=1.908, + ), + ], +) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -42,25 +122,44 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def get_devices_fixture() -> str: - """Return the name of the fixture to use for get_devices.""" - return "get_devices" +def get_devices_fixture_mode_pending() -> bool: + """Return whether to set mode_pending in the get_devices fixture.""" + return False @pytest.fixture -async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: +def get_devices_fixture_setpoint_pending() -> bool: + """Return whether to set setpoint_pending in the get_devices fixture.""" + return False + + +@pytest.fixture +def get_devices_fixture_has_vacation_mode() -> bool: + """Return whether to include vacation mode in the get_devices fixture.""" + return True + + +@pytest.fixture +async def mock_client( + get_devices_fixture_mode_pending: bool, + get_devices_fixture_setpoint_pending: bool, + get_devices_fixture_has_vacation_mode: bool, +) -> Generator[MagicMock, None, None]: """Return a mocked client.""" - get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) - get_energy_use_fixture = load_json_object_fixture( - "get_energy_use_data.json", DOMAIN - ) + get_devices_fixture = [ + build_device_fixture( + get_devices_fixture_mode_pending, + get_devices_fixture_setpoint_pending, + get_devices_fixture_has_vacation_mode, + ) + ] get_all_device_info_fixture = load_json_object_fixture( "get_all_device_info.json", DOMAIN ) client_mock = MagicMock(AOSmithAPIClient) client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) - client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) + client_mock.get_energy_use_data = AsyncMock(return_value=ENERGY_USE_FIXTURE) client_mock.get_all_device_info = AsyncMock( return_value=get_all_device_info_fixture ) diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json deleted file mode 100644 index e34c50cd270..00000000000 --- a/tests/components/aosmith/fixtures/get_devices.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "brand": "aosmith", - "model": "HPTS-50 200 202172000", - "deviceType": "NEXT_GEN_HEAT_PUMP", - "dsn": "dsn", - "junctionId": "junctionId", - "name": "My water heater", - "serial": "serial", - "install": { - "location": "Basement" - }, - "data": { - "__typename": "NextGenHeatPump", - "temperatureSetpoint": 130, - "temperatureSetpointPending": false, - "temperatureSetpointPrevious": 130, - "temperatureSetpointMaximum": 130, - "modes": [ - { - "mode": "HYBRID", - "controls": null - }, - { - "mode": "HEAT_PUMP", - "controls": null - }, - { - "mode": "ELECTRIC", - "controls": "SELECT_DAYS" - }, - { - "mode": "VACATION", - "controls": "SELECT_DAYS" - } - ], - "isOnline": true, - "firmwareVersion": "2.14", - "hotWaterStatus": "LOW", - "mode": "HEAT_PUMP", - "modePending": false, - "vacationModeRemainingDays": 0, - "electricModeRemainingDays": 0 - } - } -] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json deleted file mode 100644 index a12f1d95f13..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_mode_pending.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "brand": "aosmith", - "model": "HPTS-50 200 202172000", - "deviceType": "NEXT_GEN_HEAT_PUMP", - "dsn": "dsn", - "junctionId": "junctionId", - "name": "My water heater", - "serial": "serial", - "install": { - "location": "Basement" - }, - "data": { - "__typename": "NextGenHeatPump", - "temperatureSetpoint": 130, - "temperatureSetpointPending": false, - "temperatureSetpointPrevious": 130, - "temperatureSetpointMaximum": 130, - "modes": [ - { - "mode": "HYBRID", - "controls": null - }, - { - "mode": "HEAT_PUMP", - "controls": null - }, - { - "mode": "ELECTRIC", - "controls": "SELECT_DAYS" - }, - { - "mode": "VACATION", - "controls": "SELECT_DAYS" - } - ], - "isOnline": true, - "firmwareVersion": "2.14", - "hotWaterStatus": "LOW", - "mode": "HEAT_PUMP", - "modePending": true, - "vacationModeRemainingDays": 0, - "electricModeRemainingDays": 0 - } - } -] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json deleted file mode 100644 index 249024e1f1e..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "brand": "aosmith", - "model": "HPTS-50 200 202172000", - "deviceType": "NEXT_GEN_HEAT_PUMP", - "dsn": "dsn", - "junctionId": "junctionId", - "name": "My water heater", - "serial": "serial", - "install": { - "location": "Basement" - }, - "data": { - "__typename": "NextGenHeatPump", - "temperatureSetpoint": 130, - "temperatureSetpointPending": false, - "temperatureSetpointPrevious": 130, - "temperatureSetpointMaximum": 130, - "modes": [ - { - "mode": "HYBRID", - "controls": null - }, - { - "mode": "HEAT_PUMP", - "controls": null - }, - { - "mode": "ELECTRIC", - "controls": "SELECT_DAYS" - } - ], - "isOnline": true, - "firmwareVersion": "2.14", - "hotWaterStatus": "LOW", - "mode": "HEAT_PUMP", - "modePending": false, - "vacationModeRemainingDays": 0, - "electricModeRemainingDays": 0 - } - } -] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json deleted file mode 100644 index 4d6e7613cf2..00000000000 --- a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "brand": "aosmith", - "model": "HPTS-50 200 202172000", - "deviceType": "NEXT_GEN_HEAT_PUMP", - "dsn": "dsn", - "junctionId": "junctionId", - "name": "My water heater", - "serial": "serial", - "install": { - "location": "Basement" - }, - "data": { - "__typename": "NextGenHeatPump", - "temperatureSetpoint": 130, - "temperatureSetpointPending": true, - "temperatureSetpointPrevious": 130, - "temperatureSetpointMaximum": 130, - "modes": [ - { - "mode": "HYBRID", - "controls": null - }, - { - "mode": "HEAT_PUMP", - "controls": null - }, - { - "mode": "ELECTRIC", - "controls": "SELECT_DAYS" - }, - { - "mode": "VACATION", - "controls": "SELECT_DAYS" - } - ], - "isOnline": true, - "firmwareVersion": "2.14", - "hotWaterStatus": "LOW", - "mode": "HEAT_PUMP", - "modePending": false, - "vacationModeRemainingDays": 0, - "electricModeRemainingDays": 0 - } - } -] diff --git a/tests/components/aosmith/fixtures/get_energy_use_data.json b/tests/components/aosmith/fixtures/get_energy_use_data.json deleted file mode 100644 index 989ddab5399..00000000000 --- a/tests/components/aosmith/fixtures/get_energy_use_data.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "average": 2.7552000000000003, - "graphData": [ - { - "date": "2023-10-30T04:00:00.000Z", - "kwh": 2.01 - }, - { - "date": "2023-10-31T04:00:00.000Z", - "kwh": 1.542 - }, - { - "date": "2023-11-01T04:00:00.000Z", - "kwh": 1.908 - } - ], - "lifetimeKwh": 132.825, - "startDate": "Oct 30" -} diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py index 463932e930a..7ff75ce1105 100644 --- a/tests/components/aosmith/test_init.py +++ b/tests/components/aosmith/test_init.py @@ -15,11 +15,9 @@ from homeassistant.components.aosmith.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_array_fixture, -) +from .conftest import build_device_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: @@ -52,7 +50,7 @@ async def test_config_entry_not_ready_get_energy_use_data_error( """Test the config entry not ready when get_energy_use_data fails.""" mock_config_entry.add_to_hass(hass) - get_devices_fixture = load_json_array_fixture("get_devices.json", DOMAIN) + get_devices_fixture = [build_device_fixture(False, False, True)] with patch( "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", @@ -68,12 +66,17 @@ async def test_config_entry_not_ready_get_energy_use_data_error( @pytest.mark.parametrize( - ("get_devices_fixture", "time_to_wait", "expected_call_count"), + ( + "get_devices_fixture_mode_pending", + "get_devices_fixture_setpoint_pending", + "time_to_wait", + "expected_call_count", + ), [ - ("get_devices", REGULAR_INTERVAL, 1), - ("get_devices", FAST_INTERVAL, 0), - ("get_devices_mode_pending", FAST_INTERVAL, 1), - ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + (False, False, REGULAR_INTERVAL, 1), + (False, False, FAST_INTERVAL, 0), + (True, False, FAST_INTERVAL, 1), + (False, True, FAST_INTERVAL, 1), ], ) async def test_update( diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py index 61cb159c82a..a66b5db35e6 100644 --- a/tests/components/aosmith/test_water_heater.py +++ b/tests/components/aosmith/test_water_heater.py @@ -2,15 +2,10 @@ from unittest.mock import MagicMock +from py_aosmith.models import OperationMode import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.aosmith.const import ( - AOSMITH_MODE_ELECTRIC, - AOSMITH_MODE_HEAT_PUMP, - AOSMITH_MODE_HYBRID, - AOSMITH_MODE_VACATION, -) from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, ATTR_OPERATION_MODE, @@ -59,8 +54,8 @@ async def test_state( @pytest.mark.parametrize( - ("get_devices_fixture"), - ["get_devices_no_vacation_mode"], + ("get_devices_fixture_has_vacation_mode"), + [False], ) async def test_state_away_mode_unsupported( hass: HomeAssistant, init_integration: MockConfigEntry @@ -77,9 +72,9 @@ async def test_state_away_mode_unsupported( @pytest.mark.parametrize( ("hass_mode", "aosmith_mode"), [ - (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), - (STATE_ECO, AOSMITH_MODE_HYBRID), - (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + (STATE_HEAT_PUMP, OperationMode.HEAT_PUMP), + (STATE_ECO, OperationMode.HYBRID), + (STATE_ELECTRIC, OperationMode.ELECTRIC), ], ) async def test_set_operation_mode( @@ -122,8 +117,8 @@ async def test_set_temperature( @pytest.mark.parametrize( ("hass_away_mode", "aosmith_mode"), [ - (True, AOSMITH_MODE_VACATION), - (False, AOSMITH_MODE_HYBRID), + (True, OperationMode.VACATION), + (False, OperationMode.HYBRID), ], ) async def test_away_mode( From fce869248cbb902aeece2ca7b469a2e90d7fdd3d Mon Sep 17 00:00:00 2001 From: Elvio Date: Sat, 6 Jan 2024 22:44:20 +0000 Subject: [PATCH 0332/1544] Update Apprise to 1.7.1 (#107383) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 8132f3623a9..851aaae0f19 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.6.0"] + "requirements": ["apprise==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1d9fb84075b..c9ae57afc0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -443,7 +443,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.6.0 +apprise==1.7.1 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14235bb399a..9ee61b20511 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.6.0 +apprise==1.7.1 # homeassistant.components.aprs aprslib==0.7.0 From 50fbcaf20f27eb4289f989dff1e0964643536393 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 6 Jan 2024 15:56:19 -0700 Subject: [PATCH 0333/1544] Streamline exception handling in Guardian (#107053) --- homeassistant/components/guardian/__init__.py | 8 ++-- homeassistant/components/guardian/button.py | 13 ++----- homeassistant/components/guardian/switch.py | 37 +++++-------------- homeassistant/components/guardian/util.py | 37 +++++++++++++++++-- 4 files changed, 50 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index b9f0740ea0c..2343871bd99 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, cast +from typing import Any from aioguardian import Client from aioguardian.errors import GuardianError @@ -302,9 +302,7 @@ class PairedSensorManager: entry=self._entry, client=self._client, api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}", - api_coro=lambda: cast( - Awaitable, self._client.sensor.paired_sensor_status(uid) - ), + api_coro=lambda: self._client.sensor.paired_sensor_status(uid), api_lock=self._api_lock, valve_controller_uid=self._entry.data[CONF_UID], ) diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 485de90f1d8..cb9c6f0121c 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from aioguardian import Client -from aioguardian.errors import GuardianError from homeassistant.components.button import ( ButtonDeviceClass, @@ -15,12 +14,12 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error @dataclass(frozen=True, kw_only=True) @@ -96,14 +95,10 @@ class GuardianButton(ValveControllerEntity, ButtonEntity): self._client = data.client + @convert_exceptions_to_homeassistant_error async def async_press(self) -> None: """Send out a restart command.""" - try: - async with self._client: - await self.entity_description.push_action(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while pressing button "{self.entity_id}": {err}' - ) from err + async with self._client: + await self.entity_description.push_action(self._client) async_dispatcher_send(self.hass, self.coordinator.signal_reboot_requested) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 81f06ba4356..69d86c10e04 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -6,17 +6,16 @@ from dataclasses import dataclass from typing import Any from aioguardian import Client -from aioguardian.errors import GuardianError 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.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error ATTR_AVG_CURRENT = "average_current" ATTR_CONNECTED_CLIENTS = "connected_clients" @@ -139,34 +138,16 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): """Return True if entity is on.""" return self.entity_description.is_on_fn(self.coordinator.data) + @convert_exceptions_to_homeassistant_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - if not self._attr_is_on: - return - - try: - async with self._client: - await self.entity_description.off_fn(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while turning "{self.entity_id}" off: {err}' - ) from err - - self._attr_is_on = False - self.async_write_ha_state() + async with self._client: + await self.entity_description.off_fn(self._client) + await self.coordinator.async_request_refresh() + @convert_exceptions_to_homeassistant_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - if self._attr_is_on: - return - - try: - async with self._client: - await self.entity_description.on_fn(self._client) - except GuardianError as err: - raise HomeAssistantError( - f'Error while turning "{self.entity_id}" on: {err}' - ) from err - - self._attr_is_on = True - self.async_write_ha_state() + async with self._client: + await self.entity_description.on_fn(self._client) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 400cd472446..048f3750d32 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -2,22 +2,29 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta -from typing import Any, cast +from functools import wraps +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast from aioguardian import Client from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +if TYPE_CHECKING: + from . import GuardianEntity + + _GuardianEntityT = TypeVar("_GuardianEntityT", bound=GuardianEntity) + DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" @@ -68,7 +75,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): entry: ConfigEntry, client: Client, api_name: str, - api_coro: Callable[..., Awaitable], + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], api_lock: asyncio.Lock, valve_controller_uid: str, ) -> None: @@ -112,3 +119,27 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): self.hass, self.signal_reboot_requested, async_reboot_requested ) ) + + +_P = ParamSpec("_P") + + +@callback +def convert_exceptions_to_homeassistant_error( + func: Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate to handle exceptions from the Guardian API.""" + + @wraps(func) + async def wrapper( + entity: _GuardianEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + """Wrap the provided function.""" + try: + await func(entity, *args, **kwargs) + except GuardianError as err: + raise HomeAssistantError( + f"Error while calling {func.__name__}: {err}" + ) from err + + return wrapper From e4468570017df23db561c43bb94afea25973ba53 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 6 Jan 2024 23:06:28 -0700 Subject: [PATCH 0334/1544] Clean up buggy Guardian `switch` context managers (#107426) --- homeassistant/components/guardian/switch.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 69d86c10e04..1ed5239641d 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -49,22 +49,26 @@ class ValveControllerSwitchDescription( async def _async_disable_ap(client: Client) -> None: """Disable the onboard AP.""" - await client.wifi.disable_ap() + async with client: + await client.wifi.disable_ap() async def _async_enable_ap(client: Client) -> None: """Enable the onboard AP.""" - await client.wifi.enable_ap() + async with client: + await client.wifi.enable_ap() async def _async_close_valve(client: Client) -> None: """Close the valve.""" - await client.valve.close() + async with client: + await client.valve.close() async def _async_open_valve(client: Client) -> None: """Open the valve.""" - await client.valve.open() + async with client: + await client.valve.open() VALVE_CONTROLLER_DESCRIPTIONS = ( @@ -141,13 +145,11 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): @convert_exceptions_to_homeassistant_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - async with self._client: - await self.entity_description.off_fn(self._client) + await self.entity_description.off_fn(self._client) await self.coordinator.async_request_refresh() @convert_exceptions_to_homeassistant_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - async with self._client: - await self.entity_description.on_fn(self._client) + await self.entity_description.on_fn(self._client) await self.coordinator.async_request_refresh() From c96f9864c50744b08e0190d9dd83b0a422952f94 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 6 Jan 2024 23:06:45 -0700 Subject: [PATCH 0335/1544] Remove leftover Guardian mixin (#107424) --- homeassistant/components/guardian/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 2343871bd99..117510a8c1a 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -390,19 +390,12 @@ class PairedSensorEntity(GuardianEntity): @dataclass(frozen=True, kw_only=True) -class ValveControllerEntityDescriptionMixin: - """Define an entity description mixin for valve controller entities.""" +class ValveControllerEntityDescription(EntityDescription): + """Describe a Guardian valve controller entity.""" api_category: str -@dataclass(frozen=True, kw_only=True) -class ValveControllerEntityDescription( - EntityDescription, ValveControllerEntityDescriptionMixin -): - """Describe a Guardian valve controller entity.""" - - class ValveControllerEntity(GuardianEntity): """Define a Guardian valve controller entity.""" From de3fde5901e366397662c780e6277e11b8adcf05 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:41:31 +0100 Subject: [PATCH 0336/1544] Enable strict typing for oralb (#107438) --- .strict-typing | 1 + homeassistant/components/oralb/__init__.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9c70e27895d..f01c3541b0e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -295,6 +295,7 @@ homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* homeassistant.components.openuv.* +homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* homeassistant.components.p1_monitor.* diff --git a/homeassistant/components/oralb/__init__.py b/homeassistant/components/oralb/__init__.py index c981ad01bd8..4a4d06cabbb 100644 --- a/homeassistant/components/oralb/__init__.py +++ b/homeassistant/components/oralb/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from oralb_ble import OralBBluetoothDeviceData +from oralb_ble import OralBBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_poll(service_info: BluetoothServiceInfoBleak): + async def _async_poll(service_info: BluetoothServiceInfoBleak) -> SensorUpdate: # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it # directly to the oralb code # Make sure the device we have is one that we can connect with diff --git a/mypy.ini b/mypy.ini index 3f478afaa82..44cd6af98b0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2711,6 +2711,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.oralb.*] +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.otbr.*] check_untyped_defs = true disallow_incomplete_defs = true From 5a39503accf0bba03d62d22a591d9b91d3371e6f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:41:54 +0100 Subject: [PATCH 0337/1544] Enable strict typing for led_ble (#107437) --- .strict-typing | 1 + homeassistant/components/led_ble/__init__.py | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index f01c3541b0e..8134581fb5d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -242,6 +242,7 @@ homeassistant.components.laundrify.* homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* +homeassistant.components.led_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 1bdb8bf8ec9..70b77ba6787 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_update(): + async def _async_update() -> None: """Update the device state.""" try: await led_ble.update() diff --git a/mypy.ini b/mypy.ini index 44cd6af98b0..c670e7b1c5e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2181,6 +2181,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true From be68feffddb99b684e876f7f59f770c1e33d7226 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:42:38 +0100 Subject: [PATCH 0338/1544] Enable strict typing for enphase_envoy (#107436) --- .strict-typing | 1 + .../components/enphase_envoy/config_flow.py | 6 +++--- homeassistant/components/enphase_envoy/switch.py | 12 ++++++------ mypy.ini | 10 ++++++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8134581fb5d..22a619dde69 100644 --- a/.strict-typing +++ b/.strict-typing @@ -155,6 +155,7 @@ homeassistant.components.emulated_hue.* homeassistant.components.energy.* homeassistant.components.energyzero.* homeassistant.components.enigma2.* +homeassistant.components.enphase_envoy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 999542ee2a5..939359f7fbf 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -42,12 +42,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize an envoy flow.""" - self.ip_address = None + self.ip_address: str | None = None self.username = None self.protovers: str | None = None - self._reauth_entry = None + self._reauth_entry: config_entries.ConfigEntry | None = None @callback def _async_generate_schema(self) -> vol.Schema: diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 76c73914db6..921c5601dac 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -169,12 +169,12 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) await self.coordinator.async_request_refresh() @@ -217,12 +217,12 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): self.async_write_ha_state() @@ -261,12 +261,12 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) await self.coordinator.async_request_refresh() diff --git a/mypy.ini b/mypy.ini index c670e7b1c5e..dbcaf3a840d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1311,6 +1311,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.enphase_envoy.*] +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.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true From d8c79964c8c54de176ed245e34189c976a73f775 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:43:42 +0100 Subject: [PATCH 0339/1544] Enable strict typing for waqi (#107439) --- .strict-typing | 1 + homeassistant/components/waqi/__init__.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 22a619dde69..669e96e8bb0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -420,6 +420,7 @@ homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* +homeassistant.components.waqi.* homeassistant.components.water_heater.* homeassistant.components.watttime.* homeassistant.components.weather.* diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index d3cf1af21a2..d4e41095b26 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -47,7 +47,7 @@ async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: entity_registry, entry.entry_id ) for reg_entry in registry_entries: - if isinstance(reg_entry.unique_id, int): - entity_registry.async_update_entity( + if isinstance(reg_entry.unique_id, int): # type: ignore[unreachable] + entity_registry.async_update_entity( # type: ignore[unreachable] reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" ) diff --git a/mypy.ini b/mypy.ini index dbcaf3a840d..0152db2a1ae 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3963,6 +3963,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.waqi.*] +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.water_heater.*] check_untyped_defs = true disallow_incomplete_defs = true From e4ff51fa9a961d43851ca853cbabccb62a926b5d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:43:54 +0100 Subject: [PATCH 0340/1544] Enable strict typing for youtube (#107440) --- .strict-typing | 1 + homeassistant/components/youtube/api.py | 2 +- homeassistant/components/youtube/diagnostics.py | 2 +- mypy.ini | 10 ++++++++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 669e96e8bb0..377a30444de 100644 --- a/.strict-typing +++ b/.strict-typing @@ -434,6 +434,7 @@ homeassistant.components.wled.* homeassistant.components.worldclock.* homeassistant.components.yale_smart_alarm.* homeassistant.components.yalexs_ble.* +homeassistant.components.youtube.* homeassistant.components.zeroconf.* homeassistant.components.zodiac.* homeassistant.components.zone.* diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index f8a9008d9b3..b98169e3589 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -25,7 +25,7 @@ class AsyncConfigEntryAuth: @property def access_token(self) -> str: """Return the access token.""" - return self.oauth_session.token[CONF_ACCESS_TOKEN] + return self.oauth_session.token[CONF_ACCESS_TOKEN] # type: ignore[no-any-return] async def check_and_refresh_token(self) -> str: """Check the token.""" diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 380033e450a..7cd32d50d27 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -17,7 +17,7 @@ async def async_get_config_entry_diagnostics( coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ COORDINATOR ] - sensor_data = {} + sensor_data: dict[str, Any] = {} for channel_id, channel_data in coordinator.data.items(): channel_data.get(ATTR_LATEST_VIDEO, {}).pop(ATTR_DESCRIPTION) sensor_data[channel_id] = channel_data diff --git a/mypy.ini b/mypy.ini index 0152db2a1ae..479565ec777 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4103,6 +4103,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.youtube.*] +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.zeroconf.*] check_untyped_defs = true disallow_incomplete_defs = true From c833f275d6b84da0017d3593b55c248d8614f58e Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:44:28 +0100 Subject: [PATCH 0341/1544] Add select platform to Vogel's MotionMount integration (#107132) --- .coveragerc | 1 + .../components/motionmount/__init__.py | 1 + homeassistant/components/motionmount/const.py | 1 + .../components/motionmount/select.py | 60 +++++++++++++++++++ .../components/motionmount/strings.json | 8 +++ 5 files changed, 71 insertions(+) create mode 100644 homeassistant/components/motionmount/select.py diff --git a/.coveragerc b/.coveragerc index 0724ff80394..664dbb666f3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -764,6 +764,7 @@ omit = homeassistant/components/motionmount/__init__.py homeassistant/components/motionmount/entity.py homeassistant/components/motionmount/number.py + homeassistant/components/motionmount/select.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 8baceb104c3..4285a12a101 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -15,6 +15,7 @@ from .const import DOMAIN, EMPTY_MAC PLATFORMS: list[Platform] = [ Platform.NUMBER, + Platform.SELECT, ] diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py index 92045193ad6..884904332af 100644 --- a/homeassistant/components/motionmount/const.py +++ b/homeassistant/components/motionmount/const.py @@ -3,3 +3,4 @@ DOMAIN = "motionmount" EMPTY_MAC = "00:00:00:00:00:00" +WALL_PRESET_NAME = "0_wall" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py new file mode 100644 index 00000000000..ef0b1e918ae --- /dev/null +++ b/homeassistant/components/motionmount/select.py @@ -0,0 +1,60 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, WALL_PRESET_NAME +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([MotionMountPresets(mm, entry)], True) + + +class MotionMountPresets(MotionMountEntity, SelectEntity): + """The presets of a MotionMount.""" + + _attr_translation_key = "motionmount_preset" + _attr_current_option: str | None = None + + def __init__( + self, + mm: motionmount.MotionMount, + config_entry: ConfigEntry, + ) -> None: + """Initialize Preset selector.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-preset" + + def _update_options(self, presets: dict[int, str]) -> None: + """Convert presets to select options.""" + options = [WALL_PRESET_NAME] + for index, name in presets.items(): + options.append(f"{index}: {name}") + + self._attr_options = options + + async def async_update(self) -> None: + """Get latest state from MotionMount.""" + presets = await self.mm.get_presets() + self._update_options(presets) + + if self._attr_current_option is None: + self._attr_current_option = self._attr_options[0] + + async def async_select_option(self, option: str) -> None: + """Set the new option.""" + index = int(option[:1]) + await self.mm.go_to_preset(index) + self._attr_current_option = option + + # Perform an update so we detect changes to the presets (changes are not pushed) + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 00a409f3058..2a25611433a 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -32,6 +32,14 @@ "motionmount_turn": { "name": "Turn" } + }, + "select": { + "motionmount_preset": { + "name": "Preset", + "state": { + "0_wall": "0: Wall" + } + } } } } From 84b20edeca071a69adfa27966b0d19c93a54b3aa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 7 Jan 2024 10:51:31 +0100 Subject: [PATCH 0342/1544] Add missing wifi data in AVM!Fritz Tools tests (#107373) --- tests/components/fritz/const.py | 15 +++++++++++++++ tests/components/fritz/test_diagnostics.py | 1 + 2 files changed, 16 insertions(+) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index dc27e8aab96..d39cb21beea 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -132,6 +132,21 @@ MOCK_FB_SERVICES: dict[str, dict] = { }, "GetPortMappingNumberOfEntries": {}, }, + "WLANConfiguration1": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewSSID": "MyWifi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + }, + "GetSSID": { + "NewSSID": "MyWifi", + }, + "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, + }, "X_AVM-DE_Homeauto1": { "GetGenericDeviceInfos": [ { diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index 760b5f32d0c..9751e25de72 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -62,6 +62,7 @@ async def test_entry_diagnostics( "WANDSLInterfaceConfig1", "WANIPConn1", "WANPPPConnection1", + "WLANConfiguration1", "X_AVM-DE_Homeauto1", "X_AVM-DE_HostFilter1", ], From cecb12a93ce6dd08052bd7e28d7531e9667c402a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 7 Jan 2024 11:27:13 +0100 Subject: [PATCH 0343/1544] Remove name from faa_delays (#107418) --- homeassistant/components/faa_delays/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 20bebcf08c8..df6ddc38de7 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -118,7 +118,6 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE super().__init__(coordinator) self.entity_description = description _id = coordinator.data.code - self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, _id)}, From cd8adfc84ea21df2e096e7504d1db289f67f4fd8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 11:39:41 +0100 Subject: [PATCH 0344/1544] Improve flume typing (#107444) --- homeassistant/components/flume/__init__.py | 6 ++++- homeassistant/components/flume/config_flow.py | 25 +++++++++++++------ homeassistant/components/flume/coordinator.py | 14 +++++------ homeassistant/components/flume/entity.py | 2 +- homeassistant/components/flume/sensor.py | 3 ++- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index a5911af3c8f..3a8718a14e0 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -1,4 +1,6 @@ """The flume integration.""" +from __future__ import annotations + from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException @@ -41,7 +43,9 @@ LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( ) -def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): +def _setup_entry( + hass: HomeAssistant, entry: ConfigEntry +) -> tuple[FlumeAuth, FlumeDeviceList, Session]: """Config entry set up in executor.""" config = entry.data diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index e31519738d1..df2a697ed8d 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,4 +1,6 @@ """Config flow for flume integration.""" +from __future__ import annotations + from collections.abc import Mapping import logging import os @@ -36,7 +38,9 @@ DATA_SCHEMA = vol.Schema( ) -def _validate_input(hass: core.HomeAssistant, data: dict, clear_token_file: bool): +def _validate_input( + hass: core.HomeAssistant, data: dict[str, Any], clear_token_file: bool +) -> FlumeDeviceList: """Validate in the executor.""" flume_token_full_path = hass.config.path( f"{BASE_TOKEN_FILENAME}-{data[CONF_USERNAME]}" @@ -56,8 +60,8 @@ def _validate_input(hass: core.HomeAssistant, data: dict, clear_token_file: bool async def validate_input( - hass: core.HomeAssistant, data: dict, clear_token_file: bool = False -): + hass: core.HomeAssistant, data: dict[str, Any], clear_token_file: bool = False +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -85,11 +89,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Init flume config flow.""" - self._reauth_unique_id = None + self._reauth_unique_id: str | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() @@ -111,10 +117,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauth input.""" - errors = {} + errors: dict[str, str] = {} existing_entry = await self.async_set_unique_id(self._reauth_unique_id) + assert existing_entry if user_input is not None: new_data = {**existing_entry.data, CONF_PASSWORD: user_input[CONF_PASSWORD]} try: diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 1f590b0cd16..b5d37b8027f 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any import pyflume -from pyflume import FlumeDeviceList +from pyflume import FlumeAuth, FlumeData, FlumeDeviceList from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,7 +21,7 @@ from .const import ( class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for an individual flume device.""" - def __init__(self, hass: HomeAssistant, flume_device) -> None: + def __init__(self, hass: HomeAssistant, flume_device: FlumeData) -> None: """Initialize the Coordinator.""" super().__init__( hass, @@ -79,7 +79,7 @@ class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for flume notifications.""" - def __init__(self, hass: HomeAssistant, auth) -> None: + def __init__(self, hass: HomeAssistant, auth: FlumeAuth) -> None: """Initialize the Coordinator.""" super().__init__( hass, @@ -88,15 +88,15 @@ class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=NOTIFICATION_SCAN_INTERVAL, ) self.auth = auth - self.active_notifications_by_device: dict = {} - self.notifications: list[dict[str, Any]] + self.active_notifications_by_device: dict[str, set[str]] = {} + self.notifications: list[dict[str, Any]] = [] - def _update_lists(self): + def _update_lists(self) -> None: """Query flume for notification list.""" # Get notifications (read or unread). # The related binary sensors (leak detected, high flow, low battery) # will be active until the notification is deleted in the Flume app. - self.notifications: list[dict[str, Any]] = pyflume.FlumeNotificationList( + self.notifications = pyflume.FlumeNotificationList( self.auth, read=None ).notification_list _LOGGER.debug("Notifications %s", self.notifications) diff --git a/homeassistant/components/flume/entity.py b/homeassistant/components/flume/entity.py index f17e58529c4..a6d13b1f291 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -58,7 +58,7 @@ class FlumeEntity(CoordinatorEntity[_FlumeCoordinatorT]): configuration_url="https://portal.flumewater.com", ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Request an update when added.""" await super().async_added_to_hass() # We do not ask for an update with async_add_entities() diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index fc08fee476c..d4753301213 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DEVICE_SCAN_INTERVAL, @@ -139,7 +140,7 @@ class FlumeSensor(FlumeEntity[FlumeDeviceDataUpdateCoordinator], SensorEntity): """Representation of the Flume sensor.""" @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" sensor_key = self.entity_description.key if sensor_key not in self.coordinator.flume_device.values: From d19037a36bb5b252f5aebff0dbbaa04b2e91f4bf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 7 Jan 2024 06:26:08 -0500 Subject: [PATCH 0345/1544] Clean up zwave_js test_removed_device test (#107346) --- tests/components/zwave_js/test_init.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 75a7397cc4e..77b1fcb8b3a 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -964,22 +964,14 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 3 - # Check how many entities there are - ent_reg = er.async_get(hass) - entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 93 - # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() - # Assert that the node and all of it's entities were removed from the device and - # entity registry + # Assert that the node was removed from the device registry device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 - entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 62 assert ( dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) From 840089b8ac86ea22c1ebc1fd58f5b938c2951051 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 7 Jan 2024 13:15:34 +0100 Subject: [PATCH 0346/1544] Handle OSError during setup for System Monitor (#107396) * Handle OSError during setup for System Monitor * Clean string copy * debug --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/systemmonitor/util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 25b8aa2eb1d..742e0d40f3d 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -25,6 +25,11 @@ def get_all_disk_mounts() -> set[str]: "No permission for running user to access %s", part.mountpoint ) continue + except OSError as err: + _LOGGER.debug( + "Mountpoint %s was excluded because of: %s", part.mountpoint, err + ) + continue if usage.total > 0 and part.device != "": disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) From da8ce7bbf38b1037b0e223ab62f98e71adc24b55 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sun, 7 Jan 2024 14:20:37 +0100 Subject: [PATCH 0347/1544] Fix local_todo typo (#107454) local todo typo --- homeassistant/components/local_todo/todo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 99fb6dcebfa..e94206317d7 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -127,7 +127,7 @@ class LocalTodoListEntity(TodoListEntity): await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: - """Add an item to the To-do list.""" + """Delete an item from the To-do list.""" store = TodoStore(self._calendar) for uid in uids: store.delete(uid) From 3139e926965b464c1172770830a4ab3ae6283959 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Sun, 7 Jan 2024 16:19:58 +0100 Subject: [PATCH 0348/1544] Fix Swiss public transport initial data for attributes (#107452) faster initial data for attributes --- .../components/swiss_public_transport/sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 5d4a6813d2d..0e88cd2d3ad 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -122,15 +122,25 @@ class SwissPublicTransportSensor( entry_type=DeviceEntryType.SERVICE, ) + async def async_added_to_hass(self) -> None: + """Prepare the extra attributes at start.""" + self._async_update_attrs() + await super().async_added_to_hass() + @callback def _handle_coordinator_update(self) -> None: """Handle the state update and prepare the extra state attributes.""" + self._async_update_attrs() + return super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update the extra state attributes based on the coordinator data.""" self._attr_extra_state_attributes = { key: value for key, value in self.coordinator.data.items() if key not in {"departure"} } - return super()._handle_coordinator_update() @property def native_value(self) -> str: From 15ce70606f5417a02d283d236fb97a461e53c9cf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 7 Jan 2024 17:48:23 +0100 Subject: [PATCH 0349/1544] Add typing to Lutron platforms (#107408) * Add typing to Lutron platforms * Update homeassistant/components/lutron/switch.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Update homeassistant/components/lutron/__init__.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Update homeassistant/components/lutron/entity.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Update homeassistant/components/lutron/scene.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Fix typing * Fix typing --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/lutron/__init__.py | 28 +++++++++++-------- .../components/lutron/binary_sensor.py | 1 + homeassistant/components/lutron/cover.py | 5 +++- homeassistant/components/lutron/entity.py | 13 +++++++-- homeassistant/components/lutron/light.py | 9 +++--- homeassistant/components/lutron/scene.py | 13 ++++++++- homeassistant/components/lutron/switch.py | 19 +++++++++++-- 7 files changed, 64 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index b5b7f4d1b31..d89797eedc7 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import logging -from pylutron import Button, Led, Lutron, OccupancyGroup, Output +from pylutron import Button, Keypad, Led, Lutron, LutronEvent, OccupancyGroup, Output import voluptuous as vol from homeassistant import config_entries @@ -110,7 +110,9 @@ class LutronButton: represented as an entity; it simply fires events. """ - def __init__(self, hass: HomeAssistant, area_name, keypad, button) -> None: + def __init__( + self, hass: HomeAssistant, area_name: str, keypad: Keypad, button: Button + ) -> None: """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" if button.name == "Unknown Button": @@ -130,7 +132,9 @@ class LutronButton: button.subscribe(self.button_callback, None) - def button_callback(self, button, context, event, params): + def button_callback( + self, _button: Button, _context: None, event: LutronEvent, _params: dict + ) -> None: """Fire an event about a button being pressed or released.""" # Events per button type: # RaiseLower -> pressed/released @@ -154,17 +158,17 @@ class LutronButton: self._hass.bus.fire(self._event, data) -@dataclass(slots=True) +@dataclass(slots=True, kw_only=True) class LutronData: """Storage class for platform global data.""" client: Lutron - covers: list[tuple[str, Output]] - lights: list[tuple[str, Output]] - switches: list[tuple[str, Output]] - scenes: list[tuple[str, str, Button, Led]] binary_sensors: list[tuple[str, OccupancyGroup]] buttons: list[LutronButton] + covers: list[tuple[str, Output]] + lights: list[tuple[str, Output]] + scenes: list[tuple[str, str, Button, Led]] + switches: list[tuple[str, Output]] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -181,12 +185,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b entry_data = LutronData( client=lutron_client, - covers=[], - lights=[], - switches=[], - scenes=[], binary_sensors=[], buttons=[], + covers=[], + lights=[], + scenes=[], + switches=[], ) # Sort our devices into types _LOGGER.debug("Start adding devices") diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 65f9bd4d390..8433724d489 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -49,6 +49,7 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): reported as a single occupancy group. """ + _lutron_device: OccupancyGroup _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @property diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 1658d92b79c..1941c050aa4 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from pylutron import Output + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, @@ -33,7 +35,7 @@ async def async_setup_entry( entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - LutronCover(area_name, device, entry_data.covers) + LutronCover(area_name, device, entry_data.client) for area_name, device in entry_data.covers ], True, @@ -48,6 +50,7 @@ class LutronCover(LutronDevice, CoverEntity): | CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION ) + _lutron_device: Output @property def is_closed(self) -> bool: diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index 238c2e3c552..423186eceae 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -1,4 +1,7 @@ """Base class for Lutron devices.""" + +from pylutron import Lutron, LutronEntity, LutronEvent + from homeassistant.helpers.entity import Entity @@ -7,7 +10,9 @@ class LutronDevice(Entity): _attr_should_poll = False - def __init__(self, area_name, lutron_device, controller) -> None: + def __init__( + self, area_name: str, lutron_device: LutronEntity, controller: Lutron + ) -> None: """Initialize the device.""" self._lutron_device = lutron_device self._controller = controller @@ -17,7 +22,9 @@ class LutronDevice(Entity): """Register callbacks.""" self._lutron_device.subscribe(self._update_callback, None) - def _update_callback(self, _device, _context, _event, _params): + def _update_callback( + self, _device: LutronEntity, _context: None, _event: LutronEvent, _params: dict + ) -> None: """Run when invoked by pylutron when the device state changes.""" self.schedule_update_ha_state() @@ -27,7 +34,7 @@ class LutronDevice(Entity): return f"{self._area_name} {self._lutron_device.name}" @property - def unique_id(self): + def unique_id(self) -> str | None: """Return a unique ID.""" # Temporary fix for https://github.com/thecynic/pylutron/issues/70 if self._lutron_device.uuid is None: diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index a04006de61f..b6860a4e818 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any +from pylutron import Output + from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -48,11 +50,8 @@ class LutronLight(LutronDevice, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - - def __init__(self, area_name, lutron_device, controller) -> None: - """Initialize the light.""" - self._prev_brightness = None - super().__init__(area_name, lutron_device, controller) + _lutron_device: Output + _prev_brightness: int | None = None @property def brightness(self) -> int: diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 2033a9024bf..ae8f787d290 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from pylutron import Button, Led, Lutron + from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -36,7 +38,16 @@ async def async_setup_entry( class LutronScene(LutronDevice, Scene): """Representation of a Lutron Scene.""" - def __init__(self, area_name, keypad_name, lutron_device, lutron_led, controller): + _lutron_device: Button + + def __init__( + self, + area_name: str, + keypad_name: str, + lutron_device: Button, + lutron_led: Led, + controller: Lutron, + ) -> None: """Initialize the scene/button.""" super().__init__(area_name, lutron_device, controller) self._keypad_name = keypad_name diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index b418c9c481c..5cb7dcf53d8 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any +from pylutron import Button, Led, Lutron, Output + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -42,7 +44,11 @@ async def async_setup_entry( class LutronSwitch(LutronDevice, SwitchEntity): """Representation of a Lutron Switch.""" - def __init__(self, area_name, lutron_device, controller) -> None: + _lutron_device: Output + + def __init__( + self, area_name: str, lutron_device: Output, controller: Lutron + ) -> None: """Initialize the switch.""" self._prev_state = None super().__init__(area_name, lutron_device, controller) @@ -74,7 +80,16 @@ class LutronSwitch(LutronDevice, SwitchEntity): class LutronLed(LutronDevice, SwitchEntity): """Representation of a Lutron Keypad LED.""" - def __init__(self, area_name, keypad_name, scene_device, led_device, controller): + _lutron_device: Led + + def __init__( + self, + area_name: str, + keypad_name: str, + scene_device: Button, + led_device: Led, + controller: Lutron, + ) -> None: """Initialize the switch.""" self._keypad_name = keypad_name self._scene_name = scene_device.name From 901b9365b437194825d11532956910888ba47f12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 07:39:33 -1000 Subject: [PATCH 0350/1544] Small cleanups to ESPHome callbacks (#107428) --- .../components/esphome/entry_data.py | 81 ++++++++++++------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d9e5b199748..723141a94a2 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field +from functools import partial import logging from typing import TYPE_CHECKING, Any, Final, TypedDict, cast @@ -163,11 +164,18 @@ class RuntimeEntryData: """Register to receive callbacks when static info changes for an EntityInfo type.""" callbacks = self.entity_info_callbacks.setdefault(entity_info_type, []) callbacks.append(callback_) + return partial( + self._async_unsubscribe_register_static_info, callbacks, callback_ + ) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_register_static_info( + self, + callbacks: list[Callable[[list[EntityInfo]], None]], + callback_: Callable[[list[EntityInfo]], None], + ) -> None: + """Unsubscribe to when static info is registered.""" + callbacks.remove(callback_) @callback def async_register_key_static_info_remove_callback( @@ -179,11 +187,16 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_remove_callbacks.setdefault(callback_key, []) callbacks.append(callback_) + return partial(self._async_unsubscribe_static_key_remove, callbacks, callback_) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_static_key_remove( + self, + callbacks: list[Callable[[], Coroutine[Any, Any, None]]], + callback_: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + """Unsubscribe to when static info is removed.""" + callbacks.remove(callback_) @callback def async_register_key_static_info_updated_callback( @@ -195,11 +208,18 @@ class RuntimeEntryData: callback_key = (type(static_info), static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) + return partial( + self._async_unsubscribe_static_key_info_updated, callbacks, callback_ + ) - def _unsub() -> None: - callbacks.remove(callback_) - - return _unsub + @callback + def _async_unsubscribe_static_key_info_updated( + self, + callbacks: list[Callable[[EntityInfo], None]], + callback_: Callable[[EntityInfo], None], + ) -> None: + """Unsubscribe to when static info is updated .""" + callbacks.remove(callback_) @callback def async_set_assist_pipeline_state(self, state: bool) -> None: @@ -208,16 +228,20 @@ class RuntimeEntryData: for update_callback in self.assist_pipeline_update_callbacks: update_callback() + @callback def async_subscribe_assist_pipeline_update( self, update_callback: Callable[[], None] ) -> Callable[[], None]: """Subscribe to assist pipeline updates.""" - - def _unsubscribe() -> None: - self.assist_pipeline_update_callbacks.remove(update_callback) - self.assist_pipeline_update_callbacks.append(update_callback) - return _unsubscribe + return partial(self._async_unsubscribe_assist_pipeline_update, update_callback) + + @callback + def _async_unsubscribe_assist_pipeline_update( + self, update_callback: Callable[[], None] + ) -> None: + """Unsubscribe to assist pipeline updates.""" + self.assist_pipeline_update_callbacks.remove(update_callback) async def async_remove_entities(self, static_infos: Iterable[EntityInfo]) -> None: """Schedule the removal of an entity.""" @@ -232,19 +256,16 @@ class RuntimeEntryData: @callback def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None: """Call static info updated callbacks.""" + callbacks = self.entity_info_key_updated_callbacks for static_info in static_infos: - callback_key = (type(static_info), static_info.key) - for callback_ in self.entity_info_key_updated_callbacks.get( - callback_key, [] - ): + for callback_ in callbacks.get((type(static_info), static_info.key), ()): callback_(static_info) async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[Platform] ) -> None: async with self.platform_load_lock: - needed = platforms - self.loaded_platforms - if needed: + if needed := platforms - self.loaded_platforms: await hass.config_entries.async_forward_entry_setups(entry, needed) self.loaded_platforms |= needed @@ -305,12 +326,16 @@ class RuntimeEntryData: entity_callback: Callable[[], None], ) -> Callable[[], None]: """Subscribe to state updates.""" + subscription_key = (state_type, state_key) + self.state_subscriptions[subscription_key] = entity_callback + return partial(self._async_unsubscribe_state_update, subscription_key) - def _unsubscribe() -> None: - self.state_subscriptions.pop((state_type, state_key)) - - self.state_subscriptions[(state_type, state_key)] = entity_callback - return _unsubscribe + @callback + def _async_unsubscribe_state_update( + self, subscription_key: tuple[type[EntityState], int] + ) -> None: + """Unsubscribe to state updates.""" + self.state_subscriptions.pop(subscription_key) @callback def async_update_state(self, state: EntityState) -> None: From 75d591593db8ca4a4ca0551272de7a5c34a67655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 07:39:48 -1000 Subject: [PATCH 0351/1544] Remove calls to distribution and legacy zip support from package util (#107427) --- homeassistant/util/package.py | 30 +++++++++++++----------------- tests/util/test_package.py | 25 +++++++++---------------- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index bc60953a1aa..61d282931c0 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -3,13 +3,12 @@ from __future__ import annotations import asyncio from functools import cache -from importlib.metadata import PackageNotFoundError, distribution, version +from importlib.metadata import PackageNotFoundError, version import logging import os from pathlib import Path from subprocess import PIPE, Popen import sys -from urllib.parse import urlparse from packaging.requirements import InvalidRequirement, Requirement @@ -30,29 +29,26 @@ def is_docker_env() -> bool: return Path("/.dockerenv").exists() -def is_installed(package: str) -> bool: +def is_installed(requirement_str: str) -> bool: """Check if a package is installed and will be loaded when we import it. + expected input is a pip compatible package specifier (requirement string) + e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" + Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req. """ try: - distribution(package) - return True - except (IndexError, PackageNotFoundError): - try: - req = Requirement(package) - except InvalidRequirement: - # This is a zip file. We no longer use this in Home Assistant, - # leaving it in for custom components. - req = Requirement(urlparse(package).fragment) + req = Requirement(requirement_str) + except InvalidRequirement: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return False try: - installed_version = version(req.name) - # This will happen when an install failed or - # was aborted while in progress see - # https://github.com/home-assistant/core/issues/47699 - if installed_version is None: + if (installed_version := version(req.name)) is None: + # This can happen when an install failed or + # was aborted while in progress see + # https://github.com/home-assistant/core/issues/47699 _LOGGER.error( # type: ignore[unreachable] "Installed version for %s resolved to None", req.name ) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index e64ea01ffa8..e940fdf6f9c 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,6 +1,6 @@ """Test Home Assistant package util methods.""" import asyncio -from importlib.metadata import PackageNotFoundError, metadata +from importlib.metadata import metadata import logging import os from subprocess import PIPE @@ -235,21 +235,17 @@ def test_check_package_zip() -> None: assert not package.is_installed(TEST_ZIP_REQ) -def test_get_distribution_falls_back_to_version() -> None: - """Test for get_distribution failing and fallback to version.""" +def test_get_is_installed() -> None: + """Test is_installed can parse complex requirements.""" pkg = metadata("homeassistant") installed_package = pkg["name"] installed_version = pkg["version"] - with patch( - "homeassistant.util.package.distribution", - side_effect=PackageNotFoundError, - ): - assert package.is_installed(installed_package) - assert package.is_installed(f"{installed_package}=={installed_version}") - assert package.is_installed(f"{installed_package}>={installed_version}") - assert package.is_installed(f"{installed_package}<={installed_version}") - assert not package.is_installed(f"{installed_package}<{installed_version}") + assert package.is_installed(installed_package) + assert package.is_installed(f"{installed_package}=={installed_version}") + assert package.is_installed(f"{installed_package}>={installed_version}") + assert package.is_installed(f"{installed_package}<={installed_version}") + assert not package.is_installed(f"{installed_package}<{installed_version}") def test_check_package_previous_failed_install() -> None: @@ -258,9 +254,6 @@ def test_check_package_previous_failed_install() -> None: installed_package = pkg["name"] installed_version = pkg["version"] - with patch( - "homeassistant.util.package.distribution", - side_effect=PackageNotFoundError, - ), patch("homeassistant.util.package.version", return_value=None): + with patch("homeassistant.util.package.version", return_value=None): assert not package.is_installed(installed_package) assert not package.is_installed(f"{installed_package}=={installed_version}") From 2a8444b245213492da827582fa7aa83b6484fdda Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 7 Jan 2024 19:04:14 +0000 Subject: [PATCH 0352/1544] Fix evohome high_precision temps not retreived consistently (#107366) * initial commit * doctweak * remove hint * doctweak --- homeassistant/components/evohome/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 06712a83b6a..390bdeb3f33 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -497,7 +497,6 @@ class EvoBroker: session_id = get_session_id(self.client_v1) - self.temps = {} # these are now stale, will fall back to v2 temps try: temps = await self.client_v1.get_temperatures() @@ -523,6 +522,11 @@ class EvoBroker: ), err, ) + self.temps = {} # high-precision temps now considered stale + + except Exception: + self.temps = {} # high-precision temps now considered stale + raise else: if str(self.client_v1.location_id) != self._location.locationId: @@ -654,6 +658,7 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check if self._evo_broker.temps.get(self._evo_id) is not None: + # use high-precision temps if available return self._evo_broker.temps[self._evo_id] return self._evo_device.temperature From a9b51f0255eb673d204bc0e536dbda41dc851584 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 7 Jan 2024 20:32:17 +0100 Subject: [PATCH 0353/1544] Fix KNX telegram device trigger not firing after integration reload (#107388) --- homeassistant/components/knx/const.py | 3 +++ homeassistant/components/knx/device_trigger.py | 10 ++++++---- homeassistant/components/knx/telegrams.py | 4 +++- tests/components/knx/test_device_trigger.py | 3 --- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3d1e3c62a34..aa48bcdf557 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -82,6 +82,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +# dispatcher signal for KNX interface device triggers +SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict" + AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] MessageCallbackType = Callable[[Telegram], None] diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 1abafb221db..867a7c075b0 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -9,11 +9,12 @@ from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEM from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import selector +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT from .project import KNXProject from .schema import ga_list_validator from .telegrams import TelegramDict @@ -87,7 +88,6 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) job = HassJob(action, f"KNX device trigger {trigger_info}") - knx: KNXModule = hass.data[DOMAIN] @callback def async_call_trigger_action(telegram: TelegramDict) -> None: @@ -99,6 +99,8 @@ async def async_attach_trigger( {"trigger": {**trigger_data, **telegram}}, ) - return knx.telegrams.async_listen_telegram( - async_call_trigger_action, name="KNX device trigger call" + return async_dispatcher_connect( + hass, + signal=SIGNAL_KNX_TELEGRAM_DICT, + target=async_call_trigger_action, ) diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 87c1a8b6052..95250d99f85 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -11,10 +11,11 @@ from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT from .project import KNXProject STORAGE_VERSION: Final = 1 @@ -87,6 +88,7 @@ class Telegrams: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) self.recent_telegrams.append(telegram_dict) + async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM_DICT, telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e901fd7f29e..f3448947cf8 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -150,7 +150,6 @@ async def test_remove_device_trigger( }, ) - assert len(hass.data[DOMAIN].telegrams._jobs) == 1 await knx.receive_write("0/0/1", (0x03, 0x2F)) assert len(calls) == 1 assert calls.pop().data["catch_all"] == "telegram - 0/0/1" @@ -161,8 +160,6 @@ async def test_remove_device_trigger( {ATTR_ENTITY_ID: f"automation.{automation_name}"}, blocking=True, ) - - assert len(hass.data[DOMAIN].telegrams._jobs) == 0 await knx.receive_write("0/0/1", (0x03, 0x2F)) assert len(calls) == 0 From fd52172c336b833ce156dd9b3b880bef148d433c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 7 Jan 2024 20:35:55 +0100 Subject: [PATCH 0354/1544] Improve harmony typing (#107447) --- homeassistant/components/harmony/__init__.py | 12 ++++---- .../components/harmony/config_flow.py | 26 +++++++++++------ homeassistant/components/harmony/data.py | 14 ++++----- homeassistant/components/harmony/entity.py | 14 +++++---- homeassistant/components/harmony/remote.py | 29 +++++++++++-------- homeassistant/components/harmony/select.py | 4 +-- .../components/harmony/subscriber.py | 8 ++--- homeassistant/components/harmony/switch.py | 6 ++-- homeassistant/components/harmony/util.py | 2 +- 9 files changed, 67 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index d861068629f..327dbad343b 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: cancel_listener = entry.add_update_listener(_update_listener) - async def _async_on_stop(event): + async def _async_on_stop(event: Event) -> None: await data.shutdown() cancel_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_on_stop) @@ -56,11 +56,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _migrate_old_unique_ids( hass: HomeAssistant, entry_id: str, data: HarmonyData -): +) -> None: names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} @callback - def _async_migrator(entity_entry: er.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None: # Old format for switches was {remote_unique_id}-{activity_name} # New format is activity_{activity_id} parts = entity_entry.unique_id.split("-", 1) @@ -82,7 +82,9 @@ async def _migrate_old_unique_ids( @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: ConfigEntry +) -> None: options = dict(entry.options) modified = 0 for importable_option in (ATTR_ACTIVITY, ATTR_DELAY_SECS): diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index f74a19425ab..ad041e75f1a 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -35,7 +35,7 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(data): +async def validate_input(data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -60,9 +60,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the Harmony config flow.""" self.harmony_config: dict[str, Any] = {} - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: validated = await validate_input(user_input) @@ -116,9 +118,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.harmony_config[UNIQUE_ID] = unique_id return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Harmony.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: # Everything was validated in async_step_ssdp @@ -145,7 +149,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) - async def _async_create_entry_from_valid_input(self, validated, user_input): + async def _async_create_entry_from_valid_input( + self, validated: dict[str, Any], user_input: dict[str, Any] + ) -> FlowResult: """Single path to create the config entry from validated input.""" data = { @@ -159,8 +165,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=validated[CONF_NAME], data=data) -def _options_from_user_input(user_input): - options = {} +def _options_from_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + options: dict[str, Any] = {} if ATTR_ACTIVITY in user_input: options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY] if ATTR_DELAY_SECS in user_input: @@ -175,7 +181,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index a1b11189a04..44c0fde19c1 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -45,7 +45,7 @@ class HarmonyData(HarmonySubscriberMixin): ] @property - def activity_names(self): + def activity_names(self) -> list[str]: """Names of all the remotes activities.""" activity_infos = self.activities activities = [activity["label"] for activity in activity_infos] @@ -61,7 +61,7 @@ class HarmonyData(HarmonySubscriberMixin): return devices @property - def name(self): + def name(self) -> str: """Return the Harmony device's name.""" return self._name @@ -138,7 +138,7 @@ class HarmonyData(HarmonySubscriberMixin): f"{self._name}: Unable to connect to HUB at: {self._address}:8088" ) - async def shutdown(self): + async def shutdown(self) -> None: """Close connection on shutdown.""" _LOGGER.debug("%s: Closing Harmony Hub", self._name) try: @@ -146,7 +146,7 @@ class HarmonyData(HarmonySubscriberMixin): except aioexc.TimeOut: _LOGGER.warning("%s: Disconnect timed-out", self._name) - async def async_start_activity(self, activity: str): + async def async_start_activity(self, activity: str) -> None: """Start an activity from the Harmony device.""" if not activity: @@ -189,7 +189,7 @@ class HarmonyData(HarmonySubscriberMixin): _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) self.async_unlock_start_activity() - async def async_power_off(self): + async def async_power_off(self) -> None: """Start the PowerOff activity.""" _LOGGER.debug("%s: Turn Off", self.name) try: @@ -204,7 +204,7 @@ class HarmonyData(HarmonySubscriberMixin): num_repeats: int, delay_secs: float, hold_secs: float, - ): + ) -> None: """Send a list of commands to one device.""" device_id = None if device.isdigit(): @@ -259,7 +259,7 @@ class HarmonyData(HarmonySubscriberMixin): result.msg, ) - async def change_channel(self, channel: int): + async def change_channel(self, channel: int) -> None: """Change the channel using Harmony remote.""" _LOGGER.debug("%s: Changing channel to %s", self.name, channel) try: diff --git a/homeassistant/components/harmony/entity.py b/homeassistant/components/harmony/entity.py index 24c72a771e7..b1b1599a16c 100644 --- a/homeassistant/components/harmony/entity.py +++ b/homeassistant/components/harmony/entity.py @@ -1,4 +1,8 @@ """Base class Harmony entities.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime import logging from homeassistant.helpers.entity import Entity @@ -17,7 +21,7 @@ class HarmonyEntity(Entity): def __init__(self, data: HarmonyData) -> None: """Initialize the Harmony base entity.""" super().__init__() - self._unsub_mark_disconnected = None + self._unsub_mark_disconnected: Callable[[], None] | None = None self._name = data.name self._data = data self._attr_should_poll = False @@ -27,14 +31,14 @@ class HarmonyEntity(Entity): """Return True if we're connected to the Hub, otherwise False.""" return self._data.available - async def async_got_connected(self, _=None): + async def async_got_connected(self, _: str | None = None) -> None: """Notification that we're connected to the HUB.""" _LOGGER.debug("%s: connected to the HUB", self._name) self.async_write_ha_state() self._clear_disconnection_delay() - async def async_got_disconnected(self, _=None): + async def async_got_disconnected(self, _: str | None = None) -> None: """Notification that we're disconnected from the HUB.""" _LOGGER.debug("%s: disconnected from the HUB", self._name) # We're going to wait for 10 seconds before announcing we're @@ -43,12 +47,12 @@ class HarmonyEntity(Entity): self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable ) - def _clear_disconnection_delay(self): + def _clear_disconnection_delay(self) -> None: if self._unsub_mark_disconnected: self._unsub_mark_disconnected() self._unsub_mark_disconnected = None - def _mark_disconnected_if_unavailable(self, _): + def _mark_disconnected_if_unavailable(self, _: datetime) -> None: self._unsub_mark_disconnected = None if not self.available: # Still disconnected. Let the state engine know. diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index c1e85c86787..863c3fe5c56 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -1,4 +1,6 @@ """Support for Harmony Hub devices.""" +from __future__ import annotations + from collections.abc import Iterable import json import logging @@ -36,6 +38,7 @@ from .const import ( SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, ) +from .data import HarmonyData from .entity import HarmonyEntity from .subscriber import HarmonyCallback @@ -56,12 +59,12 @@ async def async_setup_entry( ) -> None: """Set up the Harmony config entry.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] _LOGGER.debug("HarmonyData : %s", data) - default_activity = entry.options.get(ATTR_ACTIVITY) - delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + default_activity: str | None = entry.options.get(ATTR_ACTIVITY) + delay_secs: float = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") device = HarmonyRemote(data, default_activity, delay_secs, harmony_conf_file) @@ -84,10 +87,12 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): _attr_supported_features = RemoteEntityFeature.ACTIVITY - def __init__(self, data, activity, delay_secs, out_path): + def __init__( + self, data: HarmonyData, activity: str | None, delay_secs: float, out_path: str + ) -> None: """Initialize HarmonyRemote class.""" super().__init__(data=data) - self._state = None + self._state: bool | None = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None @@ -99,7 +104,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): self._attr_device_info = self._data.device_info(DOMAIN) self._attr_name = data.name - async def _async_update_options(self, data): + async def _async_update_options(self, data: dict[str, Any]) -> None: """Change options when the options flow does.""" if ATTR_DELAY_SECS in data: self.delay_secs = data[ATTR_DELAY_SECS] @@ -170,7 +175,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): return self._data.activity_names @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Add platform specific attributes.""" return { ATTR_ACTIVITY_STARTING: self._activity_starting, @@ -179,7 +184,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): } @property - def is_on(self): + def is_on(self) -> bool: """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, "PowerOff"] @@ -201,7 +206,7 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): self._state = bool(activity_id != -1) self.async_write_ha_state() - async def async_new_config(self, _=None): + async def async_new_config(self, _: dict | None = None) -> None: """Call for updating the current activity.""" _LOGGER.debug("%s: configuration has been updated", self.name) self.async_new_activity(self._data.current_activity) @@ -242,16 +247,16 @@ class HarmonyRemote(HarmonyEntity, RemoteEntity, RestoreEntity): command, device, num_repeats, delay_secs, hold_secs ) - async def change_channel(self, channel): + async def change_channel(self, channel: int) -> None: """Change the channel using Harmony remote.""" await self._data.change_channel(channel) - async def sync(self): + async def sync(self) -> None: """Sync the Harmony device with the web service.""" if await self._data.sync(): await self.hass.async_add_executor_job(self.write_config_file) - def write_config_file(self): + def write_config_file(self) -> None: """Write Harmony configuration file. This is a handy way for users to figure out the available commands for automations. diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py index 0ed3f0ca275..e98a15c788f 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -23,7 +23,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activities select.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] _LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME]) async_add_entities( [HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)] @@ -85,5 +85,5 @@ class HarmonyActivitySelect(HarmonyEntity, SelectEntity): ) @callback - def _async_activity_update(self, activity_info: tuple): + def _async_activity_update(self, activity_info: tuple) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py index 4804253151f..8a47e437e17 100644 --- a/homeassistant/components/harmony/subscriber.py +++ b/homeassistant/components/harmony/subscriber.py @@ -34,7 +34,7 @@ class HarmonySubscriberMixin: self._subscriptions: list[HarmonyCallback] = [] self._activity_lock = asyncio.Lock() - async def async_lock_start_activity(self): + async def async_lock_start_activity(self) -> None: """Acquire the lock.""" await self._activity_lock.acquire() @@ -59,17 +59,17 @@ class HarmonySubscriberMixin: """Remove a callback subscriber.""" self._subscriptions.remove(update_callback) - def _config_updated(self, _=None) -> None: + def _config_updated(self, _: dict | None = None) -> None: _LOGGER.debug("config_updated") self._call_callbacks("config_updated") - def _connected(self, _=None) -> None: + def _connected(self, _: str | None = None) -> None: _LOGGER.debug("connected") self.async_unlock_start_activity() self._available = True self._call_callbacks("connected") - def _disconnected(self, _=None) -> None: + def _disconnected(self, _: str | None = None) -> None: _LOGGER.debug("disconnected") self.async_unlock_start_activity() self._available = False diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index 2d072f11f2c..c5bba39eb95 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,7 +23,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] + data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities switches = [] @@ -49,7 +49,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self._attr_device_info = self._data.device_info(DOMAIN) @property - def is_on(self): + def is_on(self) -> bool: """Return if the current activity is the one for this switch.""" _, activity_name = self._data.current_activity return activity_name == self._activity_name @@ -111,5 +111,5 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): ) @callback - def _async_activity_update(self, activity_info: tuple): + def _async_activity_update(self, activity_info: tuple) -> None: self.async_write_ha_state() diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py index 3f126f22f3c..0bfee32b414 100644 --- a/homeassistant/components/harmony/util.py +++ b/homeassistant/components/harmony/util.py @@ -25,7 +25,7 @@ def find_best_name_for_remote(data: dict, harmony: HarmonyAPI): return data[CONF_NAME] -async def get_harmony_client_if_available(ip_address: str): +async def get_harmony_client_if_available(ip_address: str) -> HarmonyAPI | None: """Connect to a harmony hub and fetch info.""" harmony = HarmonyAPI(ip_address=ip_address) From 810c6ea5aeeeddeb73ad74a5aa99efc936d8113f Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 7 Jan 2024 13:21:27 -0800 Subject: [PATCH 0355/1544] Google Generative AI: Add a service for prompts consisting of text and images using Gemini Pro Vision (#105789) * Bump google-generativeai to 0.3.1 * Migrate to the new API and default to gemini-pro * Add max output tokens option * Add generate_content service * Add tests * additional checks * async read_bytes * Add tests for all errors --- .../__init__.py | 148 +++++++++++--- .../config_flow.py | 14 +- .../const.py | 10 +- .../services.yaml | 11 ++ .../strings.json | 21 +- .../snapshots/test_init.ambr | 132 ++++++++++--- .../test_config_flow.py | 7 +- .../test_init.py | 181 +++++++++++++++++- 8 files changed, 450 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/services.yaml diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c507e0c046d..a522eeab5cd 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,44 +3,122 @@ from __future__ import annotations from functools import partial import logging +import mimetypes +from pathlib import Path from typing import Literal from google.api_core.exceptions import ClientError -import google.generativeai as palm -from google.generativeai.types.discuss_types import ChatResponse +import google.generativeai as genai +import google.generativeai.types as genai_types +import voluptuous as vol from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, MATCH_ALL -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import intent, template +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + TemplateError, +) +from homeassistant.helpers import config_validation as cv, intent, template +from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid from .const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, DEFAULT_TOP_P, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) +SERVICE_GENERATE_CONTENT = "generate_content" +CONF_IMAGE_FILENAME = "image_filename" + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Google Generative AI Conversation.""" + + async def generate_content(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + prompt_parts = [call.data[CONF_PROMPT]] + image_filenames = call.data[CONF_IMAGE_FILENAME] + for image_filename in image_filenames: + if not hass.config.is_allowed_path(image_filename): + raise HomeAssistantError( + f"Cannot read `{image_filename}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + ) + if not Path(image_filename).exists(): + raise HomeAssistantError(f"`{image_filename}` does not exist") + mime_type, _ = mimetypes.guess_type(image_filename) + if mime_type is None or not mime_type.startswith("image"): + raise HomeAssistantError(f"`{image_filename}` is not an image") + prompt_parts.append( + { + "mime_type": mime_type, + "data": await hass.async_add_executor_job( + Path(image_filename).read_bytes + ), + } + ) + + model_name = "gemini-pro-vision" if image_filenames else "gemini-pro" + model = genai.GenerativeModel(model_name=model_name) + + try: + response = await model.generate_content_async(prompt_parts) + except ( + ClientError, + ValueError, + genai_types.BlockedPromptException, + genai_types.StopCandidateException, + ) as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + + return {"text": response.text} + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_CONTENT, + generate_content, + schema=vol.Schema( + { + vol.Required(CONF_PROMPT): cv.string, + vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Generative AI Conversation from a config entry.""" - palm.configure(api_key=entry.data[CONF_API_KEY]) + genai.configure(api_key=entry.data[CONF_API_KEY]) try: await hass.async_add_executor_job( partial( - palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + genai.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) ) ) except ClientError as err: @@ -55,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload GoogleGenerativeAI.""" - palm.configure(api_key=None) + genai.configure(api_key=None) conversation.async_unset_agent(hass, entry) return True @@ -67,7 +145,7 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, list[dict]] = {} + self.history: dict[str, list[genai_types.ContentType]] = {} @property def supported_languages(self) -> list[str] | Literal["*"]: @@ -79,17 +157,27 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) - top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) - top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K) + model = genai.GenerativeModel( + model_name=self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL), + generation_config={ + "temperature": self.entry.options.get( + CONF_TEMPERATURE, DEFAULT_TEMPERATURE + ), + "top_p": self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P), + "top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K), + "max_output_tokens": self.entry.options.get( + CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS + ), + }, + ) + _LOGGER.debug("Model: %s", model) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() - messages = [] + messages = [{}, {}] try: prompt = self._async_generate_prompt(raw_prompt) @@ -104,20 +192,21 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): response=intent_response, conversation_id=conversation_id ) - messages.append({"author": "0", "content": user_input.text}) + messages[0] = {"role": "user", "parts": prompt} + messages[1] = {"role": "model", "parts": "Ok"} - _LOGGER.debug("Prompt for %s: %s", model, messages) + _LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) + chat = model.start_chat(history=messages) try: - chat_response: ChatResponse = await palm.chat_async( - model=model, - context=prompt, - messages=messages, - temperature=temperature, - top_p=top_p, - top_k=top_k, - ) - except ClientError as err: + chat_response = await chat.send_message_async(user_input.text) + except ( + ClientError, + ValueError, + genai_types.BlockedPromptException, + genai_types.StopCandidateException, + ) as err: + _LOGGER.error("Error sending message: %s", err) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, @@ -127,14 +216,11 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): response=intent_response, conversation_id=conversation_id ) - _LOGGER.debug("Response %s", chat_response) - # For some queries the response is empty. In that case don't update history to avoid - # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating" - if chat_response.last: - self.history[conversation_id] = chat_response.messages + _LOGGER.debug("Response: %s", chat_response.parts) + self.history[conversation_id] = chat.history intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(chat_response.last) + intent_response.async_set_speech(chat_response.text) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index fea023c604e..74ba3c478df 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -8,7 +8,7 @@ from types import MappingProxyType from typing import Any from google.api_core.exceptions import ClientError -import google.generativeai as palm +import google.generativeai as genai import voluptuous as vol from homeassistant import config_entries @@ -23,11 +23,13 @@ from homeassistant.helpers.selector import ( from .const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_K, @@ -50,6 +52,7 @@ DEFAULT_OPTIONS = types.MappingProxyType( CONF_TEMPERATURE: DEFAULT_TEMPERATURE, CONF_TOP_P: DEFAULT_TOP_P, CONF_TOP_K: DEFAULT_TOP_K, + CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, } ) @@ -59,8 +62,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - palm.configure(api_key=data[CONF_API_KEY]) - await hass.async_add_executor_job(partial(palm.list_models)) + genai.configure(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(partial(genai.list_models)) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -162,4 +165,9 @@ def google_generative_ai_config_option_schema( description={"suggested_value": options[CONF_TOP_K]}, default=DEFAULT_TOP_K, ): int, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 9664552e436..2798b85f308 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -24,10 +24,12 @@ Answer the user's questions about the world truthfully. If the user wants to control a device, reject the request and suggest using the Home Assistant app. """ CONF_CHAT_MODEL = "chat_model" -DEFAULT_CHAT_MODEL = "models/chat-bison-001" +DEFAULT_CHAT_MODEL = "models/gemini-pro" CONF_TEMPERATURE = "temperature" -DEFAULT_TEMPERATURE = 0.25 +DEFAULT_TEMPERATURE = 0.9 CONF_TOP_P = "top_p" -DEFAULT_TOP_P = 0.95 +DEFAULT_TOP_P = 1.0 CONF_TOP_K = "top_k" -DEFAULT_TOP_K = 40 +DEFAULT_TOP_K = 1 +CONF_MAX_TOKENS = "max_tokens" +DEFAULT_MAX_TOKENS = 150 diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml new file mode 100644 index 00000000000..f35697b89f8 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/services.yaml @@ -0,0 +1,11 @@ +generate_content: + fields: + prompt: + required: true + selector: + text: + multiline: true + image_filename: + required: false + selector: + object: diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 2b1b41a2c28..76e6135b14d 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -21,7 +21,26 @@ "model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", - "top_k": "Top K" + "top_k": "Top K", + "max_tokens": "Maximum tokens to return in response" + } + } + } + }, + "services": { + "generate_content": { + "name": "Generate content", + "description": "Generate content from a prompt consisting of text and optionally images", + "fields": { + "prompt": { + "name": "Prompt", + "description": "The prompt", + "example": "Describe what you see in these images:" + }, + "image_filename": { + "name": "Image filename", + "description": "Images", + "example": "/config/www/image.jpg" } } } diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 636a46e42f5..5347c010f28 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,33 +1,109 @@ # serializer version: 1 # name: test_default_prompt - dict({ - 'context': ''' - This smart home is controlled by Home Assistant. - - An overview of the areas and the devices in this smart home: - - Test Area: - - Test Device (Test Model) - - Test Area 2: - - Test Device 2 - - Test Device 3 (Test Model 3A) - - Test Device 4 - - 1 (3) - - Answer the user's questions about the world truthfully. - - If the user wants to control a device, reject the request and suggest using the Home Assistant app. - ''', - 'messages': list([ + list([ + tuple( + '', + tuple( + ), dict({ - 'author': '0', - 'content': 'hello', + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 0.9, + 'top_k': 1, + 'top_p': 1.0, + }), + 'model_name': 'models/gemini-pro', }), - ]), - 'model': 'models/chat-bison-001', - 'temperature': 0.25, - 'top_k': 40, - 'top_p': 0.95, - }) + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- +# name: test_generate_content_service_with_image + list([ + tuple( + '', + tuple( + ), + dict({ + 'model_name': 'gemini-pro-vision', + }), + ), + tuple( + '().generate_content_async', + tuple( + list([ + 'Describe this image from my doorbell camera', + dict({ + 'data': b'image bytes', + 'mime_type': 'image/jpeg', + }), + ]), + ), + dict({ + }), + ), + ]) +# --- +# name: test_generate_content_service_without_images + list([ + tuple( + '', + tuple( + ), + dict({ + 'model_name': 'gemini-pro', + }), + ), + tuple( + '().generate_content_async', + tuple( + list([ + 'Write an opening speech for a Home Assistant release party', + ]), + ), + dict({ + }), + ), + ]) # --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 0b7072f4ef0..4a2478c5a7a 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -8,9 +8,11 @@ import pytest from homeassistant import config_entries from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, + CONF_MAX_TOKENS, CONF_TOP_K, CONF_TOP_P, DEFAULT_CHAT_MODEL, + DEFAULT_MAX_TOKENS, DEFAULT_TOP_K, DEFAULT_TOP_P, DOMAIN, @@ -37,7 +39,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", return_value=True, @@ -78,6 +80,7 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL assert options["data"][CONF_TOP_P] == DEFAULT_TOP_P assert options["data"][CONF_TOP_K] == DEFAULT_TOP_K + assert options["data"][CONF_MAX_TOKENS] == DEFAULT_MAX_TOKENS @pytest.mark.parametrize( @@ -104,7 +107,7 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: ) with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 982f3993e04..380d5e82638 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,11 +1,13 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from google.api_core.exceptions import ClientError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, device_registry as dr, intent from tests.common import MockConfigEntry @@ -91,20 +93,24 @@ async def test_default_prompt( model=3, suggested_area="Test Area 2", ) - with patch("google.generativeai.chat_async") as mock_chat: + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_model.return_value.start_chat.return_value = AsyncMock() result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_chat.mock_calls[0][2] == snapshot + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: """Test that the default prompt works.""" - with patch("google.generativeai.chat_async", side_effect=ClientError("")): + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + mock_chat.send_message_async.side_effect = ClientError("") result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) @@ -125,7 +131,7 @@ async def test_template_error( ) with patch( "google.generativeai.get_model", - ), patch("google.generativeai.chat_async"): + ), patch("google.generativeai.GenerativeModel"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -146,3 +152,168 @@ async def test_conversation_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" + + +async def test_generate_content_service_without_images( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + + "party for the latest version of Home Assistant!" + ) + + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_response = MagicMock() + mock_response.text = stubbed_generated_content + mock_model.return_value.generate_content_async = AsyncMock( + return_value=mock_response + ) + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + +async def test_generate_content_service_with_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service.""" + stubbed_generated_content = ( + "A mail carrier is at your front door delivering a package" + ) + + with patch("google.generativeai.GenerativeModel") as mock_model, patch( + "homeassistant.components.google_generative_ai_conversation.Path.read_bytes", + return_value=b"image bytes", + ), patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ): + mock_response = MagicMock() + mock_response.text = stubbed_generated_content + mock_model.return_value.generate_content_async = AsyncMock( + return_value=mock_response + ) + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles errors.""" + with patch("google.generativeai.GenerativeModel") as mock_model, pytest.raises( + HomeAssistantError, match="Error generating content: None reason" + ): + mock_model.return_value.generate_content_async = AsyncMock( + side_effect=ClientError("reason") + ) + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_image_not_allowed_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with an image in a not allowed path.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=False + ), pytest.raises( + HomeAssistantError, + match="Cannot read `doorbell_snapshot.jpg`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_image_not_exists( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with an image that does not exist.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ), patch("pathlib.Path.exists", return_value=False), pytest.raises( + HomeAssistantError, match="`doorbell_snapshot.jpg` does not exist" + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +async def test_generate_content_service_with_non_image( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test generate content service with a non image.""" + with patch("pathlib.Path.exists", return_value=True), patch.object( + hass.config, "is_allowed_path", return_value=True + ), patch("pathlib.Path.exists", return_value=True), pytest.raises( + HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + { + "prompt": "Describe this image from my doorbell camera", + "image_filename": "doorbell_snapshot.mp4", + }, + blocking=True, + return_response=True, + ) From 426a1511d5fb1c9a387aedcd11c3ffd708fb2799 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 7 Jan 2024 23:14:38 +0100 Subject: [PATCH 0356/1544] Mark Ring battery and signal strength sensors as diagnostic (#107503) --- homeassistant/components/ring/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index a596d413ac7..0ed24f45cbd 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -10,7 +10,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -194,6 +198,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( category=["doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, cls=RingSensor, ), RingSensorEntityDescription( @@ -234,6 +239,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( translation_key="wifi_signal_category", category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, cls=HealthDataRingSensor, ), RingSensorEntityDescription( @@ -243,6 +249,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, icon="mdi:wifi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, cls=HealthDataRingSensor, ), ) From f53109f5135a87900e86e0e547c948ce09bc53a9 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 7 Jan 2024 23:26:46 +0100 Subject: [PATCH 0357/1544] Move KNX service registration to `async_setup` (#106635) --- homeassistant/components/knx/__init__.py | 248 +------------------- homeassistant/components/knx/const.py | 9 + homeassistant/components/knx/services.py | 284 +++++++++++++++++++++++ tests/components/knx/test_services.py | 16 ++ 4 files changed, 320 insertions(+), 237 deletions(-) create mode 100644 homeassistant/components/knx/services.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 3444e9b002a..c6869f34eeb 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -5,23 +5,17 @@ import asyncio import contextlib import logging from pathlib import Path -from typing import Final import voluptuous as vol from xknx import XKNX from xknx.core import XknxConnectionState from xknx.core.telegram_queue import TelegramQueue -from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.dpt import DPTBase from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException from xknx.io import ConnectionConfig, ConnectionType, SecureConfig from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import ( - DeviceGroupAddress, - GroupAddress, - InternalGroupAddress, - parse_device_group_address, -) -from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite +from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,15 +24,13 @@ from homeassistant.const import ( CONF_PORT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, - SERVICE_RELOAD, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config -from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType @@ -95,24 +87,14 @@ from .schema import ( TextSchema, TimeSchema, WeatherSchema, - ga_validator, - sensor_type_validator, ) +from .services import register_knx_services from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel _LOGGER = logging.getLogger(__name__) -SERVICE_KNX_SEND: Final = "send" -SERVICE_KNX_ATTR_PAYLOAD: Final = "payload" -SERVICE_KNX_ATTR_TYPE: Final = "type" -SERVICE_KNX_ATTR_RESPONSE: Final = "response" -SERVICE_KNX_ATTR_REMOVE: Final = "remove" -SERVICE_KNX_EVENT_REGISTER: Final = "event_register" -SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" -SERVICE_KNX_READ: Final = "read" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -158,69 +140,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SERVICE_KNX_SEND_SCHEMA = vol.Any( - vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, - vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator, - vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, - } - ), - vol.Schema( - # without type given payload is treated as raw bytes - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( - cv.positive_int, [cv.positive_int] - ), - vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, - } - ), -) - -SERVICE_KNX_READ_SCHEMA = vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ) - } -) - -SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( - { - vol.Required(KNX_ADDRESS): vol.All( - cv.ensure_list, - [ga_validator], - ), - vol.Optional(CONF_TYPE): sensor_type_validator, - vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, - } -) - -SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( - ExposeSchema.EXPOSE_SENSOR_SCHEMA.extend( - { - vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, - } - ), - vol.Schema( - # for removing only `address` is required - { - vol.Required(KNX_ADDRESS): ga_validator, - vol.Required(SERVICE_KNX_ATTR_REMOVE): vol.All(cv.boolean, True), - }, - extra=vol.ALLOW_EXTRA, - ), -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start the KNX integration.""" @@ -235,6 +154,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + register_knx_services(hass) + return True @@ -287,43 +208,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.services.async_register( - DOMAIN, - SERVICE_KNX_SEND, - knx_module.service_send_to_knx_bus, - schema=SERVICE_KNX_SEND_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_KNX_READ, - knx_module.service_read_to_knx_bus, - schema=SERVICE_KNX_READ_SCHEMA, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_KNX_EVENT_REGISTER, - knx_module.service_event_register_modify, - schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_KNX_EXPOSURE_REGISTER, - knx_module.service_exposure_register_modify, - schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, - ) - - async def _reload_integration(call: ServiceCall) -> None: - """Reload the integration.""" - await hass.config_entries.async_reload(entry.entry_id) - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) - - async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_integration) - await register_panel(hass) return True @@ -419,10 +303,8 @@ class KNXModule: ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self._knx_event_callback: TelegramQueue.Callback = ( - self.register_event_callback() - ) + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() self.entry.async_on_unload( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) @@ -555,7 +437,7 @@ class KNXModule: ): data = telegram.payload.value.value if transcoder := ( - self._group_address_transcoder.get(telegram.destination_address) + self.group_address_transcoder.get(telegram.destination_address) or next( ( _transcoder @@ -612,111 +494,3 @@ class KNXModule: group_addresses=[], match_for_outgoing=True, ) - - async def service_event_register_modify(self, call: ServiceCall) -> None: - """Service for adding or removing a GroupAddress to the knx_event filter.""" - attr_address = call.data[KNX_ADDRESS] - group_addresses = list(map(parse_device_group_address, attr_address)) - - if call.data.get(SERVICE_KNX_ATTR_REMOVE): - for group_address in group_addresses: - try: - self._knx_event_callback.group_addresses.remove(group_address) - except ValueError: - _LOGGER.warning( - "Service event_register could not remove event for '%s'", - str(group_address), - ) - if group_address in self._group_address_transcoder: - del self._group_address_transcoder[group_address] - return - - if (dpt := call.data.get(CONF_TYPE)) and ( - transcoder := DPTBase.parse_transcoder(dpt) - ): - self._group_address_transcoder.update( - { - _address: transcoder # type: ignore[type-abstract] - for _address in group_addresses - } - ) - for group_address in group_addresses: - if group_address in self._knx_event_callback.group_addresses: - continue - self._knx_event_callback.group_addresses.append(group_address) - _LOGGER.debug( - "Service event_register registered event for '%s'", - str(group_address), - ) - - async def service_exposure_register_modify(self, call: ServiceCall) -> None: - """Service for adding or removing an exposure to KNX bus.""" - group_address = call.data[KNX_ADDRESS] - - if call.data.get(SERVICE_KNX_ATTR_REMOVE): - try: - removed_exposure = self.service_exposures.pop(group_address) - except KeyError as err: - raise HomeAssistantError( - f"Could not find exposure for '{group_address}' to remove." - ) from err - - removed_exposure.shutdown() - return - - if group_address in self.service_exposures: - replaced_exposure = self.service_exposures.pop(group_address) - _LOGGER.warning( - ( - "Service exposure_register replacing already registered exposure" - " for '%s' - %s" - ), - group_address, - replaced_exposure.device.name, - ) - replaced_exposure.shutdown() - exposure = create_knx_exposure(self.hass, self.xknx, call.data) - self.service_exposures[group_address] = exposure - _LOGGER.debug( - "Service exposure_register registered exposure for '%s' - %s", - group_address, - exposure.device.name, - ) - - async def service_send_to_knx_bus(self, call: ServiceCall) -> None: - """Service for sending an arbitrary KNX message to the KNX bus.""" - attr_address = call.data[KNX_ADDRESS] - attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] - attr_type = call.data.get(SERVICE_KNX_ATTR_TYPE) - attr_response = call.data[SERVICE_KNX_ATTR_RESPONSE] - - payload: DPTBinary | DPTArray - if attr_type is not None: - transcoder = DPTBase.parse_transcoder(attr_type) - if transcoder is None: - raise ValueError(f"Invalid type for knx.send service: {attr_type}") - payload = transcoder.to_knx(attr_payload) - elif isinstance(attr_payload, int): - payload = DPTBinary(attr_payload) - else: - payload = DPTArray(attr_payload) - - for address in attr_address: - telegram = Telegram( - destination_address=parse_device_group_address(address), - payload=GroupValueResponse(payload) - if attr_response - else GroupValueWrite(payload), - source_address=self.xknx.current_address, - ) - await self.xknx.telegrams.put(telegram) - - async def service_read_to_knx_bus(self, call: ServiceCall) -> None: - """Service for sending a GroupValueRead telegram to the KNX bus.""" - for address in call.data[KNX_ADDRESS]: - telegram = Telegram( - destination_address=parse_device_group_address(address), - payload=GroupValueRead(), - source_address=self.xknx.current_address, - ) - await self.xknx.telegrams.put(telegram) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index aa48bcdf557..8cb1986c540 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -88,6 +88,15 @@ SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict" AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] MessageCallbackType = Callable[[Telegram], None] +SERVICE_KNX_SEND: Final = "send" +SERVICE_KNX_ATTR_PAYLOAD: Final = "payload" +SERVICE_KNX_ATTR_TYPE: Final = "type" +SERVICE_KNX_ATTR_RESPONSE: Final = "response" +SERVICE_KNX_ATTR_REMOVE: Final = "remove" +SERVICE_KNX_EVENT_REGISTER: Final = "event_register" +SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" +SERVICE_KNX_READ: Final = "read" + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py new file mode 100644 index 00000000000..99c44a5eee6 --- /dev/null +++ b/homeassistant/components/knx/services.py @@ -0,0 +1,284 @@ +"""KNX integration services.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import TYPE_CHECKING + +import voluptuous as vol +from xknx.dpt import DPTArray, DPTBase, DPTBinary +from xknx.telegram import Telegram +from xknx.telegram.address import parse_device_group_address +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite + +from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service import async_register_admin_service + +from .const import ( + DOMAIN, + KNX_ADDRESS, + SERVICE_KNX_ATTR_PAYLOAD, + SERVICE_KNX_ATTR_REMOVE, + SERVICE_KNX_ATTR_RESPONSE, + SERVICE_KNX_ATTR_TYPE, + SERVICE_KNX_EVENT_REGISTER, + SERVICE_KNX_EXPOSURE_REGISTER, + SERVICE_KNX_READ, + SERVICE_KNX_SEND, +) +from .expose import create_knx_exposure +from .schema import ExposeSchema, ga_validator, sensor_type_validator + +if TYPE_CHECKING: + from . import KNXModule + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_knx_services(hass: HomeAssistant) -> None: + """Register KNX integration services.""" + hass.services.async_register( + DOMAIN, + SERVICE_KNX_SEND, + partial(service_send_to_knx_bus, hass), + schema=SERVICE_KNX_SEND_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_KNX_READ, + partial(service_read_to_knx_bus, hass), + schema=SERVICE_KNX_READ_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_KNX_EVENT_REGISTER, + partial(service_event_register_modify, hass), + schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_KNX_EXPOSURE_REGISTER, + partial(service_exposure_register_modify, hass), + schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + partial(service_reload_integration, hass), + ) + + +@callback +def get_knx_module(hass: HomeAssistant) -> KNXModule: + """Return KNXModule instance.""" + try: + return hass.data[DOMAIN] # type: ignore[no-any-return] + except KeyError as err: + raise HomeAssistantError("KNX entry not loaded") from err + + +SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Optional(CONF_TYPE): sensor_type_validator, + vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, + } +) + + +async def service_event_register_modify(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for adding or removing a GroupAddress to the knx_event filter.""" + knx_module = get_knx_module(hass) + + attr_address = call.data[KNX_ADDRESS] + group_addresses = list(map(parse_device_group_address, attr_address)) + + if call.data.get(SERVICE_KNX_ATTR_REMOVE): + for group_address in group_addresses: + try: + knx_module.knx_event_callback.group_addresses.remove(group_address) + except ValueError: + _LOGGER.warning( + "Service event_register could not remove event for '%s'", + str(group_address), + ) + if group_address in knx_module.group_address_transcoder: + del knx_module.group_address_transcoder[group_address] + return + + if (dpt := call.data.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + knx_module.group_address_transcoder.update( + { + _address: transcoder # type: ignore[type-abstract] + for _address in group_addresses + } + ) + for group_address in group_addresses: + if group_address in knx_module.knx_event_callback.group_addresses: + continue + knx_module.knx_event_callback.group_addresses.append(group_address) + _LOGGER.debug( + "Service event_register registered event for '%s'", + str(group_address), + ) + + +SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA = vol.Any( + ExposeSchema.EXPOSE_SENSOR_SCHEMA.extend( + { + vol.Optional(SERVICE_KNX_ATTR_REMOVE, default=False): cv.boolean, + } + ), + vol.Schema( + # for removing only `address` is required + { + vol.Required(KNX_ADDRESS): ga_validator, + vol.Required(SERVICE_KNX_ATTR_REMOVE): vol.All(cv.boolean, True), + }, + extra=vol.ALLOW_EXTRA, + ), +) + + +async def service_exposure_register_modify( + hass: HomeAssistant, call: ServiceCall +) -> None: + """Service for adding or removing an exposure to KNX bus.""" + knx_module = get_knx_module(hass) + + group_address = call.data[KNX_ADDRESS] + + if call.data.get(SERVICE_KNX_ATTR_REMOVE): + try: + removed_exposure = knx_module.service_exposures.pop(group_address) + except KeyError as err: + raise ServiceValidationError( + f"Could not find exposure for '{group_address}' to remove." + ) from err + + removed_exposure.shutdown() + return + + if group_address in knx_module.service_exposures: + replaced_exposure = knx_module.service_exposures.pop(group_address) + _LOGGER.warning( + ( + "Service exposure_register replacing already registered exposure" + " for '%s' - %s" + ), + group_address, + replaced_exposure.device.name, + ) + replaced_exposure.shutdown() + exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data) + knx_module.service_exposures[group_address] = exposure + _LOGGER.debug( + "Service exposure_register registered exposure for '%s' - %s", + group_address, + exposure.device.name, + ) + + +SERVICE_KNX_SEND_SCHEMA = vol.Any( + vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): cv.match_all, + vol.Required(SERVICE_KNX_ATTR_TYPE): sensor_type_validator, + vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, + } + ), + vol.Schema( + # without type given payload is treated as raw bytes + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ), + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(SERVICE_KNX_ATTR_RESPONSE, default=False): cv.boolean, + } + ), +) + + +async def service_send_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for sending an arbitrary KNX message to the KNX bus.""" + knx_module = get_knx_module(hass) + + attr_address = call.data[KNX_ADDRESS] + attr_payload = call.data[SERVICE_KNX_ATTR_PAYLOAD] + attr_type = call.data.get(SERVICE_KNX_ATTR_TYPE) + attr_response = call.data[SERVICE_KNX_ATTR_RESPONSE] + + payload: DPTBinary | DPTArray + if attr_type is not None: + transcoder = DPTBase.parse_transcoder(attr_type) + if transcoder is None: + raise ValueError(f"Invalid type for knx.send service: {attr_type}") + payload = transcoder.to_knx(attr_payload) + elif isinstance(attr_payload, int): + payload = DPTBinary(attr_payload) + else: + payload = DPTArray(attr_payload) + + for address in attr_address: + telegram = Telegram( + destination_address=parse_device_group_address(address), + payload=GroupValueResponse(payload) + if attr_response + else GroupValueWrite(payload), + source_address=knx_module.xknx.current_address, + ) + await knx_module.xknx.telegrams.put(telegram) + + +SERVICE_KNX_READ_SCHEMA = vol.Schema( + { + vol.Required(KNX_ADDRESS): vol.All( + cv.ensure_list, + [ga_validator], + ) + } +) + + +async def service_read_to_knx_bus(hass: HomeAssistant, call: ServiceCall) -> None: + """Service for sending a GroupValueRead telegram to the KNX bus.""" + knx_module = get_knx_module(hass) + + for address in call.data[KNX_ADDRESS]: + telegram = Telegram( + destination_address=parse_device_group_address(address), + payload=GroupValueRead(), + source_address=knx_module.xknx.current_address, + ) + await knx_module.xknx.telegrams.put(telegram) + + +async def service_reload_integration(hass: HomeAssistant, call: ServiceCall) -> None: + """Reload the integration.""" + knx_module = get_knx_module(hass) + await hass.config_entries.async_reload(knx_module.entry.entry_id) + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index 5796eae8393..30b297218cc 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -7,6 +7,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.components.knx import async_unload_entry as knx_async_unload_entry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import KNXTestKit @@ -274,3 +275,18 @@ async def test_reload_service( ) mock_unload_entry.assert_called_once() mock_setup_entry.assert_called_once() + + +async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test service setup failed.""" + await knx.setup_integration({}) + await knx.mock_config_entry.async_unload(hass) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + "knx", + "send", + {"address": "1/2/3", "payload": True, "response": False}, + blocking=True, + ) + assert str(exc_info.value) == "KNX entry not loaded" From 368feec712ee528cfbd95c6e08fb800468834575 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:29:27 -1000 Subject: [PATCH 0358/1544] Refactor translations to reduce dict lookups (#107425) * Refactor translations to reduce dict lookups All of our cache lookups used: `cache[language][O(component)][category]` The cache was designed as `cache[language][component][category][flatted_key]` The lookups are now `cache[language][category][O(component)]` The cache is now stored as `cache[language][category][component][flatted_key]` This allows the catch fetch to avoid looking up the category each loop * already a set, and we do not mutate --- homeassistant/helpers/translation.py | 37 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 4e13707257b..873d54e7165 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -197,23 +197,25 @@ class _TranslationCache: """Initialize the cache.""" self.hass = hass self.loaded: dict[str, set[str]] = {} - self.cache: dict[str, dict[str, dict[str, Any]]] = {} + self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {} async def async_fetch( self, language: str, category: str, components: set[str], - ) -> list[dict[str, dict[str, Any]]]: + ) -> dict[str, str]: """Load resources into the cache.""" components_to_load = components - self.loaded.setdefault(language, set()) if components_to_load: await self._async_load(language, components_to_load) - cached = self.cache.get(language, {}) - - return [cached.get(component, {}).get(category, {}) for component in components] + result: dict[str, str] = {} + category_cache = self.cache.get(language, {}).get(category, {}) + for component in components.intersection(category_cache): + result.update(category_cache[component]) + return result async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" @@ -289,6 +291,7 @@ class _TranslationCache: """Extract resources into the cache.""" resource: dict[str, Any] | str cached = self.cache.setdefault(language, {}) + categories: set[str] = set() for resource in translation_strings.values(): categories.update(resource) @@ -305,10 +308,10 @@ class _TranslationCache: translation_strings, components, category ) + category_cache = cached.setdefault(category, {}) + for component, resource in new_resources.items(): - category_cache: dict[str, Any] = cached.setdefault( - component, {} - ).setdefault(category, {}) + component_cache = category_cache.setdefault(component, {}) if isinstance(resource, dict): resources_flatten = recursive_flatten( @@ -316,11 +319,11 @@ class _TranslationCache: resource, ) resources_flatten = self._validate_placeholders( - language, resources_flatten, category_cache + language, resources_flatten, component_cache ) - category_cache.update(resources_flatten) + component_cache.update(resources_flatten) else: - category_cache[f"component.{component}.{category}"] = resource + component_cache[f"component.{component}.{category}"] = resource @bind_hass @@ -330,7 +333,7 @@ async def async_get_translations( category: str, integrations: Iterable[str] | None = None, config_flow: bool | None = None, -) -> dict[str, Any]: +) -> dict[str, str]: """Return all backend translations. If integration specified, load it for that one. @@ -344,7 +347,7 @@ async def async_get_translations( elif config_flow: components = (await async_get_config_flows(hass)) - hass.config.components elif category in ("state", "entity_component", "services"): - components = set(hass.config.components) + components = hass.config.components else: # Only 'state' supports merging, so remove platforms from selection components = { @@ -353,12 +356,8 @@ async def async_get_translations( async with lock: if TRANSLATION_FLATTEN_CACHE in hass.data: - cache = hass.data[TRANSLATION_FLATTEN_CACHE] + cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE] else: cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass) - cached = await cache.async_fetch(language, category, components) - result = {} - for entry in cached: - result.update(entry) - return result + return await cache.async_fetch(language, category, components) From d8c6534aff2ac1acc8787ef855fc56b89a5fca45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:31:40 -1000 Subject: [PATCH 0359/1544] Refactor recorder for common event case (#106753) Almost 99% of items that are put into the recorder queue are Events. Avoid wrapping them in tasks since we have to unwrap them right away and its must faster to check for both RecorderTask and Events since events are the common case. --- homeassistant/components/recorder/core.py | 67 +++++++++++++--------- homeassistant/components/recorder/tasks.py | 14 ----- tests/components/recorder/test_migrate.py | 2 +- 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a8746a0a807..ad05cad3d54 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,7 +11,7 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select @@ -104,7 +104,6 @@ from .tasks import ( EntityIDPostMigrationTask, EventIdMigrationTask, EventsContextIDMigrationTask, - EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, @@ -189,7 +188,7 @@ class Recorder(threading.Thread): self.keep_days = keep_days self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval - self._queue: queue.SimpleQueue[RecorderTask] = queue.SimpleQueue() + self._queue: queue.SimpleQueue[RecorderTask | Event] = queue.SimpleQueue() self.db_url = uri self.db_max_retries = db_max_retries self.db_retry_wait = db_retry_wait @@ -278,7 +277,7 @@ class Recorder(threading.Thread): raise RuntimeError("The database connection has not been established") return self._get_session() - def queue_task(self, task: RecorderTask) -> None: + def queue_task(self, task: RecorderTask | Event) -> None: """Add a task to the recorder queue.""" self._queue.put(task) @@ -306,7 +305,6 @@ class Recorder(threading.Thread): entity_filter = self.entity_filter exclude_event_types = self.exclude_event_types queue_put = self._queue.put_nowait - event_task = EventTask @callback def _event_listener(event: Event) -> None: @@ -315,23 +313,23 @@ class Recorder(threading.Thread): return if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: - queue_put(event_task(event)) + queue_put(event) return if isinstance(entity_id, str): if entity_filter(entity_id): - queue_put(event_task(event)) + queue_put(event) return if isinstance(entity_id, list): for eid in entity_id: if entity_filter(eid): - queue_put(event_task(event)) + queue_put(event) return return # Unknown what it is. - queue_put(event_task(event)) + queue_put(event) self._event_listener = self.hass.bus.async_listen( MATCH_ALL, @@ -857,31 +855,35 @@ class Recorder(threading.Thread): # with a commit every time the event time # has changed. This reduces the disk io. queue_ = self._queue - startup_tasks: list[RecorderTask] = [] - while not queue_.empty() and (task := queue_.get_nowait()): - startup_tasks.append(task) - self._pre_process_startup_tasks(startup_tasks) - for task in startup_tasks: - self._guarded_process_one_task_or_recover(task) + startup_task_or_events: list[RecorderTask | Event] = [] + while not queue_.empty() and (task_or_event := queue_.get_nowait()): + startup_task_or_events.append(task_or_event) + self._pre_process_startup_events(startup_task_or_events) + for task in startup_task_or_events: + self._guarded_process_one_task_or_event_or_recover(task) # Clear startup tasks since this thread runs forever # and we don't want to hold them in memory - del startup_tasks + del startup_task_or_events self.stop_requested = False while not self.stop_requested: - self._guarded_process_one_task_or_recover(queue_.get()) + self._guarded_process_one_task_or_event_or_recover(queue_.get()) - def _pre_process_startup_tasks(self, startup_tasks: list[RecorderTask]) -> None: - """Pre process startup tasks.""" + def _pre_process_startup_events( + self, startup_task_or_events: list[RecorderTask | Event] + ) -> None: + """Pre process startup events.""" # Prime all the state_attributes and event_data caches # before we start processing events state_change_events: list[Event] = [] non_state_change_events: list[Event] = [] - for task in startup_tasks: - if isinstance(task, EventTask): - event_ = task.event + for task_or_event in startup_task_or_events: + # Event is never subclassed so we can + # use a fast type check + if type(task_or_event) is Event: # noqa: E721 + event_ = task_or_event if event_.event_type == EVENT_STATE_CHANGED: state_change_events.append(event_) else: @@ -894,20 +896,31 @@ class Recorder(threading.Thread): self.states_meta_manager.load(state_change_events, session) self.state_attributes_manager.load(state_change_events, session) - def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: + def _guarded_process_one_task_or_event_or_recover( + self, task: RecorderTask | Event + ) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" _LOGGER.debug("Processing task: %s", task) try: - self._process_one_task_or_recover(task) + self._process_one_task_or_event_or_recover(task) except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error while processing event %s: %s", task, err) - def _process_one_task_or_recover(self, task: RecorderTask) -> None: - """Process an event, reconnect, or recover a malformed database.""" + def _process_one_task_or_event_or_recover(self, task: RecorderTask | Event) -> None: + """Process a task or event, reconnect, or recover a malformed database.""" try: + # Almost everything coming in via the queue + # is an Event so we can process it directly + # and since its never subclassed, we can + # use a fast type check + if type(task) is Event: # noqa: E721 + self._process_one_event(task) + return # If its not an event, commit everything # that is pending before running the task - if task.commit_before: + if TYPE_CHECKING: + assert isinstance(task, RecorderTask) + if not task.commit_before: self._commit_event_session_or_retry() return task.run(self) except exc.DatabaseError as err: diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 07be6202a0c..c062eb3915f 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -10,7 +10,6 @@ import logging import threading from typing import TYPE_CHECKING, Any -from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics @@ -268,19 +267,6 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass(slots=True) -class EventTask(RecorderTask): - """An event to be processed.""" - - event: Event - commit_before = False - - def run(self, instance: Recorder) -> None: - """Handle the task.""" - # pylint: disable-next=[protected-access] - instance._process_one_event(self.event) - - @dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index ede5bc32a6f..db4074a8fdb 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -399,7 +399,7 @@ async def test_schema_migrate( ), patch( "homeassistant.components.recorder.Recorder._process_non_state_changed_event_into_session", ), patch( - "homeassistant.components.recorder.Recorder._pre_process_startup_tasks", + "homeassistant.components.recorder.Recorder._pre_process_startup_events", ): recorder_helper.async_initialize_recorder(hass) hass.async_create_task( From 0b9992260aecff76d8231e07823931966ab6d92c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:35:28 -1000 Subject: [PATCH 0360/1544] Improve logbook context augment performance (#106926) Makes LazyEventPartialState a bit lazier since almost all the properties are never called. --- homeassistant/components/logbook/models.py | 46 +++++++++++-------- homeassistant/components/logbook/processor.py | 2 +- tests/components/logbook/test_models.py | 6 +++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 6939904f520..04a2458237f 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy.engine.row import Row @@ -20,6 +20,11 @@ import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + @dataclass(slots=True) class LogbookConfig: @@ -35,16 +40,6 @@ class LogbookConfig: class LazyEventPartialState: """A lazy version of core Event with limited State joined in.""" - __slots__ = [ - "row", - "_event_data", - "_event_data_cache", - "event_type", - "entity_id", - "state", - "data", - ] - def __init__( self, row: Row | EventAsRow, @@ -54,9 +49,6 @@ class LazyEventPartialState: self.row = row self._event_data: dict[str, Any] | None = None self._event_data_cache = event_data_cache - self.event_type: str | None = self.row.event_type - self.entity_id: str | None = self.row.entity_id - self.state = self.row.state # We need to explicitly check for the row is EventAsRow as the unhappy path # to fetch row.data for Row is very expensive if type(row) is EventAsRow: # noqa: E721 @@ -64,7 +56,10 @@ class LazyEventPartialState: # json decode process as we already have the data self.data = row.data return - source = cast(str, self.row.event_data) + if TYPE_CHECKING: + source = cast(str, row.event_data) + else: + source = row.event_data if not source: self.data = {} elif event_data := self._event_data_cache.get(source): @@ -74,17 +69,32 @@ class LazyEventPartialState: dict[str, Any], json_loads(source) ) - @property + @cached_property + def event_type(self) -> str | None: + """Return the event type.""" + return self.row.event_type + + @cached_property + def entity_id(self) -> str | None: + """Return the entity id.""" + return self.row.entity_id + + @cached_property + def state(self) -> str | None: + """Return the state.""" + return self.row.state + + @cached_property def context_id(self) -> str | None: """Return the context id.""" return bytes_to_ulid_or_none(self.row.context_id_bin) - @property + @cached_property def context_user_id(self) -> str | None: """Return the context user id.""" return bytes_to_uuid_hex_or_none(self.row.context_user_id_bin) - @property + @cached_property def context_parent_id(self) -> str | None: """Return the context parent id.""" return bytes_to_ulid_or_none(self.row.context_parent_id_bin) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 671f8f8f1c2..a36c887b599 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -425,7 +425,7 @@ class EventCache: def get(self, row: EventAsRow | Row) -> LazyEventPartialState: """Get the event from the row.""" - if isinstance(row, EventAsRow): + if type(row) is EventAsRow: # noqa: E721 - this is never subclassed return LazyEventPartialState(row, self._event_data_cache) if event := self.event_cache.get(row): return event diff --git a/tests/components/logbook/test_models.py b/tests/components/logbook/test_models.py index 6f3c6bfefcb..dcafd7e4765 100644 --- a/tests/components/logbook/test_models.py +++ b/tests/components/logbook/test_models.py @@ -12,9 +12,15 @@ def test_lazy_event_partial_state_context(): context_user_id_bin=b"1234123412341234", context_parent_id_bin=b"4444444444444444", event_data={}, + event_type="event_type", + entity_id="entity_id", + state="state", ), {}, ) assert state.context_id == "1H68SK8C9J6CT32CHK6GRK4CSM" assert state.context_user_id == "31323334313233343132333431323334" assert state.context_parent_id == "1M6GT38D1M6GT38D1M6GT38D1M" + assert state.event_type == "event_type" + assert state.entity_id == "entity_id" + assert state.state == "state" From 50edc334dec634ef788ce72aca33b1454a32a3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:36:03 -1000 Subject: [PATCH 0361/1544] Refactor sensor recorder _get_sensor_states to check for state class first (#107046) The state class check is cheap and the entity filter check is much more expensive, so do the state class check first --- homeassistant/components/sensor/recorder.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index d08a20636ab..1aba934aba4 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -68,13 +68,19 @@ LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistic def _get_sensor_states(hass: HomeAssistant) -> list[State]: """Get the current state of all sensors for which to compile statistics.""" - all_sensors = hass.states.all(DOMAIN) instance = get_instance(hass) + # We check for state class first before calling the filter + # function as the filter function is much more expensive + # than checking the state class return [ state - for state in all_sensors - if instance.entity_filter(state.entity_id) - and try_parse_enum(SensorStateClass, state.attributes.get(ATTR_STATE_CLASS)) + for state in hass.states.all(DOMAIN) + if (state_class := state.attributes.get(ATTR_STATE_CLASS)) + and ( + type(state_class) is SensorStateClass + or try_parse_enum(SensorStateClass, state_class) + ) + and instance.entity_filter(state.entity_id) ] From d04e2d56da14e05d12acb62a877319a226aec390 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:36:31 -1000 Subject: [PATCH 0362/1544] Add support for JSON fragments (#107213) --- homeassistant/components/api/__init__.py | 9 +- .../homeassistant/triggers/event.py | 8 +- .../components/websocket_api/messages.py | 2 +- homeassistant/core.py | 172 ++++++++++++------ homeassistant/helpers/json.py | 5 + homeassistant/helpers/restore_state.py | 33 +--- homeassistant/helpers/template.py | 3 +- tests/common.py | 7 +- tests/helpers/test_json.py | 35 +++- tests/helpers/test_restore_state.py | 26 ++- tests/test_core.py | 92 ++++++++++ 11 files changed, 289 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 057e85613fd..5e965cd370c 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,11 +1,9 @@ """Rest API for Home Assistant.""" import asyncio from asyncio import shield, timeout -from collections.abc import Collection from functools import lru_cache from http import HTTPStatus import logging -from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest @@ -42,11 +40,10 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import EventStateChangedData -from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.json import json_dumps, json_fragment from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.json import json_loads -from homeassistant.util.read_only_dict import ReadOnlyDict _LOGGER = logging.getLogger(__name__) @@ -377,14 +374,14 @@ class APIDomainServicesView(HomeAssistantView): ) context = self.context(request) - changed_states: list[ReadOnlyDict[str, Collection[Any]]] = [] + changed_states: list[json_fragment] = [] @ha.callback def _async_save_changed_entities( event: EventType[EventStateChangedData], ) -> None: if event.context == context and (state := event.data["new_state"]): - changed_states.append(state.as_dict()) + changed_states.append(state.json_fragment) cancel_listen = hass.bus.async_listen( EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index be514fd24ad..37a91d06d1a 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -115,11 +115,15 @@ async def async_attach_trigger( if event_context_items: # Fast path for simple items comparison - if not (event.context.as_dict().items() >= event_context_items): + # This is safe because we do not mutate the event context + # pylint: disable-next=protected-access + if not (event.context._as_dict.items() >= event_context_items): return False elif event_context_schema: # Slow path for schema validation - event_context_schema(dict(event.context.as_dict())) + # This is safe because we make a copy of the event context + # pylint: disable-next=protected-access + event_context_schema(dict(event.context._as_dict)) except vol.Invalid: # If event doesn't match, skip event return False diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 3aaeff6a797..1d3181fcf3a 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -116,7 +116,7 @@ def _partial_cached_event_message(event: Event) -> str: in cached_event_message. """ return ( - _message_to_json_or_none({"type": "event", "event": event.as_dict()}) + _message_to_json_or_none({"type": "event", "event": event.json_fragment}) or INVALID_JSON_PARTIAL_MESSAGE ) diff --git a/homeassistant/core.py b/homeassistant/core.py index fed54689ab7..3ad358b0b4a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -87,7 +87,7 @@ from .helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .helpers.json import json_dumps +from .helpers.json import json_dumps, json_fragment from .util import dt as dt_util, location from .util.async_ import ( cancelling, @@ -996,8 +996,6 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict") - def __init__( self, user_id: str | None = None, @@ -1009,23 +1007,37 @@ class Context: self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None - self._as_dict: ReadOnlyDict[str, str | None] | None = None def __eq__(self, other: Any) -> bool: """Compare contexts.""" return bool(self.__class__ == other.__class__ and self.id == other.id) + @cached_property + def _as_dict(self) -> dict[str, str | None]: + """Return a dictionary representation of the context. + + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + return { + "id": self.id, + "parent_id": self.parent_id, + "user_id": self.user_id, + } + def as_dict(self) -> ReadOnlyDict[str, str | None]: - """Return a dictionary representation of the context.""" - if not self._as_dict: - self._as_dict = ReadOnlyDict( - { - "id": self.id, - "parent_id": self.parent_id, - "user_id": self.user_id, - } - ) - return self._as_dict + """Return a ReadOnlyDict representation of the context.""" + return self._as_read_only_dict + + @cached_property + def _as_read_only_dict(self) -> ReadOnlyDict[str, str | None]: + """Return a ReadOnlyDict representation of the context.""" + return ReadOnlyDict(self._as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return a JSON fragment of the context.""" + return json_fragment(json_dumps(self._as_dict)) class EventOrigin(enum.Enum): @@ -1042,8 +1054,6 @@ class EventOrigin(enum.Enum): class Event: """Representation of an event within the bus.""" - __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict") - def __init__( self, event_type: str, @@ -1062,26 +1072,54 @@ class Event: id=ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) self.context = context - self._as_dict: ReadOnlyDict[str, Any] | None = None if not context.origin_event: context.origin_event = self - def as_dict(self) -> ReadOnlyDict[str, Any]: + @cached_property + def _as_dict(self) -> dict[str, Any]: """Create a dict representation of this Event. + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + return { + "event_type": self.event_type, + "data": self.data, + "origin": self.origin.value, + "time_fired": self.time_fired.isoformat(), + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + "context": self.context._as_dict, # pylint: disable=protected-access + } + + def as_dict(self) -> ReadOnlyDict[str, Any]: + """Create a ReadOnlyDict representation of this Event. + Async friendly. """ - if not self._as_dict: - self._as_dict = ReadOnlyDict( - { - "event_type": self.event_type, - "data": ReadOnlyDict(self.data), - "origin": self.origin.value, - "time_fired": self.time_fired.isoformat(), - "context": self.context.as_dict(), - } - ) - return self._as_dict + return self._as_read_only_dict + + @cached_property + def _as_read_only_dict(self) -> ReadOnlyDict[str, Any]: + """Create a ReadOnlyDict representation of this Event.""" + as_dict = self._as_dict + data = as_dict["data"] + context = as_dict["context"] + # json_fragment will serialize data from a ReadOnlyDict + # or a normal dict so its ok to have either. We only + # mutate the cache if someone asks for the as_dict version + # to avoid storing multiple copies of the data in memory. + if type(data) is not ReadOnlyDict: + as_dict["data"] = ReadOnlyDict(data) + if type(context) is not ReadOnlyDict: + as_dict["context"] = ReadOnlyDict(context) + return ReadOnlyDict(as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return an event as a JSON fragment.""" + return json_fragment(json_dumps(self._as_dict)) def __repr__(self) -> str: """Return the representation.""" @@ -1397,7 +1435,6 @@ class State: self.context = context or Context() self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) - self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None @property def name(self) -> str: @@ -1406,36 +1443,66 @@ class State: "_", " " ) - def as_dict(self) -> ReadOnlyDict[str, Collection[Any]]: + @cached_property + def _as_dict(self) -> dict[str, Any]: """Return a dict representation of the State. + Callers should be careful to not mutate the returned dictionary + as it will mutate the cached version. + """ + last_changed_isoformat = self.last_changed.isoformat() + if self.last_changed == self.last_updated: + last_updated_isoformat = last_changed_isoformat + else: + last_updated_isoformat = self.last_updated.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + "context": self.context._as_dict, # pylint: disable=protected-access + } + + def as_dict( + self, + ) -> ReadOnlyDict[str, datetime.datetime | Collection[Any]]: + """Return a ReadOnlyDict representation of the State. + Async friendly. - To be used for JSON serialization. + Can be used for JSON serialization. Ensures: state == State.from_dict(state.as_dict()) """ - if not self._as_dict: - last_changed_isoformat = self.last_changed.isoformat() - if self.last_changed == self.last_updated: - last_updated_isoformat = last_changed_isoformat - else: - last_updated_isoformat = self.last_updated.isoformat() - self._as_dict = ReadOnlyDict( - { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - "context": self.context.as_dict(), - } - ) - return self._as_dict + return self._as_read_only_dict + + @cached_property + def _as_read_only_dict( + self, + ) -> ReadOnlyDict[str, datetime.datetime | Collection[Any]]: + """Return a ReadOnlyDict representation of the State.""" + as_dict = self._as_dict + context = as_dict["context"] + # json_fragment will serialize data from a ReadOnlyDict + # or a normal dict so its ok to have either. We only + # mutate the cache if someone asks for the as_dict version + # to avoid storing multiple copies of the data in memory. + if type(context) is not ReadOnlyDict: + as_dict["context"] = ReadOnlyDict(context) + return ReadOnlyDict(as_dict) @cached_property def as_dict_json(self) -> str: """Return a JSON string of the State.""" - return json_dumps(self.as_dict()) + return json_dumps(self._as_dict) + + @cached_property + def json_fragment(self) -> json_fragment: + """Return a JSON fragment of the State.""" + return json_fragment(self.as_dict_json) @cached_property def as_compressed_state(self) -> dict[str, Any]: @@ -1449,7 +1516,10 @@ class State: if state_context.parent_id is None and state_context.user_id is None: context: dict[str, Any] | str = state_context.id else: - context = state_context.as_dict() + # _as_dict is marked as protected + # to avoid callers outside of this module + # from misusing it by mistake. + context = state_context._as_dict # pylint: disable=protected-access compressed_state = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index e155427fa10..b9862907960 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -45,6 +45,8 @@ def json_encoder_default(obj: Any) -> Any: Hand other objects to the original method. """ + if hasattr(obj, "json_fragment"): + return obj.json_fragment if isinstance(obj, (set, tuple)): return list(obj) if isinstance(obj, float): @@ -114,6 +116,9 @@ def json_bytes_strip_null(data: Any) -> bytes: return json_bytes(_strip_null(orjson.loads(result))) +json_fragment = orjson.Fragment + + def json_dumps(data: Any) -> str: r"""Dump json string. diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0878114552f..7df83cd0ab9 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,6 +10,7 @@ from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util +from homeassistant.util.json import json_loads from . import start from .entity import Entity @@ -70,9 +71,9 @@ class StoredState: self.state = state def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the stored state.""" + """Return a dict representation of the stored state to be JSON serialized.""" result = { - "state": self.state.as_dict(), + "state": self.state.json_fragment, "extra_data": self.extra_data.as_dict() if self.extra_data else None, "last_seen": self.last_seen, } @@ -270,7 +271,7 @@ class RestoreStateData: # To fully mimic all the attribute data types when loaded from storage, # we're going to serialize it to JSON and then re-load it. if state is not None: - state = State.from_dict(_encode_complex(state.as_dict())) + state = State.from_dict(json_loads(state.as_dict_json)) # type: ignore[arg-type] if state is not None: self.last_states[entity_id] = StoredState( state, extra_data, dt_util.utcnow() @@ -279,32 +280,6 @@ class RestoreStateData: self.entities.pop(entity_id) -def _encode(value: Any) -> Any: - """Little helper to JSON encode a value.""" - try: - return JSONEncoder.default( - None, # type: ignore[arg-type] - value, - ) - except TypeError: - return value - - -def _encode_complex(value: Any) -> Any: - """Recursively encode all values with the JSONEncoder.""" - if isinstance(value, dict): - return {_encode(key): _encode_complex(value) for key, value in value.items()} - if isinstance(value, list): - return [_encode_complex(val) for val in value] - - new_value = _encode(value) - - if isinstance(new_value, type(value)): - return new_value - - return _encode_complex(new_value) - - class RestoreEntity(Entity): """Mixin class for restoring previous entity state.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6c6fbeb2aac..ac37360d5e2 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Generator, Iterable from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -940,7 +940,6 @@ class TemplateStateBase(State): self._hass = hass self._collect = collect self._entity_id = entity_id - self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: if self._collect and (render_info := _render_info.get()): diff --git a/tests/common.py b/tests/common.py index c6a0660be73..4e68bcf4357 100644 --- a/tests/common.py +++ b/tests/common.py @@ -74,7 +74,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder +from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe @@ -507,6 +507,11 @@ def load_json_object_fixture( return json_loads_object(load_fixture(filename, integration)) +def json_round_trip(obj: Any) -> Any: + """Round trip an object to JSON.""" + return json_loads(json_dumps(obj)) + + def mock_state_change_event( hass: HomeAssistant, new_state: State, old_state: State | None = None ) -> None: diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 7e248c8c381..2106a397baf 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -19,12 +19,15 @@ from homeassistant.helpers.json import ( json_bytes_strip_null, json_dumps, json_dumps_sorted, + json_fragment, save_json, ) from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor from homeassistant.util.json import SerializationError, load_json +from tests.common import json_round_trip + # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} TEST_JSON_B = {"a": "one", "B": 2} @@ -45,7 +48,8 @@ def test_json_encoder(hass: HomeAssistant, encoder: type[json.JSONEncoder]) -> N assert sorted(ha_json_enc.default(data)) == sorted(data) # Test serializing an object which implements as_dict - assert ha_json_enc.default(state) == state.as_dict() + default = ha_json_enc.default(state) + assert json_round_trip(default) == json_round_trip(state.as_dict()) def test_json_encoder_raises(hass: HomeAssistant) -> None: @@ -133,6 +137,35 @@ def test_json_dumps_rgb_color_subclass() -> None: assert json_dumps(rgb) == "[4,2,1]" +def test_json_fragments() -> None: + """Test the json dumps with a fragment.""" + + assert ( + json_dumps( + [ + json_fragment('{"inner":"fragment2"}'), + json_fragment('{"inner":"fragment2"}'), + ] + ) + == '[{"inner":"fragment2"},{"inner":"fragment2"}]' + ) + + class Fragment1: + @property + def json_fragment(self): + return json_fragment('{"inner":"fragment1"}') + + class Fragment2: + @property + def json_fragment(self): + return json_fragment('{"inner":"fragment2"}') + + assert ( + json_dumps([Fragment1(), Fragment2()]) + == '[{"inner":"fragment1"},{"inner":"fragment2"}]' + ) + + def test_json_bytes_strip_null() -> None: """Test stripping nul from strings.""" diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index d69996e5d29..79298ed1611 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -31,6 +31,7 @@ from tests.common import ( MockModule, MockPlatform, async_fire_time_changed, + json_round_trip, mock_integration, mock_platform, ) @@ -318,12 +319,15 @@ async def test_dump_data(hass: HomeAssistant) -> None: # b4 should not be written, since it is now expired # b5 should be written, since current state is restored by entity registry assert len(written_states) == 3 - assert written_states[0]["state"]["entity_id"] == "input_boolean.b1" - assert written_states[0]["state"]["state"] == "on" - assert written_states[1]["state"]["entity_id"] == "input_boolean.b3" - assert written_states[1]["state"]["state"] == "off" - assert written_states[2]["state"]["entity_id"] == "input_boolean.b5" - assert written_states[2]["state"]["state"] == "off" + state0 = json_round_trip(written_states[0]) + state1 = json_round_trip(written_states[1]) + state2 = json_round_trip(written_states[2]) + assert state0["state"]["entity_id"] == "input_boolean.b1" + assert state0["state"]["state"] == "on" + assert state1["state"]["entity_id"] == "input_boolean.b3" + assert state1["state"]["state"] == "off" + assert state2["state"]["entity_id"] == "input_boolean.b5" + assert state2["state"]["state"] == "off" # Test that removed entities are not persisted await entity.async_remove() @@ -340,10 +344,12 @@ async def test_dump_data(hass: HomeAssistant) -> None: args = mock_write_data.mock_calls[0][1] written_states = args[0] assert len(written_states) == 2 - assert written_states[0]["state"]["entity_id"] == "input_boolean.b3" - assert written_states[0]["state"]["state"] == "off" - assert written_states[1]["state"]["entity_id"] == "input_boolean.b5" - assert written_states[1]["state"]["state"] == "off" + state0 = json_round_trip(written_states[0]) + state1 = json_round_trip(written_states[1]) + assert state0["state"]["entity_id"] == "input_boolean.b3" + assert state0["state"]["state"] == "off" + assert state1["state"]["entity_id"] == "input_boolean.b5" + assert state1["state"]["state"] == "off" async def test_dump_error(hass: HomeAssistant) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index 02fba4c93af..1210b110601 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -54,6 +54,7 @@ from homeassistant.exceptions import ( MaxLengthExceeded, ServiceNotFound, ) +from homeassistant.helpers.json import json_dumps import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import METRIC_SYSTEM @@ -624,6 +625,38 @@ def test_event_eq() -> None: assert event1.as_dict() == event2.as_dict() +def test_event_json_fragment() -> None: + """Test event JSON fragments.""" + now = dt_util.utcnow() + data = {"some": "attr"} + context = ha.Context() + event1, event2 = ( + ha.Event("some_type", data, time_fired=now, context=context) for _ in range(2) + ) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = event1.json_fragment + as_dict_1 = event1.as_dict() + as_dict_2 = event2.as_dict() + json_fragment_2 = event2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # as is the data and context inside regardless of + # if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_1["data"], ReadOnlyDict) + assert isinstance(as_dict_1["context"], ReadOnlyDict) + + assert isinstance(as_dict_2, ReadOnlyDict) + assert isinstance(as_dict_2["data"], ReadOnlyDict) + assert isinstance(as_dict_2["context"], ReadOnlyDict) + + def test_event_repr() -> None: """Test that Event repr method works.""" assert str(ha.Event("TestEvent")) == "" @@ -712,6 +745,44 @@ def test_state_as_dict_json() -> None: assert state.as_dict_json is as_dict_json_1 +def test_state_json_fragment() -> None: + """Test state JSON fragments.""" + last_time = datetime(1984, 12, 8, 12, 0, 0) + state1, state2 = ( + ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), + ) + for _ in range(2) + ) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = state1.json_fragment + as_dict_1 = state1.as_dict() + as_dict_2 = state2.as_dict() + json_fragment_2 = state2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # as is the attributes and context inside regardless of + # if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_1["attributes"], ReadOnlyDict) + assert isinstance(as_dict_1["context"], ReadOnlyDict) + + assert isinstance(as_dict_2, ReadOnlyDict) + assert isinstance(as_dict_2["attributes"], ReadOnlyDict) + assert isinstance(as_dict_2["context"], ReadOnlyDict) + + def test_state_as_compressed_state() -> None: """Test a State as compressed state.""" last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) @@ -1729,6 +1800,27 @@ def test_context() -> None: assert c.id is not None +def test_context_json_fragment() -> None: + """Test context JSON fragments.""" + context1, context2 = (ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW") for _ in range(2)) + + # We are testing that the JSON fragments are the same when as_dict is called + # after json_fragment or before. + json_fragment_1 = context1.json_fragment + as_dict_1 = context1.as_dict() + as_dict_2 = context2.as_dict() + json_fragment_2 = context2.json_fragment + + assert json_dumps(json_fragment_1) == json_dumps(json_fragment_2) + # We also test that the as_dict is the same + assert as_dict_1 == as_dict_2 + + # Finally we verify that the as_dict is a ReadOnlyDict + # regardless of if the json fragment was called first or not + assert isinstance(as_dict_1, ReadOnlyDict) + assert isinstance(as_dict_2, ReadOnlyDict) + + async def test_async_functions_with_callback(hass: HomeAssistant) -> None: """Test we deal with async functions accidentally marked as callback.""" runs = [] From acf78664e23d503fce4ecf3bbec507f9ca573d60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:36:49 -1000 Subject: [PATCH 0363/1544] Reduce overhead to compile statistics (#106927) * Reduce overhead to compile statistics statistics uses LazyState for compatibility with State when pulling data from the database. After the previous round of refactoring to modern history, the setters are never called and can be removed. * reduce --- .../components/recorder/models/state.py | 81 ++++++------------- tests/components/recorder/test_models.py | 16 ---- 2 files changed, 26 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 73e7798b9f5..5f469638ec0 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime import logging -from typing import Any +from typing import TYPE_CHECKING, Any from sqlalchemy.engine.row import Row @@ -17,10 +17,16 @@ from homeassistant.core import Context, State import homeassistant.util.dt as dt_util from .state_attributes import decode_attributes_from_source -from .time import process_timestamp + +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property _LOGGER = logging.getLogger(__name__) +EMPTY_CONTEXT = Context(id=None) + def extract_metadata_ids( entity_id_to_metadata_id: dict[str, int | None], @@ -36,15 +42,6 @@ def extract_metadata_ids( class LazyState(State): """A lazy version of core State after schema 31.""" - __slots__ = [ - "_row", - "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_context", - "attr_cache", - ] - def __init__( # pylint: disable=super-init-not-called self, row: Row, @@ -61,61 +58,35 @@ class LazyState(State): self.state = state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = last_updated_ts or start_time_ts - self._last_changed_ts: float | None = None - self._context: Context | None = None self.attr_cache = attr_cache + self.context = EMPTY_CONTEXT - @property # type: ignore[override] + @cached_property # type: ignore[override] def attributes(self) -> dict[str, Any]: """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_source( - getattr(self._row, "attributes", None), self.attr_cache - ) - return self._attributes + return decode_attributes_from_source( + getattr(self._row, "attributes", None), self.attr_cache + ) - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value + @cached_property + def _last_changed_ts(self) -> float | None: + """Last changed timestamp.""" + return getattr(self._row, "last_changed_ts", None) - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: + @cached_property + def last_changed(self) -> datetime: # type: ignore[override] """Last changed datetime.""" - if self._last_changed_ts is None: - self._last_changed_ts = ( - getattr(self._row, "last_changed_ts", None) or self._last_updated_ts - ) - return dt_util.utc_from_timestamp(self._last_changed_ts) + return dt_util.utc_from_timestamp( + self._last_changed_ts or self._last_updated_ts + ) - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed_ts = process_timestamp(value).timestamp() - - @property - def last_updated(self) -> datetime: + @cached_property + def last_updated(self) -> datetime: # type: ignore[override] """Last updated datetime.""" - assert self._last_updated_ts is not None + if TYPE_CHECKING: + assert self._last_updated_ts is not None return dt_util.utc_from_timestamp(self._last_updated_ts) - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated_ts = process_timestamp(value).timestamp() - def as_dict(self) -> dict[str, Any]: # type: ignore[override] """Return a dict representation of the LazyState. diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index f5ea8ff1656..8536481dd1f 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -352,22 +352,6 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - lstate.last_updated = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2021-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } - lstate.last_changed = datetime(2020, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - assert lstate.as_dict() == { - "attributes": {"shared": True}, - "entity_id": "sensor.valid", - "last_changed": "2020-06-12T03:04:01.000323+00:00", - "last_updated": "2020-06-12T03:04:01.000323+00:00", - "state": "off", - } @pytest.mark.parametrize( From 69307374f49f945f955379b2ffaf66521d192194 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 17:55:40 -1000 Subject: [PATCH 0364/1544] Signficantly reduce executor contention during bootstrap (#107312) * Signficantly reduce executor contention during bootstrap At startup we have a thundering herd wanting to use the executor to load manifiest.json. Since we know which integrations we are about to load in each resolver step, group the manifest loads into single executor jobs by calling async_get_integrations on the deps of the integrations after they are resolved. In practice this reduced the number of executor jobs by 80% during bootstrap * merge * naming * tweak * tweak * not enough contention to be worth it there * refactor to avoid waiting * refactor to avoid waiting * tweaks * tweaks * tweak * background is fine * comment --- homeassistant/bootstrap.py | 41 ++++++++++++++++++++++++++++++----- homeassistant/requirements.py | 19 ++++++++++++++++ homeassistant/util/package.py | 5 +++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 83b2f18719f..bca74a684b2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol import yarl -from . import config as conf_util, config_entries, core, loader +from . import config as conf_util, config_entries, core, loader, requirements from .components import http from .const import ( FORMAT_DATETIME, @@ -229,7 +229,7 @@ def open_hass_ui(hass: core.HomeAssistant) -> None: ) -async def load_registries(hass: core.HomeAssistant) -> None: +async def async_load_base_functionality(hass: core.HomeAssistant) -> None: """Load the registries and cache the result of platform.uname().processor.""" if DATA_REGISTRIES_LOADED in hass.data: return @@ -256,6 +256,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: hass.async_add_executor_job(_cache_uname_processor), template.async_load_custom_templates(hass), restore_state.async_load(hass), + hass.config_entries.async_initialize(), ) @@ -270,8 +271,7 @@ async def async_from_config_dict( start = monotonic() hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_initialize() - await load_registries(hass) + await async_load_base_functionality(hass) # Set up core. _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) @@ -527,11 +527,13 @@ async def async_setup_multi_components( config: dict[str, Any], ) -> None: """Set up multiple domains. Log on failure.""" + # Avoid creating tasks for domains that were setup in a previous stage + domains_not_yet_setup = domains - hass.config.components futures = { domain: hass.async_create_task( async_setup_component(hass, domain, config), f"setup component {domain}" ) - for domain in domains + for domain in domains_not_yet_setup } results = await asyncio.gather(*futures.values(), return_exceptions=True) for idx, domain in enumerate(futures): @@ -555,6 +557,8 @@ async def _async_set_up_integrations( domains_to_setup = _get_domains(hass, config) + needed_requirements: set[str] = set() + # Resolve all dependencies so we know all integrations # that will have to be loaded and start rightaway integration_cache: dict[str, loader.Integration] = {} @@ -570,6 +574,25 @@ async def _async_set_up_integrations( ).values() if isinstance(int_or_exc, loader.Integration) ] + + manifest_deps: set[str] = set() + for itg in integrations_to_process: + manifest_deps.update(itg.dependencies) + manifest_deps.update(itg.after_dependencies) + needed_requirements.update(itg.requirements) + + if manifest_deps: + # If there are dependencies, try to preload all + # the integrations manifest at once and add them + # to the list of requirements we need to install + # so we can try to check if they are already installed + # in a single call below which avoids each integration + # having to wait for the lock to do it individually + deps = await loader.async_get_integrations(hass, manifest_deps) + for dependant_itg in deps.values(): + if isinstance(dependant_itg, loader.Integration): + needed_requirements.update(dependant_itg.requirements) + resolve_dependencies_tasks = [ itg.resolve_dependencies() for itg in integrations_to_process @@ -591,6 +614,14 @@ async def _async_set_up_integrations( _LOGGER.info("Domains to be set up: %s", domains_to_setup) + # Optimistically check if requirements are already installed + # ahead of setting up the integrations so we can prime the cache + # We do not wait for this since its an optimization only + hass.async_create_background_task( + requirements.async_load_installed_versions(hass, needed_requirements), + "check installed requirements", + ) + # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 27a9607a6ee..9892a4d9169 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -63,6 +63,13 @@ async def async_process_requirements( await _async_get_manager(hass).async_process_requirements(name, requirements) +async def async_load_installed_versions( + hass: HomeAssistant, requirements: set[str] +) -> None: + """Load the installed version of requirements.""" + await _async_get_manager(hass).async_load_installed_versions(requirements) + + @callback def _async_get_manager(hass: HomeAssistant) -> RequirementsManager: """Get the requirements manager.""" @@ -284,3 +291,15 @@ class RequirementsManager: self.install_failure_history |= failures if failures: raise RequirementsNotFound(name, list(failures)) + + async def async_load_installed_versions( + self, + requirements: set[str], + ) -> None: + """Load the installed version of requirements.""" + if not (requirements_to_check := requirements - self.is_installed_cache): + return + + self.is_installed_cache |= await self.hass.async_add_executor_job( + pkg_util.get_installed_versions, requirements_to_check + ) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 61d282931c0..d487edee4a4 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -29,6 +29,11 @@ def is_docker_env() -> bool: return Path("/.dockerenv").exists() +def get_installed_versions(specifiers: set[str]) -> set[str]: + """Return a set of installed packages and versions.""" + return {specifier for specifier in specifiers if is_installed(specifier)} + + def is_installed(requirement_str: str) -> bool: """Check if a package is installed and will be loaded when we import it. From efffbc08aad5f0ce2a0132674e2396d5b61a1977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 18:25:56 -1000 Subject: [PATCH 0365/1544] Add support for bluetooth local name matchers shorter than 3 chars (#107411) --- homeassistant/components/bluetooth/match.py | 41 ++++++-------- tests/components/bluetooth/test_init.py | 60 +++++++++++++++++---- 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 827006fe19d..453ab996abc 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -237,10 +237,12 @@ class BluetoothMatcherIndexBase(Generic[_T]): def match(self, service_info: BluetoothServiceInfoBleak) -> list[_T]: """Check for a match.""" matches = [] - if service_info.name and len(service_info.name) >= LOCAL_NAME_MIN_MATCH_LENGTH: - for matcher in self.local_name.get( - service_info.name[:LOCAL_NAME_MIN_MATCH_LENGTH], [] - ): + if (name := service_info.name) and ( + local_name_matchers := self.local_name.get( + name[:LOCAL_NAME_MIN_MATCH_LENGTH] + ) + ): + for matcher in local_name_matchers: if ble_device_matches(matcher, service_info): matches.append(matcher) @@ -351,11 +353,6 @@ def _local_name_to_index_key(local_name: str) -> str: if they try to setup a matcher that will is overly broad as would match too many devices and cause a performance hit. """ - if len(local_name) < LOCAL_NAME_MIN_MATCH_LENGTH: - raise ValueError( - "Local name matchers must be at least " - f"{LOCAL_NAME_MIN_MATCH_LENGTH} characters long ({local_name})" - ) match_part = local_name[:LOCAL_NAME_MIN_MATCH_LENGTH] if "*" in match_part or "[" in match_part: raise ValueError( @@ -377,35 +374,29 @@ def ble_device_matches( if matcher.get(CONNECTABLE, True) and not service_info.connectable: return False - advertisement_data = service_info.advertisement if ( service_uuid := matcher.get(SERVICE_UUID) - ) and service_uuid not in advertisement_data.service_uuids: + ) and service_uuid not in service_info.service_uuids: return False if ( service_data_uuid := matcher.get(SERVICE_DATA_UUID) - ) and service_data_uuid not in advertisement_data.service_data: + ) and service_data_uuid not in service_info.service_data: return False - if manfacturer_id := matcher.get(MANUFACTURER_ID): - if manfacturer_id not in advertisement_data.manufacturer_data: + if manufacturer_id := matcher.get(MANUFACTURER_ID): + if manufacturer_id not in service_info.manufacturer_data: return False + if manufacturer_data_start := matcher.get(MANUFACTURER_DATA_START): - manufacturer_data_start_bytes = bytearray(manufacturer_data_start) - if not any( - manufacturer_data.startswith(manufacturer_data_start_bytes) - for manufacturer_data in advertisement_data.manufacturer_data.values() + if not service_info.manufacturer_data[manufacturer_id].startswith( + bytes(manufacturer_data_start) ): return False - if (local_name := matcher.get(LOCAL_NAME)) and ( - (device_name := advertisement_data.local_name or service_info.device.name) - is None - or not _memorized_fnmatch( - device_name, - local_name, - ) + if (local_name := matcher.get(LOCAL_NAME)) and not _memorized_fnmatch( + service_info.name, + local_name, ): return False diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 1659b989af0..35ee073bc87 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import time -from unittest.mock import ANY, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -376,6 +376,56 @@ async def test_discovery_match_by_service_uuid( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" +@patch.object( + bluetooth, + "async_get_bluetooth", + return_value=[ + { + "domain": "sensorpush", + "local_name": "s", + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9", + } + ], +) +async def test_discovery_match_by_service_uuid_and_short_local_name( + mock_async_get_bluetooth: AsyncMock, + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test bluetooth discovery match by service_uuid and short local name.""" + entry = MockConfigEntry(domain="bluetooth", unique_id="00:00:00:00:00:01") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + await async_setup_with_default_adapter(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") + wrong_adv = generate_advertisement_data(local_name="s", service_uuids=[]) + + inject_advertisement(hass, wrong_device, wrong_adv) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 0 + + ht1_device = generate_ble_device("44:44:33:11:23:45", "s") + ht1_adv = generate_advertisement_data( + local_name="s", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090aa9"] + ) + + inject_advertisement(hass, ht1_device, ht1_adv) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "sensorpush" + + def _domains_from_mock_config_flow(mock_config_flow: Mock) -> list[str]: """Get all the domains that were passed to async_init except bluetooth.""" return [call[1][0] for call in mock_config_flow.mock_calls if call[1][0] != DOMAIN] @@ -2016,14 +2066,6 @@ async def test_register_callback_by_local_name_overly_broad( ): await async_setup_with_default_adapter(hass) - with pytest.raises(ValueError): - bluetooth.async_register_callback( - hass, - _fake_subscriber, - {LOCAL_NAME: "a"}, - BluetoothScanningMode.ACTIVE, - ) - with pytest.raises(ValueError): bluetooth.async_register_callback( hass, From f2483bf660ffb190c565657999635da675f2ecc5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 08:05:51 +0100 Subject: [PATCH 0366/1544] Use constants in Alpha2 config flow (#107518) --- .../components/moehlenhoff_alpha2/__init__.py | 4 ++-- .../moehlenhoff_alpha2/config_flow.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 4992ecf34a7..05f6b23fdd0 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -8,7 +8,7 @@ import aiohttp from moehlenhoff_alpha2 import Alpha2Base from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -24,7 +24,7 @@ UPDATE_INTERVAL = timedelta(seconds=60) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - base = Alpha2Base(entry.data["host"]) + base = Alpha2Base(entry.data[CONF_HOST]) coordinator = Alpha2BaseCoordinator(hass, base) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py index cafdca040b3..d2d14f27552 100644 --- a/homeassistant/components/moehlenhoff_alpha2/config_flow.py +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -1,27 +1,30 @@ """Alpha2 config flow.""" import asyncio import logging +from typing import Any import aiohttp from moehlenhoff_alpha2 import Alpha2Base import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -async def validate_input(data): +async def validate_input(data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ - base = Alpha2Base(data["host"]) + base = Alpha2Base(data[CONF_HOST]) try: await base.update_data() except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): @@ -34,16 +37,18 @@ async def validate_input(data): return {"title": base.name} -class Alpha2BaseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class Alpha2BaseConfigFlow(ConfigFlow, domain=DOMAIN): """Möhlenhoff Alpha2 config flow.""" VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - self._async_abort_entries_match({"host": user_input["host"]}) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) result = await validate_input(user_input) if result.get("error"): errors["base"] = result["error"] From 8b0c96a212c81da358defb4a614819534cbab760 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 08:11:30 +0100 Subject: [PATCH 0367/1544] Clean up met config flow (#107480) --- homeassistant/components/met/config_flow.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index d36a9e58eb7..ac614e4691b 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,6 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import OptionsFlowWithConfigEntry from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, @@ -95,15 +96,11 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Init MetConfigFlowHandler.""" - self._errors: dict[str, Any] = {} - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - self._errors = {} + errors = {} if user_input is not None: if ( @@ -113,12 +110,12 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) - self._errors[CONF_NAME] = "already_configured" + errors[CONF_NAME] = "already_configured" return self.async_show_form( step_id="user", data_schema=_get_data_schema(self.hass), - errors=self._errors, + errors=errors, ) async def async_step_onboarding( @@ -146,14 +143,9 @@ class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler(config_entry) -class MetOptionsFlowHandler(config_entries.OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithConfigEntry): """Options flow for Met component.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize the Met OptionsFlow.""" - self._config_entry = config_entry - self._errors: dict[str, Any] = {} - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -171,5 +163,4 @@ class MetOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form( step_id="init", data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), - errors=self._errors, ) From eaac01bc76ee4c5bd84d522a7f30f083a337bac2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 08:17:28 +0100 Subject: [PATCH 0368/1544] Introduce heat area property in moehlenhoff alpha2 (#107488) --- .../components/moehlenhoff_alpha2/climate.py | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 1868be11f67..23a39084f9f 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -35,7 +35,6 @@ async def async_setup_entry( ) -# https://developers.home-assistant.io/docs/core/entity/climate/ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): """Alpha2 ClimateEntity.""" @@ -53,34 +52,27 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): super().__init__(coordinator) self.heat_area_id = heat_area_id self._attr_unique_id = heat_area_id - self._attr_name = self.coordinator.data["heat_areas"][heat_area_id][ - "HEATAREA_NAME" - ] + self._attr_name = self.heat_area["HEATAREA_NAME"] + + @property + def heat_area(self) -> dict[str, Any]: + """Return the heat area.""" + return self.coordinator.data["heat_areas"][self.heat_area_id] @property def min_temp(self) -> float: """Return the minimum temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get( - "T_TARGET_MIN", 0.0 - ) - ) + return float(self.heat_area.get("T_TARGET_MIN", 0.0)) @property def max_temp(self) -> float: """Return the maximum temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get( - "T_TARGET_MAX", 30.0 - ) - ) + return float(self.heat_area.get("T_TARGET_MAX", 30.0)) @property def current_temperature(self) -> float: """Return the current temperature.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get("T_ACTUAL", 0.0) - ) + return float(self.heat_area.get("T_ACTUAL", 0.0)) @property def hvac_mode(self) -> HVACMode: @@ -96,9 +88,7 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" - if not self.coordinator.data["heat_areas"][self.heat_area_id][ - "_HEATCTRL_STATE" - ]: + if not self.heat_area["_HEATCTRL_STATE"]: return HVACAction.IDLE if self.coordinator.get_cooling(): return HVACAction.COOLING @@ -107,9 +97,7 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return float( - self.coordinator.data["heat_areas"][self.heat_area_id].get("T_TARGET", 0.0) - ) + return float(self.heat_area.get("T_TARGET", 0.0)) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -123,9 +111,9 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): @property def preset_mode(self) -> str: """Return the current preset mode.""" - if self.coordinator.data["heat_areas"][self.heat_area_id]["HEATAREA_MODE"] == 1: + if self.heat_area["HEATAREA_MODE"] == 1: return PRESET_DAY - if self.coordinator.data["heat_areas"][self.heat_area_id]["HEATAREA_MODE"] == 2: + if self.heat_area["HEATAREA_MODE"] == 2: return PRESET_NIGHT return PRESET_AUTO From fc36c48accc533b7bdf4ad9825b9b38fdd366107 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:06:12 -1000 Subject: [PATCH 0369/1544] Bump sensorpush-ble to 1.6.2 (#107410) --- homeassistant/components/sensorpush/manifest.json | 7 ++++++- homeassistant/generated/bluetooth.py | 6 ++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 2c6d929a3e4..0222a1c2884 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -5,6 +5,11 @@ { "local_name": "SensorPush*", "connectable": false + }, + { + "local_name": "s", + "connectable": false, + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9" } ], "codeowners": ["@bdraco"], @@ -12,5 +17,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.6.1"] + "requirements": ["sensorpush-ble==1.6.2"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 13700a4521c..22710f31f87 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -424,6 +424,12 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "sensorpush", "local_name": "SensorPush*", }, + { + "connectable": False, + "domain": "sensorpush", + "local_name": "s", + "service_uuid": "ef090000-11d6-42ba-93b8-9dd7ec090aa9", + }, { "domain": "snooz", "local_name": "Snooz*", diff --git a/requirements_all.txt b/requirements_all.txt index c9ae57afc0f..9ff3e7fb2fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2473,7 +2473,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.6.1 +sensorpush-ble==1.6.2 # homeassistant.components.sentry sentry-sdk==1.37.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ee61b20511..32f77d6891b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1868,7 +1868,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.6.1 +sensorpush-ble==1.6.2 # homeassistant.components.sentry sentry-sdk==1.37.1 From 448d5bbf27d869838c1a8368d778cad82daba9a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:07:42 -1000 Subject: [PATCH 0370/1544] Increase pip timeout in image builds to match core (#107514) --- .github/workflows/builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 8bfebbee85e..16a48d3cb48 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -11,6 +11,7 @@ on: env: BUILD_TYPE: core DEFAULT_PYTHON: "3.12" + PIP_TIMEOUT: 60 jobs: init: From 102fdbb237280cdaba4b3f742bbf985dfcbd27f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 09:08:59 +0100 Subject: [PATCH 0371/1544] Bump aiowithings to 2.1.0 (#107417) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index fe5704d119c..36e34ffc187 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==2.0.0"] + "requirements": ["aiowithings==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ff3e7fb2fd..492db780018 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -398,7 +398,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==2.0.0 +aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32f77d6891b..b88ddfd9d08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -371,7 +371,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==2.0.0 +aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 From d609344f403cf954e3b0ecb10f7693efbf9d877e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:10:58 -1000 Subject: [PATCH 0372/1544] Reduce duplicate code in ESPHome connection callback (#107338) --- homeassistant/components/esphome/manager.py | 43 ++++++++++----------- tests/components/esphome/conftest.py | 4 ++ tests/components/esphome/test_manager.py | 35 ++++++++++++++++- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b897ffc9408..f197574c30a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -383,6 +383,17 @@ class ESPHomeManager: self.voice_assistant_udp_server.stop() async def on_connect(self) -> None: + """Subscribe to states and list entities on successful API login.""" + try: + await self._on_connnect() + except APIConnectionError as err: + _LOGGER.warning( + "Error getting setting up connection for %s: %s", self.host, err + ) + # Re-connection logic will trigger after this + await self.cli.disconnect() + + async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry unique_id = entry.unique_id @@ -393,16 +404,10 @@ class ESPHomeManager: cli = self.cli stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id - try: - results = await asyncio.gather( - cli.device_info(), - cli.list_entities_services(), - ) - except APIConnectionError as err: - _LOGGER.warning("Error getting device info for %s: %s", self.host, err) - # Re-connection logic will trigger after this - await cli.disconnect() - return + results = await asyncio.gather( + cli.device_info(), + cli.list_entities_services(), + ) device_info: EsphomeDeviceInfo = results[0] entity_infos_services: tuple[list[EntityInfo], list[UserService]] = results[1] @@ -487,18 +492,12 @@ class ESPHomeManager: ) ) - try: - setup_results = await asyncio.gather( - *setup_coros_with_disconnect_callbacks, - cli.subscribe_states(entry_data.async_update_state), - cli.subscribe_service_calls(self.async_on_service_call), - cli.subscribe_home_assistant_states(self.async_on_state_subscription), - ) - except APIConnectionError as err: - _LOGGER.warning("Error getting initial data for %s: %s", self.host, err) - # Re-connection logic will trigger after this - await cli.disconnect() - return + setup_results = await asyncio.gather( + *setup_coros_with_disconnect_callbacks, + cli.subscribe_states(entry_data.async_update_state), + cli.subscribe_service_calls(self.async_on_service_call), + cli.subscribe_home_assistant_states(self.async_on_state_subscription), + ) for result_idx in range(len(setup_coros_with_disconnect_callbacks)): cancel_callback = setup_results[result_idx] diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 0ac940018d7..8c46fac08d4 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -90,6 +90,10 @@ class BaseMockReconnectLogic(ReconnectLogic): self._cancel_connect("forced disconnect from test") self._is_stopped = True + async def stop(self) -> None: + """Stop the reconnect logic.""" + self.stop_callback() + @pytest.fixture def mock_device_info() -> DeviceInfo: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 96a8a341308..474b1a757fb 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, call from aioesphomeapi import ( APIClient, + APIConnectionError, DeviceInfo, EntityInfo, EntityState, @@ -510,8 +511,11 @@ async def test_connection_aborted_wrong_device( "with mac address `11:22:33:44:55:ab`" in caplog.text ) + assert "Error getting setting up connection for" not in caplog.text + assert len(mock_client.disconnect.mock_calls) == 1 + mock_client.disconnect.reset_mock() caplog.clear() - # Make sure discovery triggers a reconnect to the correct device + # Make sure discovery triggers a reconnect service_info = dhcp.DhcpServiceInfo( ip="192.168.43.184", hostname="test", @@ -533,6 +537,35 @@ async def test_connection_aborted_wrong_device( assert "Unexpected device found at" not in caplog.text +async def test_failure_during_connect( + hass: HomeAssistant, + mock_client: APIClient, + mock_zeroconf: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we disconnect when there is a failure during connection setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock(side_effect=APIConnectionError("fail")) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "Error getting setting up connection for" in caplog.text + # Ensure we disconnect so that the reconnect logic is triggered + assert len(mock_client.disconnect.mock_calls) == 1 + + async def test_state_subscription( mock_client: APIClient, hass: HomeAssistant, From 7202126751c7d5c94a9ffe32b0482cf498d1f575 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 09:13:15 +0100 Subject: [PATCH 0373/1544] Add Met eireann to strict typing (#107486) --- .strict-typing | 1 + .../components/met_eireann/__init__.py | 16 +++++++------- .../components/met_eireann/config_flow.py | 9 ++++---- .../components/met_eireann/weather.py | 21 ++++++++++++------- mypy.ini | 10 +++++++++ 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.strict-typing b/.strict-typing index 377a30444de..0da84688727 100644 --- a/.strict-typing +++ b/.strict-typing @@ -265,6 +265,7 @@ homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* homeassistant.components.media_source.* +homeassistant.components.met_eireann.* homeassistant.components.metoffice.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 042eb6f458f..5edecbbac0b 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,7 +1,8 @@ """The met_eireann component.""" from datetime import timedelta import logging -from typing import Self +from types import MappingProxyType +from typing import Any, Self import meteireann @@ -32,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b altitude=config_entry.data[CONF_ELEVATION], ) - weather_data = MetEireannWeatherData(hass, config_entry.data, raw_weather_data) + weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) async def _async_update_data() -> MetEireannWeatherData: """Fetch data from Met Éireann.""" @@ -70,14 +71,15 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class MetEireannWeatherData: """Keep data for Met Éireann weather entities.""" - def __init__(self, hass, config, weather_data): + def __init__( + self, config: MappingProxyType[str, Any], weather_data: meteireann.WeatherData + ) -> None: """Initialise the weather entity data.""" - self.hass = hass self._config = config self._weather_data = weather_data - self.current_weather_data = {} - self.daily_forecast = None - self.hourly_forecast = None + self.current_weather_data: dict[str, Any] = {} + self.daily_forecast: list[dict[str, Any]] = [] + self.hourly_forecast: list[dict[str, Any]] = [] async def fetch_data(self) -> Self: """Fetch data from API - (current weather and forecast).""" diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 909dd4ae955..b4c0102b97e 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -1,9 +1,11 @@ """Config flow to configure Met Éireann component.""" +from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME @@ -14,10 +16,10 @@ class MetEireannFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" - errors = {} - if user_input is not None: # Check if an identical entity is already configured await self.async_set_unique_id( @@ -41,6 +43,5 @@ class MetEireannFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ): int, } ), - errors=errors, ) return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 7602dca8343..84fc20cead7 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -88,7 +88,12 @@ class MetEireannWeather( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, coordinator, config, hourly): + def __init__( + self, + coordinator: DataUpdateCoordinator[MetEireannWeatherData], + config: MappingProxyType[str, Any], + hourly: bool, + ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) self._attr_unique_id = _calculate_unique_id(config, hourly) @@ -103,41 +108,41 @@ class MetEireannWeather( self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, + identifiers={(DOMAIN,)}, # type: ignore[arg-type] manufacturer="Met Éireann", model="Forecast", configuration_url="https://www.met.ie", ) @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return format_condition( self.coordinator.data.current_weather_data.get("condition") ) @property - def native_temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" return self.coordinator.data.current_weather_data.get("temperature") @property - def native_pressure(self): + def native_pressure(self) -> float | None: """Return the pressure.""" return self.coordinator.data.current_weather_data.get("pressure") @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self.coordinator.data.current_weather_data.get("humidity") @property - def native_wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" return self.coordinator.data.current_weather_data.get("wind_speed") @property - def wind_bearing(self): + def wind_bearing(self) -> float | None: """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") diff --git a/mypy.ini b/mypy.ini index 479565ec777..9489375d9ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2411,6 +2411,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.met_eireann.*] +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.metoffice.*] check_untyped_defs = true disallow_incomplete_defs = true From af209fe2b8a9932c6d03bd5f90d36b398f1662df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 09:13:50 +0100 Subject: [PATCH 0374/1544] Migrate Mullvad to has entity name (#107520) --- homeassistant/components/mullvad/binary_sensor.py | 10 +++++++++- homeassistant/components/mullvad/strings.json | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 2ccf754bbbd..264bbe15520 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor 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, @@ -17,7 +18,7 @@ from .const import DOMAIN BINARY_SENSORS = ( BinarySensorEntityDescription( key="mullvad_exit_ip", - name="Mullvad Exit IP", + translation_key="exit_ip", device_class=BinarySensorDeviceClass.CONNECTIVITY, ), ) @@ -40,6 +41,8 @@ async def async_setup_entry( class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): """Represents a Mullvad binary sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator, @@ -50,6 +53,11 @@ class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): super().__init__(coordinator) self.entity_description = entity_description self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name="Mullvad VPN", + manufacturer="Mullvad", + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/mullvad/strings.json b/homeassistant/components/mullvad/strings.json index 7910a40ec35..3e029184155 100644 --- a/homeassistant/components/mullvad/strings.json +++ b/homeassistant/components/mullvad/strings.json @@ -12,5 +12,12 @@ "description": "Set up the Mullvad VPN integration?" } } + }, + "entity": { + "binary_sensor": { + "exit_ip": { + "name": "Exit IP" + } + } } } From 1171a7a3d9e56880c9230c7d84c30ed92a233527 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 09:14:37 +0100 Subject: [PATCH 0375/1544] Migrate kmtronic to has entity name (#107469) --- .../components/kmtronic/strings.json | 7 +++ homeassistant/components/kmtronic/switch.py | 5 +- tests/components/kmtronic/test_switch.py | 49 ++++++++++++------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 6cecea12f22..f3c1f75d818 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -29,5 +29,12 @@ } } } + }, + "entity": { + "switch": { + "relay": { + "name": "Relay {relay_id}" + } + } } } diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index cd1b181803f..144c05e927e 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -32,6 +32,9 @@ async def async_setup_entry( class KMtronicSwitch(CoordinatorEntity, SwitchEntity): """KMtronic Switch Entity.""" + _attr_translation_key = "relay" + _attr_has_entity_name = True + def __init__(self, hub, coordinator, relay, reverse, config_entry_id): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) @@ -46,7 +49,7 @@ class KMtronicSwitch(CoordinatorEntity, SwitchEntity): configuration_url=hub.host, ) - self._attr_name = f"Relay{relay.id}" + self._attr_translation_placeholders = {"relay_id": relay.id} self._attr_unique_id = f"{config_entry_id}_relay{relay.id}" @property diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py index 57a695c5919..cb72aba2704 100644 --- a/tests/components/kmtronic/test_switch.py +++ b/tests/components/kmtronic/test_switch.py @@ -39,15 +39,18 @@ async def test_relay_on_off( text="", ) - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_on", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" # Mocks the response for turning a relay1 off @@ -57,11 +60,14 @@ async def test_relay_on_off( ) await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_off", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" # Mocks the response for turning a relay1 on @@ -71,11 +77,14 @@ async def test_relay_on_off( ) await hass.services.async_call( - "switch", "toggle", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "toggle", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" @@ -95,7 +104,7 @@ async def test_update(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" aioclient_mock.clear_requests() @@ -106,7 +115,7 @@ async def test_update(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" @@ -128,7 +137,7 @@ async def test_failed_update( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" aioclient_mock.clear_requests() @@ -140,7 +149,7 @@ async def test_failed_update( async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == STATE_UNAVAILABLE future += timedelta(minutes=10) @@ -152,7 +161,7 @@ async def test_failed_update( async_fire_time_changed(hass, future) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == STATE_UNAVAILABLE @@ -180,15 +189,18 @@ async def test_relay_on_off_reversed( text="", ) - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_off", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "off" # Mocks the response for turning a relay1 off @@ -198,9 +210,12 @@ async def test_relay_on_off_reversed( ) await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + "switch", + "turn_on", + {"entity_id": "switch.controller_1_1_1_1_relay_1"}, + blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.relay1") + state = hass.states.get("switch.controller_1_1_1_1_relay_1") assert state.state == "on" From 3eb81bc461ed02d896aa85e5eca0eb03d8db3d30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:20:27 -1000 Subject: [PATCH 0376/1544] Add coverage for scanning tags with ESPHome (#107337) --- tests/components/esphome/test_manager.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 474b1a757fb..e3b5c2aa08d 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -87,6 +87,7 @@ async def test_esphome_device_service_calls_allowed( caplog: pytest.LogCaptureFixture, ) -> None: """Test a device with service calls are allowed.""" + await async_setup_component(hass, "tag", {}) entity_info = [] states = [] user_service = [] @@ -202,6 +203,23 @@ async def test_esphome_device_service_calls_allowed( events.clear() caplog.clear() + # Try scanning a tag + events = async_capture_events(hass, "tag_scanned") + device.mock_service_call( + HomeassistantServiceCall( + service="esphome.tag_scanned", + is_event=True, + data={"tag_id": "1234"}, + ) + ) + await hass.async_block_till_done() + assert len(events) == 1 + event = events[0] + assert event.event_type == "tag_scanned" + assert event.data["tag_id"] == "1234" + events.clear() + caplog.clear() + # Try firing events for disallowed domain events = async_capture_events(hass, "wrong.test") device.mock_service_call( From 9ad3c8dbc94f203efafbf8b4d8d4373c28be15fa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 Jan 2024 09:22:43 +0100 Subject: [PATCH 0377/1544] Remove MQTT legacy vacuum support (#107274) --- .../components/mqtt/abbreviations.py | 13 - homeassistant/components/mqtt/strings.json | 10 +- .../{vacuum/schema_state.py => vacuum.py} | 261 ++-- .../components/mqtt/vacuum/__init__.py | 122 -- homeassistant/components/mqtt/vacuum/const.py | 10 - .../components/mqtt/vacuum/schema.py | 41 - .../components/mqtt/vacuum/schema_legacy.py | 496 -------- tests/components/mqtt/test_discovery.py | 8 +- tests/components/mqtt/test_legacy_vacuum.py | 1123 +---------------- .../{test_state_vacuum.py => test_vacuum.py} | 11 +- 10 files changed, 235 insertions(+), 1860 deletions(-) rename homeassistant/components/mqtt/{vacuum/schema_state.py => vacuum.py} (65%) delete mode 100644 homeassistant/components/mqtt/vacuum/__init__.py delete mode 100644 homeassistant/components/mqtt/vacuum/const.py delete mode 100644 homeassistant/components/mqtt/vacuum/schema.py delete mode 100644 homeassistant/components/mqtt/vacuum/schema_legacy.py rename tests/components/mqtt/{test_state_vacuum.py => test_vacuum.py} (98%) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 64d8c27f1de..524448e02a8 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -22,10 +22,6 @@ ABBREVIATIONS = { "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", "clr_temp_cmd_tpl": "color_temp_command_template", - "bat_lev_t": "battery_level_topic", - "bat_lev_tpl": "battery_level_template", - "chrg_t": "charging_topic", - "chrg_tpl": "charging_template", "clrm": "color_mode", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", @@ -33,8 +29,6 @@ ABBREVIATIONS = { "clr_temp_stat_t": "color_temp_state_topic", "clr_temp_tpl": "color_temp_template", "clr_temp_val_tpl": "color_temp_value_template", - "cln_t": "cleaning_topic", - "cln_tpl": "cleaning_template", "cmd_off_tpl": "command_off_template", "cmd_on_tpl": "command_on_template", "cmd_t": "command_topic", @@ -54,19 +48,13 @@ ABBREVIATIONS = { "dir_cmd_tpl": "direction_command_template", "dir_stat_t": "direction_state_topic", "dir_val_tpl": "direction_value_template", - "dock_t": "docked_topic", - "dock_tpl": "docked_template", "dock_cmd_t": "dock_command_topic", "dock_cmd_tpl": "dock_command_template", "e": "encoding", "en": "enabled_by_default", "ent_cat": "entity_category", "ent_pic": "entity_picture", - "err_t": "error_topic", - "err_tpl": "error_template", "evt_typ": "event_types", - "fanspd_t": "fan_speed_topic", - "fanspd_tpl": "fan_speed_template", "fanspd_lst": "fan_speed_list", "flsh_tlng": "flash_time_long", "flsh_tsht": "flash_time_short", @@ -160,7 +148,6 @@ ABBREVIATIONS = { "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", "pl_strt": "payload_start", - "pl_stpa": "payload_start_pause", "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", "pl_ton": "payload_turn_on", diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 5cd7676115b..ce892e97026 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,12 +1,8 @@ { "issues": { - "deprecation_mqtt_legacy_vacuum_yaml": { - "title": "MQTT vacuum entities with legacy schema found in your configuration.yaml", - "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your configuration.yaml and restart Home Assistant to fix this issue." - }, - "deprecation_mqtt_legacy_vacuum_discovery": { - "title": "MQTT vacuum entities with legacy schema added through MQTT discovery", - "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your devices to use the correct schema and restart Home Assistant to fix this issue." + "deprecation_mqtt_schema_vacuum_yaml": { + "title": "MQTT vacuum entities with deprecated `schema` config settings found in your configuration.yaml", + "description": "The `schema` setting for MQTT vacuum entities is deprecated and should be removed. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." }, "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum.py similarity index 65% rename from homeassistant/components/mqtt/vacuum/schema_state.py rename to homeassistant/components/mqtt/vacuum.py index a51429f0c05..96c0871e27b 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -1,10 +1,19 @@ -"""Support for a State MQTT vacuum.""" +"""Support for MQTT vacuums.""" + +# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +# and was removed with HA Core 2024.2.0 +# The use of the schema attribute with MQTT vacuum was deprecated with HA Core 2024.2 +# the attribute will be remove with HA Core 2024.8 + from __future__ import annotations +from collections.abc import Callable +import logging from typing import Any, cast import voluptuous as vol +from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, STATE_CLEANING, @@ -21,58 +30,37 @@ from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, async_get_hass, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads_object -from .. import subscription -from ..config import MQTT_BASE_SCHEMA -from ..const import ( +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN, + CONF_SCHEMA, CONF_STATE_TOPIC, + DOMAIN, ) -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change -from ..models import ReceiveMessage -from ..util import valid_publish_topic -from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services - -SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { - VacuumEntityFeature.START: "start", - VacuumEntityFeature.PAUSE: "pause", - VacuumEntityFeature.STOP: "stop", - VacuumEntityFeature.RETURN_HOME: "return_home", - VacuumEntityFeature.FAN_SPEED: "fan_speed", - VacuumEntityFeature.BATTERY: "battery", - VacuumEntityFeature.STATUS: "status", - VacuumEntityFeature.SEND_COMMAND: "send_command", - VacuumEntityFeature.LOCATE: "locate", - VacuumEntityFeature.CLEAN_SPOT: "clean_spot", -} - -STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} - - -DEFAULT_SERVICES = ( - VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT -) -ALL_SERVICES = ( - DEFAULT_SERVICES - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.SEND_COMMAND +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, + write_state_on_attr_change, ) +from .models import ReceiveMessage +from .util import valid_publish_topic + +LEGACY = "legacy" +STATE = "state" BATTERY = "battery_level" FAN_SPEED = "fan_speed" @@ -102,7 +90,7 @@ CONF_SEND_COMMAND_TOPIC = "send_command_topic" DEFAULT_NAME = "MQTT State Vacuum" DEFAULT_RETAIN = False -DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) + DEFAULT_PAYLOAD_RETURN_TO_BASE = "return_to_base" DEFAULT_PAYLOAD_STOP = "stop" DEFAULT_PAYLOAD_CLEAN_SPOT = "clean_spot" @@ -110,6 +98,52 @@ DEFAULT_PAYLOAD_LOCATE = "locate" DEFAULT_PAYLOAD_START = "start" DEFAULT_PAYLOAD_PAUSE = "pause" +_LOGGER = logging.getLogger(__name__) + +SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { + VacuumEntityFeature.START: "start", + VacuumEntityFeature.PAUSE: "pause", + VacuumEntityFeature.STOP: "stop", + VacuumEntityFeature.RETURN_HOME: "return_home", + VacuumEntityFeature.FAN_SPEED: "fan_speed", + VacuumEntityFeature.BATTERY: "battery", + VacuumEntityFeature.STATUS: "status", + VacuumEntityFeature.SEND_COMMAND: "send_command", + VacuumEntityFeature.LOCATE: "locate", + VacuumEntityFeature.CLEAN_SPOT: "clean_spot", +} + +STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} +DEFAULT_SERVICES = ( + VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.CLEAN_SPOT +) +ALL_SERVICES = ( + DEFAULT_SERVICES + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.SEND_COMMAND +) + + +def services_to_strings( + services: VacuumEntityFeature, + service_to_string: dict[VacuumEntityFeature, str], +) -> list[str]: + """Convert SUPPORT_* service bitmask to list of service strings.""" + return [ + service_to_string[service] + for service in service_to_string + if service & services + ] + + +DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) + _FEATURE_PAYLOADS = { VacuumEntityFeature.START: CONF_PAYLOAD_START, VacuumEntityFeature.STOP: CONF_PAYLOAD_STOP, @@ -119,40 +153,105 @@ _FEATURE_PAYLOADS = { VacuumEntityFeature.RETURN_HOME: CONF_PAYLOAD_RETURN_TO_BASE, } -PLATFORM_SCHEMA_STATE_MODERN = ( - MQTT_BASE_SCHEMA.extend( - { - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_NAME): vol.Any(cv.string, None), - vol.Optional( - CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT - ): cv.string, - vol.Optional( - CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE - ): cv.string, - vol.Optional(CONF_PAYLOAD_START, default=DEFAULT_PAYLOAD_START): cv.string, - vol.Optional(CONF_PAYLOAD_PAUSE, default=DEFAULT_PAYLOAD_PAUSE): cv.string, - vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, - vol.Optional(CONF_STATE_TOPIC): valid_publish_topic, - vol.Optional( - CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS - ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - } - ) - .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) - .extend(MQTT_VACUUM_SCHEMA.schema) +MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( + { + vacuum.ATTR_BATTERY_ICON, + vacuum.ATTR_BATTERY_LEVEL, + vacuum.ATTR_FAN_SPEED, + } ) -DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) +MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" + + +def _fail_legacy_config(discovery: bool) -> Callable[[ConfigType], ConfigType]: + @callback + def _fail_legacy_config_callback(config: ConfigType) -> ConfigType: + """Fail the legacy schema.""" + if CONF_SCHEMA not in config: + return config + + if config[CONF_SCHEMA] == "legacy": + raise vol.Invalid( + "The support for the `legacy` MQTT vacuum schema has been removed" + ) + + if discovery: + return config + + translation_key = "deprecation_mqtt_schema_vacuum_yaml" + hass = async_get_hass() + async_create_issue( + hass, + DOMAIN, + translation_key, + breaks_in_ha_version="2024.8.0", + is_fixable=False, + translation_key=translation_key, + learn_more_url=MQTT_VACUUM_DOCS_URL, + severity=IssueSeverity.WARNING, + ) + return config + + return _fail_legacy_config_callback + + +VACUUM_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional( + CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT + ): cv.string, + vol.Optional(CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE): cv.string, + vol.Optional( + CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE + ): cv.string, + vol.Optional(CONF_PAYLOAD_START, default=DEFAULT_PAYLOAD_START): cv.string, + vol.Optional(CONF_PAYLOAD_PAUSE, default=DEFAULT_PAYLOAD_PAUSE): cv.string, + vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, + vol.Optional(CONF_STATE_TOPIC): valid_publish_topic, + vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS): vol.All( + cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())] + ), + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_SCHEMA): vol.All(vol.Lower, vol.Any(LEGACY, STATE)), + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = vol.All( + _fail_legacy_config(discovery=True), + VACUUM_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), + cv.deprecated(CONF_SCHEMA), +) + +PLATFORM_SCHEMA_MODERN = vol.All( + _fail_legacy_config(discovery=False), + VACUUM_BASE_SCHEMA, + cv.deprecated(CONF_SCHEMA), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT vacuum through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttStateVacuum, + vacuum.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) class MqttStateVacuum(MqttEntity, StateVacuumEntity): @@ -182,12 +281,22 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" - return DISCOVERY_SCHEMA_STATE + return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + + def _strings_to_services( + strings: list[str], string_to_service: dict[str, VacuumEntityFeature] + ) -> VacuumEntityFeature: + """Convert service strings to SUPPORT_* service bitmask.""" + services = VacuumEntityFeature.STATE + for string in strings: + services |= string_to_service[string] + return services + supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = VacuumEntityFeature.STATE | strings_to_services( + self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py deleted file mode 100644 index fabbb9868df..00000000000 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Support for MQTT vacuums.""" - -# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 - -from __future__ import annotations - -import logging - -import voluptuous as vol - -from homeassistant.components import vacuum -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, async_get_hass, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType - -from ..const import DOMAIN -from ..mixins import async_setup_entity_entry_helper -from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE -from .schema_legacy import ( - DISCOVERY_SCHEMA_LEGACY, - PLATFORM_SCHEMA_LEGACY_MODERN, - MqttVacuum, -) -from .schema_state import ( - DISCOVERY_SCHEMA_STATE, - PLATFORM_SCHEMA_STATE_MODERN, - MqttStateVacuum, -) - -_LOGGER = logging.getLogger(__name__) - -MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" - - -# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 -def warn_for_deprecation_legacy_schema( - hass: HomeAssistant, config: ConfigType, discovery: bool -) -> None: - """Warn for deprecation of legacy schema.""" - if config[CONF_SCHEMA] == STATE: - return - - key_suffix = "discovery" if discovery else "yaml" - translation_key = f"deprecation_mqtt_legacy_vacuum_{key_suffix}" - async_create_issue( - hass, - DOMAIN, - translation_key, - breaks_in_ha_version="2024.2.0", - is_fixable=False, - translation_key=translation_key, - learn_more_url=MQTT_VACUUM_DOCS_URL, - severity=IssueSeverity.WARNING, - ) - _LOGGER.warning( - "Deprecated `legacy` schema detected for MQTT vacuum, expected `state` schema, config found: %s", - config, - ) - - -@callback -def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: - """Validate MQTT vacuum schema.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - - schemas = {LEGACY: DISCOVERY_SCHEMA_LEGACY, STATE: DISCOVERY_SCHEMA_STATE} - config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) - hass = async_get_hass() - warn_for_deprecation_legacy_schema(hass, config, True) - return config - - -@callback -def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: - """Validate MQTT vacuum modern schema.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - - schemas = { - LEGACY: PLATFORM_SCHEMA_LEGACY_MODERN, - STATE: PLATFORM_SCHEMA_STATE_MODERN, - } - config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - hass = async_get_hass() - warn_for_deprecation_legacy_schema(hass, config, False) - return config - - -DISCOVERY_SCHEMA = vol.All( - MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery -) - -PLATFORM_SCHEMA_MODERN = vol.All( - MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up MQTT vacuum through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( - hass, - config_entry, - None, - vacuum.DOMAIN, - async_add_entities, - DISCOVERY_SCHEMA, - PLATFORM_SCHEMA_MODERN, - {"legacy": MqttVacuum, "state": MqttStateVacuum}, - ) diff --git a/homeassistant/components/mqtt/vacuum/const.py b/homeassistant/components/mqtt/vacuum/const.py deleted file mode 100644 index 26e11125556..00000000000 --- a/homeassistant/components/mqtt/vacuum/const.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Shared constants.""" -from homeassistant.components import vacuum - -MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( - { - vacuum.ATTR_BATTERY_ICON, - vacuum.ATTR_BATTERY_LEVEL, - vacuum.ATTR_FAN_SPEED, - } -) diff --git a/homeassistant/components/mqtt/vacuum/schema.py b/homeassistant/components/mqtt/vacuum/schema.py deleted file mode 100644 index 78175f61255..00000000000 --- a/homeassistant/components/mqtt/vacuum/schema.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared schema code.""" -from __future__ import annotations - -import voluptuous as vol - -from homeassistant.components.vacuum import VacuumEntityFeature - -from ..const import CONF_SCHEMA - -LEGACY = "legacy" -STATE = "state" - -MQTT_VACUUM_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SCHEMA, default=LEGACY): vol.All( - vol.Lower, vol.Any(LEGACY, STATE) - ) - } -) - - -def services_to_strings( - services: VacuumEntityFeature, - service_to_string: dict[VacuumEntityFeature, str], -) -> list[str]: - """Convert SUPPORT_* service bitmask to list of service strings.""" - return [ - service_to_string[service] - for service in service_to_string - if service & services - ] - - -def strings_to_services( - strings: list[str], string_to_service: dict[str, VacuumEntityFeature] -) -> VacuumEntityFeature: - """Convert service strings to SUPPORT_* service bitmask.""" - services = VacuumEntityFeature(0) - for string in strings: - services |= string_to_service[string] - return services diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py deleted file mode 100644 index ab13de59ede..00000000000 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Support for Legacy MQTT vacuum. - -The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -and is will be removed with HA Core 2024.2.0 -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -import voluptuous as vol - -from homeassistant.components.vacuum import ( - ATTR_STATUS, - ENTITY_ID_FORMAT, - VacuumEntity, - VacuumEntityFeature, -) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.typing import ConfigType - -from .. import subscription -from ..config import MQTT_BASE_SCHEMA -from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change -from ..models import ( - MqttValueTemplate, - PayloadSentinel, - ReceiveMessage, - ReceivePayloadType, -) -from ..util import valid_publish_topic -from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services - -SERVICE_TO_STRING = { - VacuumEntityFeature.TURN_ON: "turn_on", - VacuumEntityFeature.TURN_OFF: "turn_off", - VacuumEntityFeature.PAUSE: "pause", - VacuumEntityFeature.STOP: "stop", - VacuumEntityFeature.RETURN_HOME: "return_home", - VacuumEntityFeature.FAN_SPEED: "fan_speed", - VacuumEntityFeature.BATTERY: "battery", - VacuumEntityFeature.STATUS: "status", - VacuumEntityFeature.SEND_COMMAND: "send_command", - VacuumEntityFeature.LOCATE: "locate", - VacuumEntityFeature.CLEAN_SPOT: "clean_spot", -} - -STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()} - -DEFAULT_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT -) -ALL_SERVICES = ( - DEFAULT_SERVICES - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.SEND_COMMAND -) - -CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES -CONF_BATTERY_LEVEL_TEMPLATE = "battery_level_template" -CONF_BATTERY_LEVEL_TOPIC = "battery_level_topic" -CONF_CHARGING_TEMPLATE = "charging_template" -CONF_CHARGING_TOPIC = "charging_topic" -CONF_CLEANING_TEMPLATE = "cleaning_template" -CONF_CLEANING_TOPIC = "cleaning_topic" -CONF_DOCKED_TEMPLATE = "docked_template" -CONF_DOCKED_TOPIC = "docked_topic" -CONF_ERROR_TEMPLATE = "error_template" -CONF_ERROR_TOPIC = "error_topic" -CONF_FAN_SPEED_LIST = "fan_speed_list" -CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" -CONF_FAN_SPEED_TOPIC = "fan_speed_topic" -CONF_PAYLOAD_CLEAN_SPOT = "payload_clean_spot" -CONF_PAYLOAD_LOCATE = "payload_locate" -CONF_PAYLOAD_RETURN_TO_BASE = "payload_return_to_base" -CONF_PAYLOAD_START_PAUSE = "payload_start_pause" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_PAYLOAD_TURN_OFF = "payload_turn_off" -CONF_PAYLOAD_TURN_ON = "payload_turn_on" -CONF_SEND_COMMAND_TOPIC = "send_command_topic" -CONF_SET_FAN_SPEED_TOPIC = "set_fan_speed_topic" - -DEFAULT_NAME = "MQTT Vacuum" -DEFAULT_PAYLOAD_CLEAN_SPOT = "clean_spot" -DEFAULT_PAYLOAD_LOCATE = "locate" -DEFAULT_PAYLOAD_RETURN_TO_BASE = "return_to_base" -DEFAULT_PAYLOAD_START_PAUSE = "start_pause" -DEFAULT_PAYLOAD_STOP = "stop" -DEFAULT_PAYLOAD_TURN_OFF = "turn_off" -DEFAULT_PAYLOAD_TURN_ON = "turn_on" -DEFAULT_RETAIN = False -DEFAULT_SERVICE_STRINGS = services_to_strings(DEFAULT_SERVICES, SERVICE_TO_STRING) - -MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED = MQTT_VACUUM_ATTRIBUTES_BLOCKED | frozenset( - {ATTR_STATUS} -) - -PLATFORM_SCHEMA_LEGACY_MODERN = ( - MQTT_BASE_SCHEMA.extend( - { - vol.Inclusive(CONF_BATTERY_LEVEL_TEMPLATE, "battery"): cv.template, - vol.Inclusive(CONF_BATTERY_LEVEL_TOPIC, "battery"): valid_publish_topic, - vol.Inclusive(CONF_CHARGING_TEMPLATE, "charging"): cv.template, - vol.Inclusive(CONF_CHARGING_TOPIC, "charging"): valid_publish_topic, - vol.Inclusive(CONF_CLEANING_TEMPLATE, "cleaning"): cv.template, - vol.Inclusive(CONF_CLEANING_TOPIC, "cleaning"): valid_publish_topic, - vol.Inclusive(CONF_DOCKED_TEMPLATE, "docked"): cv.template, - vol.Inclusive(CONF_DOCKED_TOPIC, "docked"): valid_publish_topic, - vol.Inclusive(CONF_ERROR_TEMPLATE, "error"): cv.template, - vol.Inclusive(CONF_ERROR_TOPIC, "error"): valid_publish_topic, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Inclusive(CONF_FAN_SPEED_TEMPLATE, "fan_speed"): cv.template, - vol.Inclusive(CONF_FAN_SPEED_TOPIC, "fan_speed"): valid_publish_topic, - vol.Optional(CONF_NAME): vol.Any(cv.string, None), - vol.Optional( - CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT - ): cv.string, - vol.Optional( - CONF_PAYLOAD_LOCATE, default=DEFAULT_PAYLOAD_LOCATE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_RETURN_TO_BASE, default=DEFAULT_PAYLOAD_RETURN_TO_BASE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_START_PAUSE, default=DEFAULT_PAYLOAD_START_PAUSE - ): cv.string, - vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional( - CONF_PAYLOAD_TURN_OFF, default=DEFAULT_PAYLOAD_TURN_OFF - ): cv.string, - vol.Optional( - CONF_PAYLOAD_TURN_ON, default=DEFAULT_PAYLOAD_TURN_ON - ): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, - vol.Optional( - CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS - ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - } - ) - .extend(MQTT_ENTITY_COMMON_SCHEMA.schema) - .extend(MQTT_VACUUM_SCHEMA.schema) -) - -DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend( - {}, extra=vol.REMOVE_EXTRA -) - - -_COMMANDS = { - VacuumEntityFeature.TURN_ON: { - "payload": CONF_PAYLOAD_TURN_ON, - "status": "Cleaning", - }, - VacuumEntityFeature.TURN_OFF: { - "payload": CONF_PAYLOAD_TURN_OFF, - "status": "Turning Off", - }, - VacuumEntityFeature.STOP: { - "payload": CONF_PAYLOAD_STOP, - "status": "Stopping the current task", - }, - VacuumEntityFeature.CLEAN_SPOT: { - "payload": CONF_PAYLOAD_CLEAN_SPOT, - "status": "Cleaning spot", - }, - VacuumEntityFeature.LOCATE: { - "payload": CONF_PAYLOAD_LOCATE, - "status": "Hi, I'm over here!", - }, - VacuumEntityFeature.PAUSE: { - "payload": CONF_PAYLOAD_START_PAUSE, - "status": "Pausing/Resuming cleaning...", - }, - VacuumEntityFeature.RETURN_HOME: { - "payload": CONF_PAYLOAD_RETURN_TO_BASE, - "status": "Returning home...", - }, -} - - -class MqttVacuum(MqttEntity, VacuumEntity): - """Representation of a MQTT-controlled legacy vacuum.""" - - _attr_battery_level = 0 - _attr_is_on = False - _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED - _charging: bool = False - _cleaning: bool = False - _command_topic: str | None - _docked: bool = False - _default_name = DEFAULT_NAME - _entity_id_format = ENTITY_ID_FORMAT - _encoding: str | None - _error: str | None = None - _qos: bool - _retain: bool - _payloads: dict[str, str] - _send_command_topic: str | None - _set_fan_speed_topic: str | None - _state_topics: dict[str, str | None] - _templates: dict[ - str, Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - ] - - @staticmethod - def config_schema() -> vol.Schema: - """Return the config schema.""" - return DISCOVERY_SCHEMA_LEGACY - - def _setup_from_config(self, config: ConfigType) -> None: - """(Re)Setup the entity.""" - supported_feature_strings = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = strings_to_services( - supported_feature_strings, STRING_TO_SERVICE - ) - self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] - self._qos = config[CONF_QOS] - self._retain = config[CONF_RETAIN] - self._encoding = config[CONF_ENCODING] or None - - self._command_topic = config.get(CONF_COMMAND_TOPIC) - self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) - self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) - - self._payloads = { - key: config[key] - for key in ( - CONF_PAYLOAD_TURN_ON, - CONF_PAYLOAD_TURN_OFF, - CONF_PAYLOAD_RETURN_TO_BASE, - CONF_PAYLOAD_STOP, - CONF_PAYLOAD_CLEAN_SPOT, - CONF_PAYLOAD_LOCATE, - CONF_PAYLOAD_START_PAUSE, - ) - } - self._state_topics = { - key: config.get(key) - for key in ( - CONF_BATTERY_LEVEL_TOPIC, - CONF_CHARGING_TOPIC, - CONF_CLEANING_TOPIC, - CONF_DOCKED_TOPIC, - CONF_ERROR_TOPIC, - CONF_FAN_SPEED_TOPIC, - ) - } - self._templates = { - key: MqttValueTemplate( - config[key], entity=self - ).async_render_with_possible_json_value - for key in ( - CONF_BATTERY_LEVEL_TEMPLATE, - CONF_CHARGING_TEMPLATE, - CONF_CLEANING_TEMPLATE, - CONF_DOCKED_TEMPLATE, - CONF_ERROR_TEMPLATE, - CONF_FAN_SPEED_TEMPLATE, - ) - if key in config - } - - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, - { - "_attr_battery_level", - "_attr_fan_speed", - "_attr_is_on", - # We track _attr_status and _charging as they are used to - # To determine the batery_icon. - # We do not need to track _docked as it is - # not leading to entity changes directly. - "_attr_status", - "_charging", - }, - ) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT message.""" - if ( - msg.topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] - and CONF_BATTERY_LEVEL_TEMPLATE in self._config - ): - battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if battery_level and battery_level is not PayloadSentinel.DEFAULT: - self._attr_battery_level = max(0, min(100, int(battery_level))) - - if ( - msg.topic == self._state_topics[CONF_CHARGING_TOPIC] - and CONF_CHARGING_TEMPLATE in self._templates - ): - charging = self._templates[CONF_CHARGING_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if charging and charging is not PayloadSentinel.DEFAULT: - self._charging = cv.boolean(charging) - - if ( - msg.topic == self._state_topics[CONF_CLEANING_TOPIC] - and CONF_CLEANING_TEMPLATE in self._config - ): - cleaning = self._templates[CONF_CLEANING_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if cleaning and cleaning is not PayloadSentinel.DEFAULT: - self._attr_is_on = cv.boolean(cleaning) - - if ( - msg.topic == self._state_topics[CONF_DOCKED_TOPIC] - and CONF_DOCKED_TEMPLATE in self._config - ): - docked = self._templates[CONF_DOCKED_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if docked and docked is not PayloadSentinel.DEFAULT: - self._docked = cv.boolean(docked) - - if ( - msg.topic == self._state_topics[CONF_ERROR_TOPIC] - and CONF_ERROR_TEMPLATE in self._config - ): - error = self._templates[CONF_ERROR_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if error is not PayloadSentinel.DEFAULT: - self._error = cv.string(error) - - if self._docked: - if self._charging: - self._attr_status = "Docked & Charging" - else: - self._attr_status = "Docked" - elif self.is_on: - self._attr_status = "Cleaning" - elif self._error: - self._attr_status = f"Error: {self._error}" - else: - self._attr_status = "Stopped" - - if ( - msg.topic == self._state_topics[CONF_FAN_SPEED_TOPIC] - and CONF_FAN_SPEED_TEMPLATE in self._config - ): - fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if fan_speed and fan_speed is not PayloadSentinel.DEFAULT: - self._attr_fan_speed = str(fan_speed) - - topics_list = {topic for topic in self._state_topics.values() if topic} - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - f"topic{i}": { - "topic": topic, - "msg_callback": message_received, - "qos": self._qos, - "encoding": self._encoding, - } - for i, topic in enumerate(topics_list) - }, - ) - - async def _subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner. - - No need to check VacuumEntityFeature.BATTERY, this won't be called if - battery_level is None. - """ - return icon_for_battery_level( - battery_level=self.battery_level, charging=self._charging - ) - - async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: - """Publish a command.""" - - if self._command_topic is None: - return - - await self.async_publish( - self._command_topic, - self._payloads[_COMMANDS[feature]["payload"]], - qos=self._qos, - retain=self._retain, - encoding=self._encoding, - ) - self._attr_status = _COMMANDS[feature]["status"] - self.async_write_ha_state() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on.""" - await self._async_publish_command(VacuumEntityFeature.TURN_ON) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off.""" - await self._async_publish_command(VacuumEntityFeature.TURN_OFF) - - async def async_stop(self, **kwargs: Any) -> None: - """Stop the vacuum.""" - await self._async_publish_command(VacuumEntityFeature.STOP) - - async def async_clean_spot(self, **kwargs: Any) -> None: - """Perform a spot clean-up.""" - await self._async_publish_command(VacuumEntityFeature.CLEAN_SPOT) - - async def async_locate(self, **kwargs: Any) -> None: - """Locate the vacuum (usually by playing a song).""" - await self._async_publish_command(VacuumEntityFeature.LOCATE) - - async def async_start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - await self._async_publish_command(VacuumEntityFeature.PAUSE) - - async def async_return_to_base(self, **kwargs: Any) -> None: - """Tell the vacuum to return to its dock.""" - await self._async_publish_command(VacuumEntityFeature.RETURN_HOME) - - async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: - """Set fan speed.""" - if ( - self._set_fan_speed_topic is None - or (self.supported_features & VacuumEntityFeature.FAN_SPEED == 0) - or fan_speed not in self.fan_speed_list - ): - return None - - await self.async_publish( - self._set_fan_speed_topic, - fan_speed, - self._qos, - self._retain, - self._encoding, - ) - self._attr_status = f"Setting fan to {fan_speed}..." - self.async_write_ha_state() - - async def async_send_command( - self, - command: str, - params: dict[str, Any] | list[Any] | None = None, - **kwargs: Any, - ) -> None: - """Send a command to a vacuum cleaner.""" - if ( - self._send_command_topic is None - or self.supported_features & VacuumEntityFeature.SEND_COMMAND == 0 - ): - return - if params: - message: dict[str, Any] = {"command": command} - message.update(params) - message_payload = json_dumps(message) - else: - message_payload = command - await self.async_publish( - self._send_command_topic, - message_payload, - self._qos, - self._retain, - self._encoding, - ) - self._attr_status = f"Sending command {message_payload}..." - self.async_write_ha_state() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 017d24a39ce..9acd15eea7c 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -481,11 +481,11 @@ async def test_discover_alarm_control_panel( "vacuum", ), ( - "homeassistant/vacuum/object/bla/config", - '{ "name": "Hello World 17", "obj_id": "hello_id", "state_topic": "test-topic", "schema": "legacy" }', - "vacuum.hello_id", + "homeassistant/valve/object/bla/config", + '{ "name": "Hello World 17", "obj_id": "hello_id", "state_topic": "test-topic" }', + "valve.hello_id", "Hello World 17", - "vacuum", + "valve", ), ( "homeassistant/lock/object/bla/config", diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 61a27c287ac..3e88d4a4335 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,125 +1,23 @@ """The tests for the Legacy Mqtt vacuum platform.""" # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 -# and will be removed with HA Core 2024.2.0 +# and was removed with HA Core 2024.2.0 +# cleanup is planned with HA Core 2025.2 -from copy import deepcopy import json -from typing import Any from unittest.mock import patch import pytest from homeassistant.components import mqtt, vacuum -from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC -from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum -from homeassistant.components.mqtt.vacuum.schema import services_to_strings -from homeassistant.components.mqtt.vacuum.schema_legacy import ( - ALL_SERVICES, - CONF_BATTERY_LEVEL_TOPIC, - CONF_CHARGING_TOPIC, - CONF_CLEANING_TOPIC, - CONF_DOCKED_TOPIC, - CONF_ERROR_TOPIC, - CONF_FAN_SPEED_TOPIC, - CONF_SUPPORTED_FEATURES, - MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, - SERVICE_TO_STRING, -) -from homeassistant.components.vacuum import ( - ATTR_BATTERY_ICON, - ATTR_BATTERY_LEVEL, - ATTR_FAN_SPEED, - ATTR_FAN_SPEED_LIST, - ATTR_STATUS, - VacuumEntityFeature, -) -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType - -from .test_common import ( - help_custom_config, - help_test_availability_when_connection_lost, - help_test_availability_without_topic, - help_test_custom_availability_payload, - help_test_default_availability_payload, - help_test_discovery_broken, - help_test_discovery_removal, - help_test_discovery_update, - help_test_discovery_update_attr, - help_test_discovery_update_unchanged, - help_test_encoding_subscribable_topics, - help_test_entity_debug_info_message, - help_test_entity_device_info_remove, - help_test_entity_device_info_update, - help_test_entity_device_info_with_connection, - help_test_entity_device_info_with_identifier, - help_test_entity_id_update_discovery_update, - help_test_entity_id_update_subscriptions, - help_test_publishing_with_custom_encoding, - help_test_reloadable, - help_test_setting_attribute_via_mqtt_json_message, - help_test_setting_attribute_with_template, - help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_skipped_async_ha_write_state, - help_test_unique_id, - help_test_update_with_json_attrs_bad_json, - help_test_update_with_json_attrs_not_dict, -) +from homeassistant.helpers.typing import DiscoveryInfoType from tests.common import async_fire_mqtt_message -from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import MqttMockHAClientGenerator -DEFAULT_CONFIG = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - CONF_NAME: "mqtttest", - CONF_COMMAND_TOPIC: "vacuum/command", - mqttvacuum.CONF_SEND_COMMAND_TOPIC: "vacuum/send_command", - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "vacuum/state", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value_json.battery_level }}", - mqttvacuum.CONF_CHARGING_TOPIC: "vacuum/state", - mqttvacuum.CONF_CHARGING_TEMPLATE: "{{ value_json.charging }}", - mqttvacuum.CONF_CLEANING_TOPIC: "vacuum/state", - mqttvacuum.CONF_CLEANING_TEMPLATE: "{{ value_json.cleaning }}", - mqttvacuum.CONF_DOCKED_TOPIC: "vacuum/state", - mqttvacuum.CONF_DOCKED_TEMPLATE: "{{ value_json.docked }}", - mqttvacuum.CONF_ERROR_TOPIC: "vacuum/state", - mqttvacuum.CONF_ERROR_TEMPLATE: "{{ value_json.error }}", - mqttvacuum.CONF_FAN_SPEED_TOPIC: "vacuum/state", - mqttvacuum.CONF_FAN_SPEED_TEMPLATE: "{{ value_json.fan_speed }}", - mqttvacuum.CONF_SET_FAN_SPEED_TOPIC: "vacuum/set_fan_speed", - mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"], - } - } -} - -DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} - -DEFAULT_CONFIG_ALL_SERVICES = help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - }, - ), -) - - -def filter_options(default_config: ConfigType, options: set[str]) -> ConfigType: - """Generate a config from a default config with omitted options.""" - options_base: ConfigType = default_config[mqtt.DOMAIN][vacuum.DOMAIN] - config = deepcopy(default_config) - config[mqtt.DOMAIN][vacuum.DOMAIN] = { - key: value for key, value in options_base.items() if key not in options - } - return config +DEFAULT_CONFIG = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} @pytest.fixture(autouse=True) @@ -130,1009 +28,62 @@ def vacuum_platform_only(): @pytest.mark.parametrize( - ("hass_config", "deprecated"), + ("hass_config", "removed"), [ ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "legacy"}}}, True), - ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}, True), + ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}, False), ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "state"}}}, False), ], ) -async def test_deprecation( +async def test_removed_support_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - deprecated: bool, + removed: bool, ) -> None: - """Test that the depration warning for the legacy schema works.""" + """Test that the removed support validation for the legacy schema works.""" assert await mqtt_mock_entry() entity = hass.states.get("vacuum.test") - assert entity is not None - if deprecated: - assert "Deprecated `legacy` schema detected for MQTT vacuum" in caplog.text + if removed: + assert entity is None + assert ( + "The support for the `legacy` MQTT " + "vacuum schema has been removed" in caplog.text + ) else: - assert "Deprecated `legacy` schema detected for MQTT vacuum" not in caplog.text - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) -async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test that the correct supported features.""" - await mqtt_mock_entry() - entity = hass.states.get("vacuum.mqtttest") - entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) - assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - [ - "turn_on", - "turn_off", - "stop", - "return_home", - "battery", - "status", - "clean_spot", - ] - ) + assert entity is not None @pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test simple commands to the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - await common.async_turn_on(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "turn_on", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_turn_off(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "turn_off", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_stop(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with("vacuum/command", "stop", 0, False) - mqtt_mock.async_publish.reset_mock() - - await common.async_clean_spot(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "clean_spot", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_locate(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "locate", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_start_pause(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "start_pause", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_return_to_base(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/command", "return_to_base", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/set_fan_speed", "high", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") - mqtt_mock.async_publish.assert_called_once_with( - "vacuum/send_command", "44 FE 93", 0, False - ) - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command( - hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" - ) - assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == { - "command": "44 FE 93", - "key": "value", - } - - await common.async_send_command( - hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" - ) - assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == { - "command": "44 FE 93", - "key": "value", - } - - -@pytest.mark.parametrize( - "hass_config", + ("config", "removed"), [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING - ) - }, - ), - ) + ({"name": "test", "schema": "legacy"}, True), + ({"name": "test"}, False), + ({"name": "test", "schema": "state"}, False), ], ) -async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_stop(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_locate(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_start_pause(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_return_to_base(hass, "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - with pytest.raises(HomeAssistantError): - await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - -@pytest.mark.parametrize( - "hass_config", - [ - { - "mqtt": { - "vacuum": { - "name": "test", - "schema": "legacy", - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ), - } - } - } - ], -) -async def test_command_without_command_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test commands which are not supported by the vacuum.""" - mqtt_mock = await mqtt_mock_entry() - - await common.async_turn_on(hass, "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - await common.async_set_fan_speed(hass, "low", "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - await common.async_send_command(hass, "some command", "vacuum.test") - mqtt_mock.async_publish.assert_not_called() - mqtt_mock.async_publish.reset_mock() - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.STRING_TO_SERVICE["turn_on"], SERVICE_TO_STRING - ) - }, - ), - ) - ], -) -async def test_attributes_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test attributes which are not supported by the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54, - "cleaning": true, - "docked": false, - "charging": false, - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None - assert state.attributes.get(ATTR_BATTERY_ICON) is None - assert state.attributes.get(ATTR_FAN_SPEED) is None - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54, - "cleaning": true, - "docked": false, - "charging": false, - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_FAN_SPEED) == "max" - - message = """{ - "battery_level": 61, - "docked": true, - "cleaning": false, - "charging": true, - "fan_speed": "min" - }""" - - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 - assert state.attributes.get(ATTR_FAN_SPEED) == "min" - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_battery( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "battery_level": 54 - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_cleaning( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await hass.async_block_till_done() - await mqtt_mock_entry() - - message = """{ - "cleaning": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_ON - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "docked": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG_ALL_SERVICES], -) -async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "charging": true - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-outline" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_fan_speed( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "fan_speed": "max" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED) == "max" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES - VacuumEntityFeature.FAN_SPEED, SERVICE_TO_STRING - ) - }, - ), - ) - ], -) -async def test_status_no_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum. - - If the vacuum doesn't support fan speed, fan speed list should be None. - """ - await mqtt_mock_entry() - - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_error( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test status updates from the vacuum.""" - await mqtt_mock_entry() - - message = """{ - "error": "Error1" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_STATUS) == "Error: Error1" - - message = """{ - "error": "" - }""" - async_fire_mqtt_message(hass, "vacuum/state", message) - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_STATUS) == "Stopped" - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", - }, - ), - ) - ], -) -async def test_battery_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test that you can use non-default templates for battery_level.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") - state = hass.states.get("vacuum.mqtttest") - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" - - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) -async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') - state = hass.states.get("vacuum.mqtttest") - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_STATUS) == "Stopped" - - -@pytest.mark.parametrize( - "hass_config", - [ - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CHARGING_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CLEANING_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_DOCKED_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_ERROR_TEMPLATE}), - filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_FAN_SPEED_TEMPLATE}), - ], -) -async def test_missing_templates( +async def test_removed_support_discovery( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + config: DiscoveryInfoType, + removed: bool, ) -> None: - """Test to make sure missing template is not allowed.""" + """Test that the removed support validation for the legacy schema works.""" assert await mqtt_mock_entry() - assert "some but not all values in the same group of inclusion" in caplog.text + config_payload = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/vacuum/test/config", config_payload) + await hass.async_block_till_done() -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) -async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test availability after MQTT disconnection.""" - await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry, vacuum.DOMAIN - ) + entity = hass.states.get("vacuum.test") - -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) -async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test availability without defined availability topic.""" - await help_test_availability_without_topic( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test availability by default payload with defined topic.""" - await help_test_default_availability_payload( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test availability by custom payload with defined topic.""" - await help_test_custom_availability_payload( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the setting of attribute via MQTT with JSON payload.""" - await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_setting_blocked_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the setting of attribute via MQTT with JSON payload.""" - await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, - ) - - -async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the setting of attribute via MQTT with JSON payload.""" - await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_update_with_json_attrs_not_dict( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes get extracted from a JSON result.""" - await help_test_update_with_json_attrs_not_dict( - hass, - mqtt_mock_entry, - caplog, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -async def test_update_with_json_attrs_bad_json( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes get extracted from a JSON result.""" - await help_test_update_with_json_attrs_bad_json( - hass, - mqtt_mock_entry, - caplog, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -async def test_discovery_update_attr( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test update of discovered MQTTAttributes.""" - await help_test_discovery_update_attr( - hass, - mqtt_mock_entry, - caplog, - vacuum.DOMAIN, - DEFAULT_CONFIG_2, - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - vacuum.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - ], -) -async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test unique id option only creates one vacuum per unique_id.""" - await help_test_unique_id(hass, mqtt_mock_entry, vacuum.DOMAIN) - - -async def test_discovery_removal_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test removal of discovered vacuum.""" - data = json.dumps(DEFAULT_CONFIG_2[mqtt.DOMAIN][vacuum.DOMAIN]) - await help_test_discovery_removal( - hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data - ) - - -async def test_discovery_update_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test update of discovered vacuum.""" - config1 = {"name": "Beer", "command_topic": "test_topic"} - config2 = {"name": "Milk", "command_topic": "test_topic"} - await help_test_discovery_update( - hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, config1, config2 - ) - - -async def test_discovery_update_unchanged_vacuum( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test update of discovered vacuum.""" - data1 = '{ "name": "Beer", "command_topic": "test_topic" }' - with patch( - "homeassistant.components.mqtt.vacuum.schema_legacy.MqttVacuum.discovery_update" - ) as discovery_update: - await help_test_discovery_update_unchanged( - hass, - mqtt_mock_entry, - caplog, - vacuum.DOMAIN, - data1, - discovery_update, + if removed: + assert entity is None + assert ( + "The support for the `legacy` MQTT " + "vacuum schema has been removed" in caplog.text ) - - -@pytest.mark.no_fail_on_log_exception -async def test_discovery_broken( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' - data2 = '{ "name": "Milk", "command_topic": "test_topic" }' - await help_test_discovery_broken( - hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, data2 - ) - - -async def test_entity_device_info_with_connection( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT vacuum device registry integration.""" - await help_test_entity_device_info_with_connection( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_device_info_with_identifier( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT vacuum device registry integration.""" - await help_test_entity_device_info_with_identifier( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_device_info_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test device registry update.""" - await help_test_entity_device_info_update( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_device_info_remove( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test device registry remove.""" - await help_test_entity_device_info_remove( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT subscriptions are managed when entity_id is updated.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "battery_level_topic": "test-topic", - "battery_level_template": "{{ value_json.battery_level }}", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - } - } - } - await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - ["test-topic", "avty-topic"], - ) - - -async def test_entity_id_update_discovery_update( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT discovery update when entity_id is updated.""" - await help_test_entity_id_update_discovery_update( - hass, mqtt_mock_entry, vacuum.DOMAIN, DEFAULT_CONFIG_2 - ) - - -async def test_entity_debug_info_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test MQTT debug info.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: { - "name": "test", - "battery_level_topic": "state-topic", - "battery_level_template": "{{ value_json.battery_level }}", - "command_topic": "command-topic", - "payload_turn_on": "ON", - } - } - } - await help_test_entity_debug_info_message( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - vacuum.SERVICE_TURN_ON, - ) - - -@pytest.mark.parametrize( - ("service", "topic", "parameters", "payload", "template"), - [ - ( - vacuum.SERVICE_TURN_ON, - "command_topic", - None, - "turn_on", - None, - ), - ( - vacuum.SERVICE_CLEAN_SPOT, - "command_topic", - None, - "clean_spot", - None, - ), - ( - vacuum.SERVICE_SET_FAN_SPEED, - "set_fan_speed_topic", - {"fan_speed": "medium"}, - "medium", - None, - ), - ( - vacuum.SERVICE_SEND_COMMAND, - "send_command_topic", - {"command": "custom command"}, - "custom command", - None, - ), - ( - vacuum.SERVICE_TURN_OFF, - "command_topic", - None, - "turn_off", - None, - ), - ], -) -async def test_publishing_with_custom_encoding( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - service: str, - topic: str, - parameters: dict[str, Any], - payload: str, - template: str | None, -) -> None: - """Test publishing MQTT payload with different encoding.""" - domain = vacuum.DOMAIN - config = deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][domain]["supported_features"] = [ - "turn_on", - "turn_off", - "clean_spot", - "fan_speed", - "send_command", - ] - - await help_test_publishing_with_custom_encoding( - hass, - mqtt_mock_entry, - caplog, - domain, - config, - service, - topic, - parameters, - payload, - template, - ) - - -async def test_reloadable( - hass: HomeAssistant, - mqtt_client_mock: MqttMockPahoClient, -) -> None: - """Test reloading the MQTT platform.""" - domain = vacuum.DOMAIN - config = DEFAULT_CONFIG - await help_test_reloadable(hass, mqtt_client_mock, domain, config) - - -@pytest.mark.parametrize( - ("topic", "value", "attribute", "attribute_value"), - [ - (CONF_BATTERY_LEVEL_TOPIC, '{ "battery_level": 60 }', "battery_level", 60), - (CONF_CHARGING_TOPIC, '{ "charging": true }', "status", "Stopped"), - (CONF_CLEANING_TOPIC, '{ "cleaning": true }', "status", "Cleaning"), - (CONF_DOCKED_TOPIC, '{ "docked": true }', "status", "Docked"), - ( - CONF_ERROR_TOPIC, - '{ "error": "some error" }', - "status", - "Error: some error", - ), - ( - CONF_FAN_SPEED_TOPIC, - '{ "fan_speed": "medium" }', - "fan_speed", - "medium", - ), - ], -) -async def test_encoding_subscribable_topics( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - topic: str, - value: str, - attribute: str | None, - attribute_value: Any, -) -> None: - """Test handling of incoming encoded payload.""" - domain = vacuum.DOMAIN - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config[CONF_SUPPORTED_FEATURES] = [ - "turn_on", - "turn_off", - "pause", - "stop", - "return_home", - "battery", - "status", - "locate", - "clean_spot", - "fan_speed", - "send_command", - ] - - await help_test_encoding_subscribable_topics( - hass, - mqtt_mock_entry, - vacuum.DOMAIN, - config, - topic, - value, - attribute, - attribute_value, - skip_raw_test=True, - ) - - -@pytest.mark.parametrize( - "hass_config", - [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], - ids=["platform_key", "listed"], -) -async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test setup manual configured MQTT entity.""" - await mqtt_mock_entry() - platform = vacuum.DOMAIN - assert hass.states.get(f"{platform}.mqtttest") - - -@pytest.mark.parametrize( - "hass_config", - [ - help_custom_config( - vacuum.DOMAIN, - DEFAULT_CONFIG, - ( - { - "availability_topic": "availability-topic", - "json_attributes_topic": "json-attributes-topic", - }, - ), - ) - ], -) -@pytest.mark.parametrize( - ("topic", "payload1", "payload2"), - [ - ("availability-topic", "online", "offline"), - ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), - ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), - ("vacuum/state", '{"docked": true}', '{"docked": false}'), - ("vacuum/state", '{"cleaning": true}', '{"cleaning": false}'), - ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), - ("vacuum/state", '{"error": "some error"}', '{"error": "other error"}'), - ("vacuum/state", '{"charging": true}', '{"charging": false}'), - ], -) -async def test_skipped_async_ha_write_state( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - topic: str, - payload1: str, - payload2: str, -) -> None: - """Test a write state command is only called when there is change.""" - await mqtt_mock_entry() - await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + else: + assert entity is not None diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_vacuum.py similarity index 98% rename from tests/components/mqtt/test_state_vacuum.py rename to tests/components/mqtt/test_vacuum.py index 40bd5158280..f48b0b1b375 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -7,13 +7,14 @@ from unittest.mock import patch import pytest from homeassistant.components import mqtt, vacuum +from homeassistant.components.mqtt import vacuum as mqttvacuum from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC -from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum -from homeassistant.components.mqtt.vacuum.const import MQTT_VACUUM_ATTRIBUTES_BLOCKED -from homeassistant.components.mqtt.vacuum.schema import services_to_strings -from homeassistant.components.mqtt.vacuum.schema_state import ( +from homeassistant.components.mqtt.vacuum import ( ALL_SERVICES, + CONF_SCHEMA, + MQTT_VACUUM_ATTRIBUTES_BLOCKED, SERVICE_TO_STRING, + services_to_strings, ) from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, @@ -586,7 +587,7 @@ async def test_discovery_update_unchanged_vacuum( """Test update of discovered vacuum.""" data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' with patch( - "homeassistant.components.mqtt.vacuum.schema_state.MqttStateVacuum.discovery_update" + "homeassistant.components.mqtt.vacuum.MqttStateVacuum.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( hass, From d260ed938a6f247cc55fe9e66b7a599b0c3cfefa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:30:52 -1000 Subject: [PATCH 0378/1544] Reduce overhead to call entity services (#106908) --- homeassistant/helpers/entity_component.py | 29 +++++++++++++++-------- homeassistant/helpers/entity_platform.py | 26 +++++++++++--------- homeassistant/helpers/service.py | 22 ++++++++++------- tests/helpers/test_service.py | 18 +++++++++----- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index b3eb8722997..e49acc71d07 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from datetime import timedelta +from functools import partial from itertools import chain import logging from types import ModuleType @@ -20,8 +21,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( - EntityServiceResponse, Event, + HassJob, HomeAssistant, ServiceCall, ServiceResponse, @@ -225,13 +226,16 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) + async def handle_service( call: ServiceCall, ) -> ServiceResponse: """Handle the service.""" result = await service.entity_service_call( - self.hass, self._entities, func, call, required_features + self.hass, self._entities, service_func, call, required_features ) if result: @@ -259,16 +263,21 @@ class EntityComponent(Generic[_EntityT]): if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service( - call: ServiceCall, - ) -> EntityServiceResponse | None: - """Handle the service.""" - return await service.entity_service_call( - self.hass, self._entities, func, call, required_features - ) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) self.hass.services.async_register( - self.domain, name, handle_service, schema, supports_response + self.domain, + name, + partial( + service.entity_service_call, + self.hass, + self._entities, + service_func, + required_features=required_features, + ), + schema, + supports_response, ) async def async_setup_platform( diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 1bf7d95135b..89eb44a0459 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from contextvars import ContextVar from datetime import datetime, timedelta +from functools import partial from logging import Logger, getLogger from typing import TYPE_CHECKING, Any, Protocol @@ -20,7 +21,7 @@ from homeassistant.core import ( CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, - EntityServiceResponse, + HassJob, HomeAssistant, ServiceCall, SupportsResponse, @@ -833,18 +834,21 @@ class EntityPlatform: if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: ServiceCall) -> EntityServiceResponse | None: - """Handle the service.""" - return await service.entity_service_call( - self.hass, - self.domain_entities, - func, - call, - required_features, - ) + service_func: str | HassJob[..., Any] + service_func = func if isinstance(func, str) else HassJob(func) self.hass.services.async_register( - self.platform_name, name, handle_service, schema, supports_response + self.platform_name, + name, + partial( + service.entity_service_call, + self.hass, + self.domain_entities, + service_func, + required_features=required_features, + ), + schema, + supports_response, ) async def _update_entity_states(self, now: datetime) -> None: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4813a54ac8b..656b2c21129 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Iterable +from collections.abc import Awaitable, Callable, Iterable import dataclasses from enum import Enum from functools import cache, partial, wraps @@ -29,6 +29,7 @@ from homeassistant.const import ( from homeassistant.core import ( Context, EntityServiceResponse, + HassJob, HomeAssistant, ServiceCall, ServiceResponse, @@ -191,11 +192,14 @@ class ServiceParams(TypedDict): class ServiceTargetSelector: """Class to hold a target selector for a service.""" + __slots__ = ("entity_ids", "device_ids", "area_ids") + def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" - entity_ids: str | list | None = service_call.data.get(ATTR_ENTITY_ID) - device_ids: str | list | None = service_call.data.get(ATTR_DEVICE_ID) - area_ids: str | list | None = service_call.data.get(ATTR_AREA_ID) + service_call_data = service_call.data + entity_ids: str | list | None = service_call_data.get(ATTR_ENTITY_ID) + device_ids: str | list | None = service_call_data.get(ATTR_DEVICE_ID) + area_ids: str | list | None = service_call_data.get(ATTR_AREA_ID) self.entity_ids = ( set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() @@ -790,7 +794,7 @@ def _get_permissible_entity_candidates( async def entity_service_call( hass: HomeAssistant, registered_entities: dict[str, Entity], - func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], + func: str | HassJob, call: ServiceCall, required_features: Iterable[int] | None = None, ) -> EntityServiceResponse | None: @@ -926,7 +930,7 @@ async def entity_service_call( async def _handle_entity_call( hass: HomeAssistant, entity: Entity, - func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], + func: str | HassJob, data: dict | ServiceCall, context: Context, ) -> ServiceResponse: @@ -935,11 +939,11 @@ async def _handle_entity_call( task: asyncio.Future[ServiceResponse] | None if isinstance(func, str): - task = hass.async_run_job( - partial(getattr(entity, func), **data) # type: ignore[arg-type] + task = hass.async_run_hass_job( + HassJob(partial(getattr(entity, func), **data)) # type: ignore[arg-type] ) else: - task = hass.async_run_job(func, entity, data) + task = hass.async_run_hass_job(func, entity, data) # Guard because callback functions do not return a task when passed to # async_run_job. diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 628ead473d7..07e68e081b3 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -19,7 +19,13 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import Context, HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import ( + Context, + HassJob, + HomeAssistant, + ServiceCall, + SupportsResponse, +) from homeassistant.helpers import ( device_registry as dr, entity_registry as er, @@ -803,7 +809,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - await service.entity_service_call( hass, mock_entities, - test_service_mock, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A], ) @@ -822,7 +828,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - await service.entity_service_call( hass, mock_entities, - test_service_mock, + HassJob(test_service_mock), ServiceCall( "test_domain", "test_service", {"entity_id": "light.living_room"} ), @@ -839,7 +845,7 @@ async def test_call_with_both_required_features( await service.entity_service_call( hass, mock_entities, - test_service_mock, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A | SUPPORT_B], ) @@ -858,7 +864,7 @@ async def test_call_with_one_of_required_features( await service.entity_service_call( hass, mock_entities, - test_service_mock, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "all"}), required_features=[SUPPORT_A, SUPPORT_C], ) @@ -879,7 +885,7 @@ async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: await service.entity_service_call( hass, mock_entities, - test_service_mock, + HassJob(test_service_mock), ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), ) assert test_service_mock.call_count == 1 From d8c139f21126370c4a9574f045659fd1f86bdde4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 8 Jan 2024 09:31:44 +0100 Subject: [PATCH 0379/1544] Fix language flavors in holiday (#107392) --- homeassistant/components/holiday/calendar.py | 12 +++ tests/components/holiday/test_calendar.py | 85 ++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index bb9a332cb73..e48cc11d677 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -43,6 +43,18 @@ async def async_setup_entry( ) language = lang break + if ( + obj_holidays.supported_languages + and language not in obj_holidays.supported_languages + and (default_language := obj_holidays.default_language) + ): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=default_language, + ) + language = default_language async_add_entities( [ diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index 06011fb8e6b..df0ce6d50d5 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -227,3 +227,88 @@ async def test_no_next_event( assert state is not None assert state.state == "off" assert state.attributes == {"friendly_name": "Germany"} + + +async def test_language_not_exist( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test when language doesn't exist it will fallback to country default language.""" + + hass.config.language = "nb" # Norweigan language "Norks bokmål" + hass.config.country = "NO" + + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "NO"}, + title="Norge", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.norge") + assert state is not None + assert state.state == "on" + assert state.attributes == { + "friendly_name": "Norge", + "all_day": True, + "description": "", + "end_time": "2023-01-02 00:00:00", + "location": "Norge", + "message": "Første nyttårsdag", + "start_time": "2023-01-01 00:00:00", + } + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.norge", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.norge": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Første nyttårsdag", + "location": "Norge", + } + ] + } + } + + # Test with English as exist as optional language for Norway + hass.config.language = "en" + hass.config.country = "NO" + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.norge", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.norge": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Norge", + } + ] + } + } From 3709475cb5eb646608ea863022021a42d253314e Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 8 Jan 2024 00:31:56 -0800 Subject: [PATCH 0380/1544] Enable long term statistics for Flume water usage current sensor (#107512) --- homeassistant/components/flume/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index d4753301213..203c9094b2e 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -37,6 +37,7 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( translation_key="current_interval", suggested_display_precision=2, native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="month_to_date", From 442eb68d92c712f961d21a948c5a391494aebf96 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:35:47 +0100 Subject: [PATCH 0381/1544] Fix asyncio.gather call (#107500) --- homeassistant/components/microsoft_face/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 6e47ad79f5b..af0567f99a1 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine import json import logging +from typing import Any import aiohttp from aiohttp.hdrs import CONTENT_TYPE @@ -267,11 +269,11 @@ class MicrosoftFace: """Store group/person data and IDs.""" return self._store - async def update_store(self): + async def update_store(self) -> None: """Load all group/person data into local store.""" groups = await self.call_api("get", "persongroups") - remove_tasks = [] + remove_tasks: list[Coroutine[Any, Any, None]] = [] new_entities = [] for group in groups: g_id = group["personGroupId"] @@ -293,7 +295,7 @@ class MicrosoftFace: self._store[g_id][person["name"]] = person["personId"] if remove_tasks: - await asyncio.gather(remove_tasks) + await asyncio.gather(*remove_tasks) await self._component.async_add_entities(new_entities) async def call_api(self, method, function, data=None, binary=False, params=None): From 40e1bab0acbb2d0ef53f627524c8a656dfd11b96 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 8 Jan 2024 09:36:17 +0100 Subject: [PATCH 0382/1544] Remove deprecated YAML for freebox (#107497) --- homeassistant/components/freebox/__init__.py | 53 +------------------ .../components/freebox/config_flow.py | 6 --- tests/components/freebox/test_config_flow.py | 25 +-------- 3 files changed, 3 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 5465d524faf..bcfbfdbec28 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -3,70 +3,21 @@ from datetime import timedelta import logging from freebox_api.exceptions import HttpRequestError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - Event, - HomeAssistant, - ServiceCall, -) +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS, SERVICE_REBOOT from .router import FreeboxRouter, get_api -FREEBOX_SCHEMA = vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))}, - ), - extra=vol.ALLOW_EXTRA, -) - SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Freebox integration.""" - if DOMAIN in config: - for entry_config in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Freebox", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Freebox entry.""" api = await get_api(hass, entry.data[CONF_HOST]) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 2260e69cc3c..59b5d65710a 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -98,12 +98,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="link", errors=errors) - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Import a config entry.""" - return await self.async_step_user(user_input) - async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index 9d6f95b2559..6a90bbd9ba8 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -11,7 +11,7 @@ from freebox_api.exceptions import ( from homeassistant import data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.freebox.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -58,17 +58,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["step_id"] == "link" -async def test_import(hass: HomeAssistant) -> None: - """Test import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - async def test_zeroconf(hass: HomeAssistant) -> None: """Test zeroconf step.""" result = await hass.config_entries.flow.async_init( @@ -83,8 +72,6 @@ async def test_zeroconf(hass: HomeAssistant) -> None: async def test_link(hass: HomeAssistant, router: Mock) -> None: """Test linking.""" with patch( - "homeassistant.components.freebox.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.freebox.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -101,7 +88,6 @@ async def test_link(hass: HomeAssistant, router: Mock) -> None: assert result["data"][CONF_HOST] == MOCK_HOST assert result["data"][CONF_PORT] == MOCK_PORT - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -113,15 +99,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: unique_id=MOCK_HOST, ).add_to_hass(hass) - # Should fail, same MOCK_HOST (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - # Should fail, same MOCK_HOST (flow) result = await hass.config_entries.flow.async_init( DOMAIN, From ea4143154be338bbc4d589fa0cf3ce684cc81bfc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Jan 2024 22:42:28 -1000 Subject: [PATCH 0383/1544] Handle unknown state in HomeKit (#107039) --- .../components/homekit/accessories.py | 3 +- .../components/homekit/type_thermostats.py | 6 +- tests/components/homekit/test_type_covers.py | 8 +- tests/components/homekit/test_type_locks.py | 4 +- .../homekit/test_type_thermostats.py | 144 ++++++++++++++++++ 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index a14e0add488..470bb78874c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -36,6 +36,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, __version__, ) @@ -506,7 +507,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] _LOGGER.debug("New_state: %s", new_state) # HomeKit handles unavailable state via the available property # so we should not propagate it here - if new_state is None or new_state.state == STATE_UNAVAILABLE: + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return battery_state = None battery_charging_state = None diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 1fc8b3f2430..4dcc6fb8f65 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -54,6 +54,8 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, ) from homeassistant.core import State, callback @@ -167,7 +169,9 @@ HEAT_COOL_DEADBAND = 5 def _hk_hvac_mode_from_state(state: State) -> int | None: """Return the equivalent HomeKit HVAC mode for a given state.""" - if not (hvac_mode := try_parse_enum(HVACMode, state.state)): + if (current_state := state.state) in (STATE_UNKNOWN, STATE_UNAVAILABLE): + return None + if not (hvac_mode := try_parse_enum(HVACMode, current_state)): _LOGGER.error( "%s: Received invalid HVAC mode: %s", state.entity_id, state.state ) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index a44db05a37b..989e4dd01d3 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -368,22 +368,22 @@ async def test_windowcovering_cover_set_tilt( assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: None}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 100}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 90 assert acc.char_target_tilt.value == 90 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 50}) await hass.async_block_till_done() assert acc.char_current_tilt.value == 0 assert acc.char_target_tilt.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0}) + hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_TILT_POSITION: 0}) await hass.async_block_till_done() assert acc.char_current_tilt.value == -90 assert acc.char_target_tilt.value == -90 diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index dc614ee54c4..7bdfd6c5803 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -66,14 +66,14 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_current_state.value == 3 + assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 0 # Unavailable should keep last state # but set the accessory to not available hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() - assert acc.char_current_state.value == 3 + assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 0 assert acc.available is False diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 5bfbe0b1627..0bea0144506 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1,6 +1,7 @@ """Test different accessory types: Thermostats.""" from unittest.mock import patch +from pyhap.characteristic import Characteristic from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -68,6 +69,8 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfTemperature, ) from homeassistant.core import CoreState, HomeAssistant @@ -2446,3 +2449,144 @@ async def test_thermostat_with_supported_features_target_temp_but_fan_mode_set( assert acc.ordered_fan_speeds == [] assert not acc.fan_chars + + +async def test_thermostat_handles_unknown_state( + hass: HomeAssistant, hk_driver, events +) -> None: + """Test a thermostat can handle unknown state.""" + entity_id = "climate.test" + attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_MIN_TEMP: 44.6, + ATTR_MAX_TEMP: 95, + ATTR_PRESET_MODES: ["home", "away"], + ATTR_TEMPERATURE: 67, + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_FAN_MODES: None, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_PRESET_MODE: "home", + ATTR_FRIENDLY_NAME: "Rec Room", + ATTR_HVAC_MODES: [ + HVACMode.OFF, + HVACMode.HEAT, + ], + } + + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + heat_cool_char: Characteristic = acc.char_target_heat_cool + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is False + + hass.states.async_set( + entity_id, + HVACMode.OFF, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is True + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_OFF + assert acc.available is False + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: heat_cool_char.to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: HC_HEAT_COOL_HEAT, + } + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is False + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + attrs, + ) + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is True + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: heat_cool_char.to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: HC_HEAT_COOL_HEAT, + } + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert heat_cool_char.value == HC_HEAT_COOL_HEAT + assert acc.available is True + assert call_set_hvac_mode + assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT From fde7a6e9ef288cec56457369eb44e549f8e91a1a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:45:37 +0100 Subject: [PATCH 0384/1544] Improve dispatcher typing (#106872) --- homeassistant/components/cast/const.py | 19 +++- homeassistant/components/cloud/__init__.py | 5 +- homeassistant/components/cloud/const.py | 8 +- homeassistant/helpers/dispatcher.py | 117 +++++++++++++++++++-- tests/helpers/test_dispatcher.py | 27 +++++ 5 files changed, 161 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index e8e38a6e72b..730757de8b4 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,4 +1,15 @@ """Consts for Cast integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pychromecast.controllers.homeassistant import HomeAssistantController + +from homeassistant.helpers.dispatcher import SignalType + +if TYPE_CHECKING: + from .helpers import ChromecastInfo + DOMAIN = "cast" @@ -14,14 +25,16 @@ CAST_BROWSER_KEY = "cast_browser" # Dispatcher signal fired with a ChromecastInfo every time we discover a new # Chromecast or receive it through configuration -SIGNAL_CAST_DISCOVERED = "cast_discovered" +SIGNAL_CAST_DISCOVERED: SignalType[ChromecastInfo] = SignalType("cast_discovered") # Dispatcher signal fired with a ChromecastInfo every time a Chromecast is # removed -SIGNAL_CAST_REMOVED = "cast_removed" +SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed") # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. -SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" +SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ + HomeAssistantController, str, str, str | None +] = SignalType("cast_show_view") CONF_IGNORE_CEC = "ignore_cec" CONF_KNOWN_HOSTS = "known_hosts" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 6e5cddd0f28..76369c07e8e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -69,7 +70,9 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" -SIGNAL_CLOUD_CONNECTION_STATE = "CLOUD_CONNECTION_STATE" +SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType( + "CLOUD_CONNECTION_STATE" +) STARTUP_REPAIR_DELAY = 1 # 1 hour diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index db964607923..da012c20bab 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,4 +1,10 @@ """Constants for the cloud component.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.dispatcher import SignalType + DOMAIN = "cloud" DATA_PLATFORMS_SETUP = "cloud_platforms_setup" REQUEST_TIMEOUT = 10 @@ -64,6 +70,6 @@ CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" MODE_DEV = "development" MODE_PROD = "production" -DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" +DISPATCHER_REMOTE_UPDATE: SignalType[Any] = SignalType("cloud_remote_update") STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 07112226ecf..59d680a60ee 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,30 +2,73 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from dataclasses import dataclass from functools import partial import logging -from typing import Any +from typing import Any, Generic, TypeVarTuple, overload from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.logging import catch_log_exception +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" + +@dataclass(frozen=True) +class SignalType(Generic[*_Ts]): + """Generic string class for signal to improve typing.""" + + name: str + + def __hash__(self) -> int: + """Return hash of name.""" + + return hash(self.name) + + def __eq__(self, other: Any) -> bool: + """Check equality for dict keys to be compatible with str.""" + + if isinstance(other, str): + return self.name == other + if isinstance(other, SignalType): + return self.name == other.name + return False + + _DispatcherDataType = dict[ - str, + SignalType[*_Ts] | str, dict[ - Callable[..., Any], + Callable[[*_Ts], Any] | Callable[..., Any], HassJob[..., None | Coroutine[Any, Any, None]] | None, ], ] +@overload +@bind_hass +def dispatcher_connect( + hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None] +) -> Callable[[], None]: + ... + + +@overload @bind_hass def dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., None] +) -> Callable[[], None]: + ... + + +@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def +def dispatcher_connect( + hass: HomeAssistant, + signal: SignalType[*_Ts], + target: Callable[[*_Ts], None], ) -> Callable[[], None]: """Connect a callable function to a signal.""" async_unsub = run_callback_threadsafe( @@ -41,9 +84,9 @@ def dispatcher_connect( @callback def _async_remove_dispatcher( - dispatchers: _DispatcherDataType, - signal: str, - target: Callable[..., Any], + dispatchers: _DispatcherDataType[*_Ts], + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], ) -> None: """Remove signal listener.""" try: @@ -59,10 +102,30 @@ def _async_remove_dispatcher( _LOGGER.warning("Unable to remove unknown dispatcher %s", target) +@overload +@callback +@bind_hass +def async_dispatcher_connect( + hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], Any] +) -> Callable[[], None]: + ... + + +@overload @callback @bind_hass def async_dispatcher_connect( hass: HomeAssistant, signal: str, target: Callable[..., Any] +) -> Callable[[], None]: + ... + + +@callback +@bind_hass +def async_dispatcher_connect( + hass: HomeAssistant, + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], ) -> Callable[[], None]: """Connect a callable function to a signal. @@ -71,7 +134,7 @@ def async_dispatcher_connect( if DATA_DISPATCHER not in hass.data: hass.data[DATA_DISPATCHER] = {} - dispatchers: _DispatcherDataType = hass.data[DATA_DISPATCHER] + dispatchers: _DispatcherDataType[*_Ts] = hass.data[DATA_DISPATCHER] if signal not in dispatchers: dispatchers[signal] = {} @@ -84,13 +147,29 @@ def async_dispatcher_connect( return partial(_async_remove_dispatcher, dispatchers, signal, target) +@overload +@bind_hass +def dispatcher_send(hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts) -> None: + ... + + +@overload @bind_hass def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: + ... + + +@bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def +def dispatcher_send(hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts) -> None: """Send signal and data.""" hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) -def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: +def _format_err( + signal: SignalType[*_Ts] | str, + target: Callable[[*_Ts], Any] | Callable[..., Any], + *args: Any, +) -> str: """Format error message.""" return "Exception in {} when dispatching '{}': {}".format( # Functions wrapped in partial do not have a __name__ @@ -101,7 +180,7 @@ def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: def _generate_job( - signal: str, target: Callable[..., Any] + signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any] ) -> HassJob[..., None | Coroutine[Any, Any, None]]: """Generate a HassJob for a signal and target.""" return HassJob( @@ -110,16 +189,34 @@ def _generate_job( ) +@overload +@callback +@bind_hass +def async_dispatcher_send( + hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts +) -> None: + ... + + +@overload @callback @bind_hass def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: + ... + + +@callback +@bind_hass +def async_dispatcher_send( + hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts +) -> None: """Send signal and data. This method must be run in the event loop. """ if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: return - dispatchers: _DispatcherDataType = maybe_dispatchers + dispatchers: _DispatcherDataType[*_Ts] = maybe_dispatchers if (target_list := dispatchers.get(signal)) is None: return diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index 89d23fb4533..add80c941a1 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -5,6 +5,7 @@ import pytest from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -30,6 +31,32 @@ async def test_simple_function(hass: HomeAssistant) -> None: assert calls == [3, "bla"] +async def test_signal_type(hass: HomeAssistant) -> None: + """Test dispatcher with SignalType.""" + signal: SignalType[str, int] = SignalType("test") + calls: list[tuple[str, int]] = [] + + def test_funct(data1: str, data2: int) -> None: + calls.append((data1, data2)) + + async_dispatcher_connect(hass, signal, test_funct) + async_dispatcher_send(hass, signal, "Hello", 2) + await hass.async_block_till_done() + + assert calls == [("Hello", 2)] + + async_dispatcher_send(hass, signal, "World", 3) + await hass.async_block_till_done() + + assert calls == [("Hello", 2), ("World", 3)] + + # Test compatibility with string keys + async_dispatcher_send(hass, "test", "x", 4) + await hass.async_block_till_done() + + assert calls == [("Hello", 2), ("World", 3), ("x", 4)] + + async def test_simple_function_unsub(hass: HomeAssistant) -> None: """Test simple function (executor) and unsub.""" calls1 = [] From f5d5e1dcbb14489a3439fce4321663e05ad3ae7d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:47:17 +0100 Subject: [PATCH 0385/1544] Enable strict typing for google_assistant_sdk (#107306) --- .strict-typing | 1 + .../components/google_assistant_sdk/helpers.py | 2 +- .../components/google_assistant_sdk/notify.py | 6 +++--- mypy.ini | 10 ++++++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 0da84688727..df1e50b5f6d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -183,6 +183,7 @@ homeassistant.components.gios.* homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* +homeassistant.components.google_assistant_sdk.* homeassistant.components.google_sheets.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index 5ae39c98f3c..a55ff92afe6 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -104,7 +104,7 @@ async def async_send_text_commands( return command_response_list -def default_language_code(hass: HomeAssistant): +def default_language_code(hass: HomeAssistant) -> str: """Get default language code based on Home Assistant config.""" language_code = f"{hass.config.language}-{hass.config.country}" if language_code in SUPPORTED_LANGUAGE_CODES: diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index fa117b579a9..adcd07a0cda 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -24,13 +24,13 @@ LANG_TO_BROADCAST_COMMAND = { } -def broadcast_commands(language_code: str): +def broadcast_commands(language_code: str) -> tuple[str, str]: """Get the commands for broadcasting a message for the given language code. Return type is a tuple where [0] is for broadcasting to your entire home, while [1] is for broadcasting to a specific target. """ - return LANG_TO_BROADCAST_COMMAND.get(language_code.split("-", maxsplit=1)[0]) + return LANG_TO_BROADCAST_COMMAND[language_code.split("-", maxsplit=1)[0]] async def async_get_service( @@ -60,7 +60,7 @@ class BroadcastNotificationService(BaseNotificationService): CONF_LANGUAGE_CODE, default_language_code(self.hass) ) - commands = [] + commands: list[str] = [] targets = kwargs.get(ATTR_TARGET) if not targets: commands.append(broadcast_commands(language_code)[0].format(message)) diff --git a/mypy.ini b/mypy.ini index 9489375d9ee..26bb35d9695 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1591,6 +1591,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_assistant_sdk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_sheets.*] check_untyped_defs = true disallow_incomplete_defs = true From db53237b9ae106b15ed9c063ccab9cebdaff9714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Mon, 8 Jan 2024 05:51:06 -0300 Subject: [PATCH 0386/1544] Bump SunWEG to 2.1.0 (#107459) --- homeassistant/components/sunweg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index de0b3406f05..b681ecc6d5f 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunweg/", "iot_class": "cloud_polling", "loggers": ["sunweg"], - "requirements": ["sunweg==2.0.3"] + "requirements": ["sunweg==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 492db780018..9e9cfb5f2e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2590,7 +2590,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.3 +sunweg==2.1.0 # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b88ddfd9d08..a3b52bece76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1967,7 +1967,7 @@ subarulink==0.7.9 sunwatcher==0.2.1 # homeassistant.components.sunweg -sunweg==2.0.3 +sunweg==2.1.0 # homeassistant.components.surepetcare surepy==0.9.0 From 3958d89ae6bbd07c160a44d7d71e3a235c45b772 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 8 Jan 2024 09:57:01 +0100 Subject: [PATCH 0387/1544] Improve typing for Tado (#106992) --- homeassistant/components/tado/entity.py | 7 ++++--- homeassistant/components/tado/sensor.py | 25 ++++++++++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 532d784b190..417cfe939d4 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -2,6 +2,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from . import TadoConnector from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE @@ -11,7 +12,7 @@ class TadoDeviceEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device_info): + def __init__(self, device_info: dict[str, str]) -> None: """Initialize a Tado device.""" super().__init__() self._device_info = device_info @@ -34,7 +35,7 @@ class TadoHomeEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, tado): + def __init__(self, tado: TadoConnector) -> None: """Initialize a Tado home.""" super().__init__() self.home_name = tado.home_name @@ -54,7 +55,7 @@ class TadoZoneEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, zone_name, home_id, zone_id): + def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None: """Initialize a Tado zone.""" super().__init__() self.zone_name = zone_name diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index a9647c7e6e5..4ff12a6e51d 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import TadoConnector from .const import ( CONDITIONS_MAP, DATA, @@ -60,14 +61,14 @@ def format_condition(condition: str) -> str: return condition -def get_tado_mode(data) -> str | None: +def get_tado_mode(data: dict[str, str]) -> str | None: """Return Tado Mode based on Presence attribute.""" if "presence" in data: return data["presence"] return None -def get_automatic_geofencing(data) -> bool: +def get_automatic_geofencing(data: dict[str, str]) -> bool: """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" if "presenceLocked" in data: if data["presenceLocked"]: @@ -76,7 +77,7 @@ def get_automatic_geofencing(data) -> bool: return False -def get_geofencing_mode(data) -> str: +def get_geofencing_mode(data: dict[str, str]) -> str: """Return Geofencing Mode based on Presence and Presence Locked attributes.""" tado_mode = "" tado_mode = data.get("presence", "unknown") @@ -240,7 +241,9 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription - def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: + def __init__( + self, tado: TadoConnector, entity_description: TadoSensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description super().__init__(tado) @@ -261,13 +264,13 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self._async_update_home_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_home_data() self.async_write_ha_state() @callback - def _async_update_home_data(self): + def _async_update_home_data(self) -> None: """Handle update callbacks.""" try: tado_weather_data = self._tado.data["weather"] @@ -294,9 +297,9 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def __init__( self, - tado, - zone_name, - zone_id, + tado: TadoConnector, + zone_name: str, + zone_id: int, entity_description: TadoSensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" @@ -321,13 +324,13 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): self._async_update_zone_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_zone_data() self.async_write_ha_state() @callback - def _async_update_zone_data(self): + def _async_update_zone_data(self) -> None: """Handle update callbacks.""" try: tado_zone_data = self._tado.data["zone"][self.zone_id] From 265f58776890f8851dc20b83768c360f88d319a6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:58:07 +0100 Subject: [PATCH 0388/1544] Enable strict typing for history_stats (#107273) --- .strict-typing | 1 + homeassistant/components/history_stats/sensor.py | 5 ++++- mypy.ini | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index df1e50b5f6d..8aa14e5b40d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -193,6 +193,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.history_stats.* homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index baa39468bc1..7f318b03e06 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod import datetime +from typing import Any, TypeVar import voluptuous as vol @@ -53,8 +54,10 @@ UNITS: dict[str, str] = { } ICON = "mdi:chart-line" +_T = TypeVar("_T", bound=dict[str, Any]) -def exactly_two_period_keys(conf): + +def exactly_two_period_keys(conf: _T) -> _T: """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: raise vol.Invalid( diff --git a/mypy.ini b/mypy.ini index 26bb35d9695..f1bf9abbb9d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1691,6 +1691,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.history_stats.*] +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.holiday.*] check_untyped_defs = true disallow_incomplete_defs = true From 5ae419367e0d2f2632193ee99f72f6022568dbb0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:59:31 +0100 Subject: [PATCH 0389/1544] Enable strict typing for generic_hygrostat (#107272) --- .strict-typing | 1 + .../generic_hygrostat/humidifier.py | 151 ++++++++++-------- mypy.ini | 10 ++ 3 files changed, 95 insertions(+), 67 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8aa14e5b40d..33e608d38c8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -177,6 +177,7 @@ homeassistant.components.fritzbox_callmonitor.* homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fully_kiosk.* +homeassistant.components.generic_hygrostat.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 3bdecbfa997..095b46245cf 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING, Any from homeassistant.components.humidifier import ( ATTR_HUMIDITY, @@ -27,7 +30,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + Event, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import condition from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -72,22 +81,22 @@ async def async_setup_platform( """Set up the generic hygrostat platform.""" if discovery_info: config = discovery_info - name = config[CONF_NAME] - switch_entity_id = config[CONF_HUMIDIFIER] - sensor_entity_id = config[CONF_SENSOR] - min_humidity = config.get(CONF_MIN_HUMIDITY) - max_humidity = config.get(CONF_MAX_HUMIDITY) - target_humidity = config.get(CONF_TARGET_HUMIDITY) - device_class = config.get(CONF_DEVICE_CLASS) - min_cycle_duration = config.get(CONF_MIN_DUR) - sensor_stale_duration = config.get(CONF_STALE_DURATION) - dry_tolerance = config[CONF_DRY_TOLERANCE] - wet_tolerance = config[CONF_WET_TOLERANCE] - keep_alive = config.get(CONF_KEEP_ALIVE) - initial_state = config.get(CONF_INITIAL_STATE) - away_humidity = config.get(CONF_AWAY_HUMIDITY) - away_fixed = config.get(CONF_AWAY_FIXED) - unique_id = config.get(CONF_UNIQUE_ID) + name: str = config[CONF_NAME] + switch_entity_id: str = config[CONF_HUMIDIFIER] + sensor_entity_id: str = config[CONF_SENSOR] + min_humidity: int | None = config.get(CONF_MIN_HUMIDITY) + max_humidity: int | None = config.get(CONF_MAX_HUMIDITY) + target_humidity: int | None = config.get(CONF_TARGET_HUMIDITY) + device_class: HumidifierDeviceClass | None = config.get(CONF_DEVICE_CLASS) + min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) + sensor_stale_duration: timedelta | None = config.get(CONF_STALE_DURATION) + dry_tolerance: float = config[CONF_DRY_TOLERANCE] + wet_tolerance: float = config[CONF_WET_TOLERANCE] + keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) + initial_state: bool | None = config.get(CONF_INITIAL_STATE) + away_humidity: int | None = config.get(CONF_AWAY_HUMIDITY) + away_fixed: bool | None = config.get(CONF_AWAY_FIXED) + unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -120,28 +129,28 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, - ): + name: str, + switch_entity_id: str, + sensor_entity_id: str, + min_humidity: int | None, + max_humidity: int | None, + target_humidity: int | None, + device_class: HumidifierDeviceClass | None, + min_cycle_duration: timedelta | None, + dry_tolerance: float, + wet_tolerance: float, + keep_alive: timedelta | None, + initial_state: bool | None, + away_humidity: int | None, + away_fixed: bool | None, + sensor_stale_duration: timedelta | None, + unique_id: str | None, + ) -> None: """Initialize the hygrostat.""" self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._device_class = device_class + self._device_class = device_class or HumidifierDeviceClass.HUMIDIFIER self._min_cycle_duration = min_cycle_duration self._dry_tolerance = dry_tolerance self._wet_tolerance = wet_tolerance @@ -149,7 +158,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._state = initial_state self._saved_target_humidity = away_humidity or target_humidity self._active = False - self._cur_humidity = None + self._cur_humidity: float | None = None self._humidity_lock = asyncio.Lock() self._min_humidity = min_humidity self._max_humidity = max_humidity @@ -159,14 +168,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._away_humidity = away_humidity self._away_fixed = away_fixed self._sensor_stale_duration = sensor_stale_duration - self._remove_stale_tracking = None + self._remove_stale_tracking: Callable[[], None] | None = None self._is_away = False - if not self._device_class: - self._device_class = HumidifierDeviceClass.HUMIDIFIER self._attr_action = HumidifierAction.IDLE self._attr_unique_id = unique_id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() @@ -185,7 +192,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) ) - async def _async_startup(event): + async def _async_startup(event: Event | None) -> None: """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) if sensor_state is None or sensor_state.state in ( @@ -234,39 +241,39 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return await super().async_will_remove_from_hass() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._active @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes.""" if self._saved_target_humidity: return {ATTR_SAVED_HUMIDITY: self._saved_target_humidity} return None @property - def name(self): + def name(self) -> str: """Return the name of the hygrostat.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the hygrostat is on.""" return self._state @property - def current_humidity(self): + def current_humidity(self) -> int | None: """Return the measured humidity.""" - return self._cur_humidity + return int(self._cur_humidity) if self._cur_humidity is not None else None @property - def target_humidity(self): + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._target_humidity @property - def mode(self): + def mode(self) -> str | None: """Return the current mode.""" if self._away_humidity is None: return None @@ -275,18 +282,18 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return MODE_NORMAL @property - def available_modes(self): + def available_modes(self) -> list[str] | None: """Return a list of available modes.""" if self._away_humidity: return [MODE_NORMAL, MODE_AWAY] return None @property - def device_class(self): + def device_class(self) -> HumidifierDeviceClass: """Return the device class of the humidifier.""" return self._device_class - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn hygrostat on.""" if not self._active: return @@ -294,7 +301,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_operate(force=True) self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn hygrostat off.""" if not self._active: return @@ -306,7 +313,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if humidity is None: - return + return # type: ignore[unreachable] if self._is_away and self._away_fixed: self._saved_target_humidity = humidity @@ -318,7 +325,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.async_write_ha_state() @property - def min_humidity(self): + def min_humidity(self) -> int: """Return the minimum humidity.""" if self._min_humidity: return self._min_humidity @@ -327,7 +334,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): return super().min_humidity @property - def max_humidity(self): + def max_humidity(self) -> int: """Return the maximum humidity.""" if self._max_humidity: return self._max_humidity @@ -335,7 +342,9 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): # Get default humidity from super class return super().max_humidity - async def _async_sensor_changed(self, entity_id, old_state, new_state): + async def _async_sensor_changed( + self, entity_id: str, old_state: State | None, new_state: State | None + ) -> None: """Handle ambient humidity changes.""" if new_state is None: return @@ -353,18 +362,21 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_operate() self.async_write_ha_state() - async def _async_sensor_not_responding(self, now=None): + async def _async_sensor_not_responding(self, now: datetime | None = None) -> None: """Handle sensor stale event.""" + state = self.hass.states.get(self._sensor_entity_id) _LOGGER.debug( "Sensor has not been updated for %s", - now - self.hass.states.get(self._sensor_entity_id).last_updated, + now - state.last_updated if now and state else "---", ) _LOGGER.warning("Sensor is stalled, call the emergency stop") await self._async_update_humidity("Stalled") @callback - def _async_switch_changed(self, entity_id, old_state, new_state): + def _async_switch_changed( + self, entity_id: str, old_state: State | None, new_state: State | None + ) -> None: """Handle humidifier switch state changes.""" if new_state is None: return @@ -379,7 +391,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.async_schedule_update_ha_state() - async def _async_update_humidity(self, humidity): + async def _async_update_humidity(self, humidity: str) -> None: """Update hygrostat with latest state from sensor.""" try: self._cur_humidity = float(humidity) @@ -390,7 +402,9 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if self._is_device_active: await self._async_device_turn_off() - async def _async_operate(self, time=None, force=False): + async def _async_operate( + self, time: datetime | None = None, force: bool = False + ) -> None: """Check if we need to turn humidifying on or off.""" async with self._humidity_lock: if not self._active and None not in ( @@ -432,12 +446,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if force: # Ignore the tolerance when switched on manually - dry_tolerance = 0 - wet_tolerance = 0 + dry_tolerance: float = 0 + wet_tolerance: float = 0 else: dry_tolerance = self._dry_tolerance wet_tolerance = self._wet_tolerance + if TYPE_CHECKING: + assert self._target_humidity is not None + assert self._cur_humidity is not None too_dry = self._target_humidity - self._cur_humidity >= dry_tolerance too_wet = self._cur_humidity - self._target_humidity >= wet_tolerance if self._is_device_active: @@ -461,16 +478,16 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await self._async_device_turn_off() @property - def _is_device_active(self): + def _is_device_active(self) -> bool: """If the toggleable device is currently active.""" return self.hass.states.is_state(self._switch_entity_id, STATE_ON) - async def _async_device_turn_on(self): + async def _async_device_turn_on(self) -> None: """Turn humidifier toggleable device on.""" data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) - async def _async_device_turn_off(self): + async def _async_device_turn_off(self) -> None: """Turn humidifier toggleable device off.""" data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) diff --git a/mypy.ini b/mypy.ini index f1bf9abbb9d..e68048c8001 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1531,6 +1531,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.generic_hygrostat.*] +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.geo_location.*] check_untyped_defs = true disallow_incomplete_defs = true From 78752264b36395aaeaa183875f9f668678bae20c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:07:30 +0100 Subject: [PATCH 0390/1544] Fully type homeassistant integration (#107380) --- .strict-typing | 3 +- .../components/homeassistant/__init__.py | 8 ++-- .../homeassistant/exposed_entities.py | 2 +- .../components/homeassistant/logbook.py | 2 +- .../components/homeassistant/scene.py | 2 +- .../components/homeassistant/system_health.py | 6 ++- .../components/homeassistant/trigger.py | 2 +- .../homeassistant/triggers/numeric_state.py | 45 ++++++++++++------- .../homeassistant/triggers/state.py | 9 ++-- .../components/homeassistant/triggers/time.py | 6 ++- .../homeassistant/triggers/time_pattern.py | 15 ++++--- homeassistant/helpers/service.py | 2 +- mypy.ini | 12 +---- 13 files changed, 65 insertions(+), 49 deletions(-) diff --git a/.strict-typing b/.strict-typing index 33e608d38c8..501779c4700 100644 --- a/.strict-typing +++ b/.strict-typing @@ -196,8 +196,7 @@ homeassistant.components.here_travel_time.* homeassistant.components.history.* homeassistant.components.history_stats.* homeassistant.components.holiday.* -homeassistant.components.homeassistant.exposed_entities -homeassistant.components.homeassistant.triggers.event +homeassistant.components.homeassistant.* homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index c978a7d4320..0a5649ba26b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -94,8 +94,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no sorted(all_referenced), lambda item: ha.split_entity_id(item)[0] ) - tasks = [] - unsupported_entities = set() + tasks: list[Coroutine[Any, Any, ha.ServiceResponse]] = [] + unsupported_entities: set[str] = set() for domain, ent_ids in by_domain: # This leads to endless loop. @@ -298,7 +298,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: """Service handler for reloading a config entry.""" - reload_entries = set() + reload_entries: set[str] = set() if ATTR_ENTRY_ID in call.data: reload_entries.add(call.data[ATTR_ENTRY_ID]) reload_entries.update(await async_extract_config_entry_ids(hass, call)) @@ -376,7 +376,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no return True -async def _async_stop(hass: ha.HomeAssistant, restart: bool): +async def _async_stop(hass: ha.HomeAssistant, restart: bool) -> None: """Stop home assistant.""" exit_code = RESTART_EXIT_CODE if restart else 0 # Track trask in hass.data. No need to cleanup, we're stopping. diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 926ab5025f6..b53f28f4cee 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -475,7 +475,7 @@ def ws_expose_new_entities_get( def ws_expose_new_entities_set( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose new entities to an assistatant.""" + """Expose new entities to an assistant.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"]) connection.send_result(msg["id"]) diff --git a/homeassistant/components/homeassistant/logbook.py b/homeassistant/components/homeassistant/logbook.py index 229fb24cb27..60e8794799d 100644 --- a/homeassistant/components/homeassistant/logbook.py +++ b/homeassistant/components/homeassistant/logbook.py @@ -28,7 +28,7 @@ def async_describe_events( @callback def async_describe_hass_event(event: Event) -> dict[str, str]: - """Describe homeassisant logbook event.""" + """Describe homeassistant logbook event.""" return { LOGBOOK_ENTRY_NAME: "Home Assistant", LOGBOOK_ENTRY_MESSAGE: EVENT_TO_NAME[event.event_type], diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 9abfefc996f..f8fd901a18a 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -135,7 +135,7 @@ class SceneConfig(NamedTuple): id: str | None name: str icon: str | None - states: dict + states: dict[str, State] @callback diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index 4006228de25..488328b6e4e 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -1,4 +1,8 @@ """Provide info to system health.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import system_info @@ -12,7 +16,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" info = await system_info.async_get_system_info(hass) diff --git a/homeassistant/components/homeassistant/trigger.py b/homeassistant/components/homeassistant/trigger.py index 3160af58079..401da9d01e7 100644 --- a/homeassistant/components/homeassistant/trigger.py +++ b/homeassistant/components/homeassistant/trigger.py @@ -23,7 +23,7 @@ async def async_validate_trigger_config( if hasattr(platform, "async_validate_trigger_config"): return await platform.async_validate_trigger_config(hass, config) - return platform.TRIGGER_SCHEMA(config) + return platform.TRIGGER_SCHEMA(config) # type: ignore[no-any-return] async def async_attach_trigger( diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index d822cd523fc..dad57bbcdb3 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -1,5 +1,10 @@ """Offer numeric state listening automation rules.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta import logging +from typing import Any, TypeVar import voluptuous as vol @@ -13,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers import ( condition, config_validation as cv, @@ -21,14 +26,17 @@ from homeassistant.helpers import ( template, ) from homeassistant.helpers.event import ( + EventStateChangedData, async_track_same_state, async_track_state_change_event, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType + +_T = TypeVar("_T", bound=dict[str, Any]) -def validate_above_below(value): +def validate_above_below(value: _T) -> _T: """Validate that above and below can co-exist.""" above = value.get(CONF_ABOVE) below = value.get(CONF_BELOW) @@ -96,9 +104,9 @@ async def async_attach_trigger( time_delta = config.get(CONF_FOR) template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) - unsub_track_same = {} - armed_entities = set() - period: dict = {} + unsub_track_same: dict[str, Callable[[], None]] = {} + armed_entities: set[str] = set() + period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action, f"numeric state trigger {trigger_info}") @@ -108,7 +116,7 @@ async def async_attach_trigger( if value_template is not None: value_template.hass = hass - def variables(entity_id): + def variables(entity_id: str) -> dict[str, Any]: """Return a dict with trigger variables.""" trigger_info = { "trigger": { @@ -122,7 +130,9 @@ async def async_attach_trigger( return {**_variables, **trigger_info} @callback - def check_numeric_state(entity_id, from_s, to_s): + def check_numeric_state( + entity_id: str, from_s: State | None, to_s: str | State | None + ) -> bool: """Return whether the criteria are met, raise ConditionError if unknown.""" return condition.async_numeric_state( hass, to_s, below, above, value_template, variables(entity_id), attribute @@ -141,14 +151,17 @@ async def async_attach_trigger( ) @callback - def state_automation_listener(event): + def state_automation_listener(event: EventType[EventStateChangedData]) -> None: """Listen for state changes and calls action.""" - entity_id = event.data.get("entity_id") - from_s = event.data.get("old_state") - to_s = event.data.get("new_state") + entity_id = event.data["entity_id"] + from_s = event.data["old_state"] + to_s = event.data["new_state"] + + if to_s is None: + return @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -169,7 +182,9 @@ async def async_attach_trigger( ) @callback - def check_numeric_state_no_raise(entity_id, from_s, to_s): + def check_numeric_state_no_raise( + entity_id: str, from_s: State | None, to_s: State | None + ) -> bool: """Return True if the criteria are now met, False otherwise.""" try: return check_numeric_state(entity_id, from_s, to_s) @@ -216,7 +231,7 @@ async def async_attach_trigger( unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback - def async_remove(): + def async_remove() -> None: """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 2cac07e7cd9..061c2468c30 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -1,6 +1,7 @@ """Offer state listening automation rules.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta import logging @@ -114,7 +115,7 @@ async def async_attach_trigger( match_all = all( item not in config for item in (CONF_FROM, CONF_NOT_FROM, CONF_NOT_TO, CONF_TO) ) - unsub_track_same = {} + unsub_track_same: dict[str, Callable[[], None]] = {} period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) job = HassJob(action, f"state trigger {trigger_info}") @@ -158,7 +159,7 @@ async def async_attach_trigger( return @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -201,7 +202,7 @@ async def async_attach_trigger( ) return - def _check_same_state(_, _2, new_st: State | None) -> bool: + def _check_same_state(_: str, _2: State | None, new_st: State | None) -> bool: if new_st is None: return False @@ -227,7 +228,7 @@ async def async_attach_trigger( unsub = async_track_state_change_event(hass, entity_ids, state_automation_listener) @callback - def async_remove(): + def async_remove() -> None: """Remove state listeners async.""" unsub() for async_remove in unsub_track_same.values(): diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5b3cd8590a7..3cb8809a7ad 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -53,7 +53,9 @@ async def async_attach_trigger( job = HassJob(action, f"time trigger {trigger_info}") @callback - def time_automation_listener(description, now, *, entity_id=None): + def time_automation_listener( + description: str, now: datetime, *, entity_id: str | None = None + ) -> None: """Listen for time changes and calls action.""" hass.async_run_hass_job( job, @@ -183,7 +185,7 @@ async def async_attach_trigger( ) @callback - def remove_track_time_changes(): + def remove_track_time_changes() -> None: """Remove tracked time changes.""" for remove in entities.values(): remove() diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 63f9b18cf9b..d8ac55eb04f 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -1,4 +1,9 @@ """Offer time listening automation rules.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -19,15 +24,15 @@ class TimePattern: :raises Invalid: If the value has a wrong format or is outside the range. """ - def __init__(self, maximum): + def __init__(self, maximum: int) -> None: """Initialize time pattern.""" self.maximum = maximum - def __call__(self, value): + def __call__(self, value: Any) -> str | int: """Validate input.""" try: if value == "*": - return value + return value # type: ignore[no-any-return] if isinstance(value, str) and value.startswith("/"): number = int(value[1:]) @@ -39,7 +44,7 @@ class TimePattern: except ValueError as err: raise vol.Invalid("invalid time_pattern value") from err - return value + return value # type: ignore[no-any-return] TRIGGER_SCHEMA = vol.All( @@ -75,7 +80,7 @@ async def async_attach_trigger( seconds = 0 @callback - def time_automation_listener(now): + def time_automation_listener(now: datetime) -> None: """Listen for time changes and calls action.""" hass.async_run_hass_job( job, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 656b2c21129..dee896ccba2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -520,7 +520,7 @@ def async_extract_referenced_entity_ids( @bind_hass async def async_extract_config_entry_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True -) -> set: +) -> set[str]: """Extract referenced config entry ids from a service call.""" referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) ent_reg = entity_registry.async_get(hass) diff --git a/mypy.ini b/mypy.ini index e68048c8001..3315704745f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1721,17 +1721,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.homeassistant.exposed_entities] -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.homeassistant.triggers.event] +[mypy-homeassistant.components.homeassistant.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true From a6fc4c2bd50cc335f717b02fedc2200060e9e819 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:08:09 +0100 Subject: [PATCH 0391/1544] Improve hassio typing (#107292) --- homeassistant/components/hassio/__init__.py | 13 +-- .../components/hassio/addon_panel.py | 19 ++-- homeassistant/components/hassio/auth.py | 8 +- .../components/hassio/config_flow.py | 8 +- homeassistant/components/hassio/discovery.py | 14 +-- homeassistant/components/hassio/handler.py | 98 ++++++++++--------- homeassistant/components/hassio/ingress.py | 11 ++- .../components/hassio/system_health.py | 3 +- .../components/hassio/websocket_api.py | 10 +- 9 files changed, 102 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3dd9b11ae64..a8e6419a43e 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, + Event, HassJob, HomeAssistant, ServiceCall, @@ -332,7 +333,7 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: @callback @bind_hass -def get_addons_stats(hass): +def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]: """Return Addons stats. Async friendly. @@ -342,7 +343,7 @@ def get_addons_stats(hass): @callback @bind_hass -def get_core_stats(hass): +def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: """Return core stats. Async friendly. @@ -352,7 +353,7 @@ def get_core_stats(hass): @callback @bind_hass -def get_supervisor_stats(hass): +def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: """Return supervisor stats. Async friendly. @@ -362,7 +363,7 @@ def get_supervisor_stats(hass): @callback @bind_hass -def get_addons_changelogs(hass): +def get_addons_changelogs(hass: HomeAssistant): """Return Addons changelogs. Async friendly. @@ -488,7 +489,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: last_timezone = None - async def push_config(_): + async def push_config(_: Event | None) -> None: """Push core config to Hass.io.""" nonlocal last_timezone @@ -986,7 +987,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): enabled_updates[key].add(entity_id) @callback - def _remove(): + def _remove() -> None: for key in types: enabled_updates[key].remove(entity_id) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index b2cf0040be0..8ebf4bf5cca 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus import logging +from typing import Any from aiohttp import web @@ -11,12 +12,12 @@ from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_PANELS, ATTR_TITLE -from .handler import HassioAPIError +from .handler import HassIO, HassioAPIError _LOGGER = logging.getLogger(__name__) -async def async_setup_addon_panel(hass: HomeAssistant, hassio): +async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None: """Add-on Ingress Panel setup.""" hassio_addon_panel = HassIOAddonPanel(hass, hassio) hass.http.register_view(hassio_addon_panel) @@ -26,7 +27,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): return # Register available panels - jobs = [] + jobs: list[asyncio.Task[None]] = [] for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue @@ -46,12 +47,12 @@ class HassIOAddonPanel(HomeAssistantView): name = "api:hassio_push:panel" url = "/api/hassio_push/panel/{addon}" - def __init__(self, hass, hassio): + def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None: """Initialize WebView.""" self.hass = hass self.hassio = hassio - async def post(self, request, addon): + async def post(self, request: web.Request, addon: str) -> web.Response: """Handle new add-on panel requests.""" panels = await self.get_panels() @@ -65,12 +66,12 @@ class HassIOAddonPanel(HomeAssistantView): await _register_panel(self.hass, addon, data) return web.Response() - async def delete(self, request, addon): + async def delete(self, request: web.Request, addon: str) -> web.Response: """Handle remove add-on panel requests.""" frontend.async_remove_panel(self.hass, addon) return web.Response() - async def get_panels(self): + async def get_panels(self) -> dict: """Return panels add-on info data.""" try: data = await self.hassio.get_ingress_panels() @@ -80,7 +81,9 @@ class HassIOAddonPanel(HomeAssistantView): return {} -async def _register_panel(hass, addon, data): +async def _register_panel( + hass: HomeAssistant, addon: str, data: dict[str, Any] +) -> None: """Init coroutine to register the panel.""" await panel_custom.async_register_panel( hass, diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index afe944d03bc..1e20b3da8e5 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_auth_view(hass: HomeAssistant, user: User): +def async_setup_auth_view(hass: HomeAssistant, user: User) -> None: """Auth setup.""" hassio_auth = HassIOAuth(hass, user) hassio_password_reset = HassIOPasswordReset(hass, user) @@ -38,7 +38,7 @@ class HassIOBaseAuth(HomeAssistantView): self.hass = hass self.user = user - def _check_access(self, request: web.Request): + def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" # Check caller IP hassio_ip = os.environ["SUPERVISOR"].split(":")[0] @@ -71,7 +71,7 @@ class HassIOAuth(HassIOBaseAuth): extra=vol.ALLOW_EXTRA, ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle auth requests.""" self._check_access(request) provider = auth_ha.async_get_provider(request.app["hass"]) @@ -101,7 +101,7 @@ class HassIOPasswordReset(HassIOBaseAuth): extra=vol.ALLOW_EXTRA, ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle password reset requests.""" self._check_access(request) provider = auth_ha.async_get_provider(request.app["hass"]) diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index 6ebd42e7610..ef09f07b4de 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -1,7 +1,11 @@ """Config flow for Home Assistant Supervisor integration.""" +from __future__ import annotations + import logging +from typing import Any from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from . import DOMAIN @@ -13,7 +17,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_system(self, user_input=None): + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" # We only need one Hass.io config entry await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 2a5ce2485d1..1810e3ed2c5 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -12,7 +12,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow @@ -33,13 +33,13 @@ class HassioServiceInfo(BaseServiceInfo): @callback -def async_setup_discovery_view(hass: HomeAssistant, hassio): +def async_setup_discovery_view(hass: HomeAssistant, hassio: HassIO) -> None: """Discovery setup.""" hassio_discovery = HassIODiscovery(hass, hassio) hass.http.register_view(hassio_discovery) # Handle exists discovery messages - async def _async_discovery_start_handler(event): + async def _async_discovery_start_handler(event: Event) -> None: """Process all exists discovery on startup.""" try: data = await hassio.retrieve_discovery_messages() @@ -70,7 +70,7 @@ class HassIODiscovery(HomeAssistantView): self.hass = hass self.hassio = hassio - async def post(self, request, uuid): + async def post(self, request: web.Request, uuid: str) -> web.Response: """Handle new discovery requests.""" # Fetch discovery data and prevent injections try: @@ -82,9 +82,9 @@ class HassIODiscovery(HomeAssistantView): await self.async_process_new(data) return web.Response() - async def delete(self, request, uuid): + async def delete(self, request: web.Request, uuid: str) -> web.Response: """Handle remove discovery requests.""" - data = await request.json() + data: dict[str, Any] = await request.json() await self.async_process_del(data) return web.Response() @@ -114,7 +114,7 @@ class HassIODiscovery(HomeAssistantView): data=HassioServiceInfo(config=config_data, name=name, slug=slug, uuid=uuid), ) - async def async_process_del(self, data): + async def async_process_del(self, data: dict[str, Any]) -> None: """Process remove discovery entry.""" service = data[ATTR_SERVICE] uuid = data[ATTR_UUID] diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index fe9e1ba1d2e..653238709cd 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine from http import HTTPStatus import logging import os @@ -10,6 +11,7 @@ from typing import Any import aiohttp from yarl import URL +from homeassistant.auth.models import RefreshToken from homeassistant.components.http import ( CONF_SERVER_HOST, CONF_SERVER_PORT, @@ -62,7 +64,7 @@ async def async_get_addon_info(hass: HomeAssistant, slug: str) -> dict: The add-on must be installed. The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] return await hassio.get_addon_info(slug) @@ -83,7 +85,7 @@ async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> di The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] return await hassio.update_diagnostics(diagnostics) @@ -94,7 +96,7 @@ async def async_install_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/install" return await hassio.send_command(command, timeout=None) @@ -106,7 +108,7 @@ async def async_uninstall_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/uninstall" return await hassio.send_command(command, timeout=60) @@ -122,7 +124,7 @@ async def async_update_addon( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/update" return await hassio.send_command( command, @@ -138,7 +140,7 @@ async def async_start_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/start" return await hassio.send_command(command, timeout=60) @@ -150,7 +152,7 @@ async def async_restart_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/restart" return await hassio.send_command(command, timeout=None) @@ -162,7 +164,7 @@ async def async_stop_addon(hass: HomeAssistant, slug: str) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/stop" return await hassio.send_command(command, timeout=60) @@ -176,7 +178,7 @@ async def async_set_addon_options( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/addons/{slug}/options" return await hassio.send_command(command, payload=options) @@ -184,7 +186,7 @@ async def async_set_addon_options( @bind_hass async def async_get_addon_discovery_info(hass: HomeAssistant, slug: str) -> dict | None: """Return discovery data for an add-on.""" - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] data = await hassio.retrieve_discovery_messages() discovered_addons = data[ATTR_DISCOVERY] return next((addon for addon in discovered_addons if addon["addon"] == slug), None) @@ -199,7 +201,7 @@ async def async_create_backup( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] backup_type = "partial" if partial else "full" command = f"/backups/new/{backup_type}" return await hassio.send_command(command, payload=payload, timeout=None) @@ -212,7 +214,7 @@ async def async_update_os(hass: HomeAssistant, version: str | None = None) -> di The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/os/update" return await hassio.send_command( command, @@ -228,7 +230,7 @@ async def async_update_supervisor(hass: HomeAssistant) -> dict: The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/supervisor/update" return await hassio.send_command(command, timeout=None) @@ -242,7 +244,7 @@ async def async_update_core( The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = "/core/update" return await hassio.send_command( command, @@ -258,7 +260,7 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b The caller of the function should handle HassioAPIError. """ - hassio = hass.data[DOMAIN] + hassio: HassIO = hass.data[DOMAIN] command = f"/resolution/suggestion/{suggestion_uuid}" return await hassio.send_command(command, timeout=None) @@ -330,7 +332,7 @@ class HassIO: self._ip = ip @_api_bool - def is_connected(self): + def is_connected(self) -> Coroutine: """Return true if it connected to Hass.io supervisor. This method returns a coroutine. @@ -338,7 +340,7 @@ class HassIO: return self.send_command("/supervisor/ping", method="get", timeout=15) @api_data - def get_info(self): + def get_info(self) -> Coroutine: """Return generic Supervisor information. This method returns a coroutine. @@ -346,7 +348,7 @@ class HassIO: return self.send_command("/info", method="get") @api_data - def get_host_info(self): + def get_host_info(self) -> Coroutine: """Return data for Host. This method returns a coroutine. @@ -354,7 +356,7 @@ class HassIO: return self.send_command("/host/info", method="get") @api_data - def get_os_info(self): + def get_os_info(self) -> Coroutine: """Return data for the OS. This method returns a coroutine. @@ -362,7 +364,7 @@ class HassIO: return self.send_command("/os/info", method="get") @api_data - def get_core_info(self): + def get_core_info(self) -> Coroutine: """Return data for Home Asssistant Core. This method returns a coroutine. @@ -370,7 +372,7 @@ class HassIO: return self.send_command("/core/info", method="get") @api_data - def get_supervisor_info(self): + def get_supervisor_info(self) -> Coroutine: """Return data for the Supervisor. This method returns a coroutine. @@ -378,7 +380,7 @@ class HassIO: return self.send_command("/supervisor/info", method="get") @api_data - def get_addon_info(self, addon): + def get_addon_info(self, addon: str) -> Coroutine: """Return data for a Add-on. This method returns a coroutine. @@ -386,7 +388,7 @@ class HassIO: return self.send_command(f"/addons/{addon}/info", method="get") @api_data - def get_core_stats(self): + def get_core_stats(self) -> Coroutine: """Return stats for the core. This method returns a coroutine. @@ -394,7 +396,7 @@ class HassIO: return self.send_command("/core/stats", method="get") @api_data - def get_addon_stats(self, addon): + def get_addon_stats(self, addon: str) -> Coroutine: """Return stats for an Add-on. This method returns a coroutine. @@ -402,14 +404,14 @@ class HassIO: return self.send_command(f"/addons/{addon}/stats", method="get") @api_data - def get_supervisor_stats(self): + def get_supervisor_stats(self) -> Coroutine: """Return stats for the supervisor. This method returns a coroutine. """ return self.send_command("/supervisor/stats", method="get") - def get_addon_changelog(self, addon): + def get_addon_changelog(self, addon: str) -> Coroutine: """Return changelog for an Add-on. This method returns a coroutine. @@ -419,7 +421,7 @@ class HassIO: ) @api_data - def get_store(self): + def get_store(self) -> Coroutine: """Return data from the store. This method returns a coroutine. @@ -427,7 +429,7 @@ class HassIO: return self.send_command("/store", method="get") @api_data - def get_ingress_panels(self): + def get_ingress_panels(self) -> Coroutine: """Return data for Add-on ingress panels. This method returns a coroutine. @@ -435,7 +437,7 @@ class HassIO: return self.send_command("/ingress/panels", method="get") @_api_bool - def restart_homeassistant(self): + def restart_homeassistant(self) -> Coroutine: """Restart Home-Assistant container. This method returns a coroutine. @@ -443,7 +445,7 @@ class HassIO: return self.send_command("/homeassistant/restart") @_api_bool - def stop_homeassistant(self): + def stop_homeassistant(self) -> Coroutine: """Stop Home-Assistant container. This method returns a coroutine. @@ -451,7 +453,7 @@ class HassIO: return self.send_command("/homeassistant/stop") @_api_bool - def refresh_updates(self): + def refresh_updates(self) -> Coroutine: """Refresh available updates. This method returns a coroutine. @@ -459,7 +461,7 @@ class HassIO: return self.send_command("/refresh_updates", timeout=None) @api_data - def retrieve_discovery_messages(self): + def retrieve_discovery_messages(self) -> Coroutine: """Return all discovery data from Hass.io API. This method returns a coroutine. @@ -467,7 +469,7 @@ class HassIO: return self.send_command("/discovery", method="get", timeout=60) @api_data - def get_discovery_message(self, uuid): + def get_discovery_message(self, uuid: str) -> Coroutine: """Return a single discovery data message. This method returns a coroutine. @@ -475,7 +477,7 @@ class HassIO: return self.send_command(f"/discovery/{uuid}", method="get") @api_data - def get_resolution_info(self): + def get_resolution_info(self) -> Coroutine: """Return data for Supervisor resolution center. This method returns a coroutine. @@ -483,7 +485,9 @@ class HassIO: return self.send_command("/resolution/info", method="get") @api_data - def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]: + def get_suggestions_for_issue( + self, issue_id: str + ) -> Coroutine[Any, Any, dict[str, Any]]: """Return suggestions for issue from Supervisor resolution center. This method returns a coroutine. @@ -493,7 +497,9 @@ class HassIO: ) @_api_bool - async def update_hass_api(self, http_config, refresh_token): + async def update_hass_api( + self, http_config: dict[str, Any], refresh_token: RefreshToken + ): """Update Home Assistant API data on Hass.io.""" port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT options = { @@ -513,7 +519,7 @@ class HassIO: return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, timezone): + def update_hass_timezone(self, timezone: str) -> Coroutine: """Update Home-Assistant timezone data on Hass.io. This method returns a coroutine. @@ -521,7 +527,7 @@ class HassIO: return self.send_command("/supervisor/options", payload={"timezone": timezone}) @_api_bool - def update_diagnostics(self, diagnostics: bool): + def update_diagnostics(self, diagnostics: bool) -> Coroutine: """Update Supervisor diagnostics setting. This method returns a coroutine. @@ -531,7 +537,7 @@ class HassIO: ) @_api_bool - def apply_suggestion(self, suggestion_uuid: str): + def apply_suggestion(self, suggestion_uuid: str) -> Coroutine: """Apply a suggestion from supervisor's resolution center. This method returns a coroutine. @@ -540,14 +546,14 @@ class HassIO: async def send_command( self, - command, - method="post", - payload=None, - timeout=10, - return_text=False, + command: str, + method: str = "post", + payload: Any | None = None, + timeout: int | None = 10, + return_text: bool = False, *, - source="core.handler", - ): + source: str = "core.handler", + ) -> Any: """Send API command to Hass.io. This method is a coroutine. diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 0c0fe55b686..4f3933d0f5c 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -9,7 +9,7 @@ import logging from urllib.parse import quote import aiohttp -from aiohttp import ClientTimeout, hdrs, web +from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -46,7 +46,7 @@ MAX_SIMPLE_RESPONSE_SIZE = 4194000 @callback -def async_setup_ingress_view(hass: HomeAssistant, host: str): +def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: """Auth setup.""" websession = async_get_clientsession(hass) @@ -281,7 +281,10 @@ def _is_websocket(request: web.Request) -> bool: ) -async def _websocket_forward(ws_from, ws_to): +async def _websocket_forward( + ws_from: web.WebSocketResponse | ClientWebSocketResponse, + ws_to: web.WebSocketResponse | ClientWebSocketResponse, +) -> None: """Handle websocket message directly.""" try: async for msg in ws_from: @@ -294,7 +297,7 @@ async def _websocket_forward(ws_from, ws_to): elif msg.type == aiohttp.WSMsgType.PONG: await ws_to.pong() elif ws_to.closed: - await ws_to.close(code=ws_to.close_code, message=msg.extra) + await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type] except RuntimeError: _LOGGER.debug("Ingress Websocket runtime error") except ConnectionResetError: diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index 74437186ff2..d89224a2476 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from typing import Any from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback @@ -20,7 +21,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass: HomeAssistant): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" info = get_info(hass) or {} host_info = get_host_info(hass) or {} diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 8f44f7f2843..ae04aa0fff5 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -54,7 +54,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) @callback -def async_load_websocket_api(hass: HomeAssistant): +def async_load_websocket_api(hass: HomeAssistant) -> None: """Set up the websocket API.""" websocket_api.async_register_command(hass, websocket_supervisor_event) websocket_api.async_register_command(hass, websocket_supervisor_api) @@ -66,11 +66,11 @@ def async_load_websocket_api(hass: HomeAssistant): @websocket_api.async_response async def websocket_subscribe( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Subscribe to supervisor events.""" @callback - def forward_messages(data): + def forward_messages(data: dict[str, str]) -> None: """Forward events to websocket.""" connection.send_message(websocket_api.event_message(msg[WS_ID], data)) @@ -89,7 +89,7 @@ async def websocket_subscribe( @websocket_api.async_response async def websocket_supervisor_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Publish events from the Supervisor.""" connection.send_result(msg[WS_ID]) async_dispatcher_send(hass, EVENT_SUPERVISOR_EVENT, msg[ATTR_DATA]) @@ -107,7 +107,7 @@ async def websocket_supervisor_event( @websocket_api.async_response async def websocket_supervisor_api( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -): +) -> None: """Websocket handler to call Supervisor API.""" if not connection.user.is_admin and not WS_NO_ADMIN_ENDPOINTS.match( msg[ATTR_ENDPOINT] From 5ef04fcc7b0863f389a3425e3cc9cc3bd6208738 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:08:52 +0100 Subject: [PATCH 0392/1544] Improve hunterdouglas_powerview typing (#107445) --- .../hunterdouglas_powerview/config_flow.py | 30 ++++++++++++------- .../hunterdouglas_powerview/cover.py | 10 +++---- .../hunterdouglas_powerview/scene.py | 13 ++++++-- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 8c6d0fc4dd3..81532187bbf 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging +from typing import Any from aiopvapi.helpers.aiorequest import AioRequest import voluptuous as vol @@ -51,18 +52,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the powerview config flow.""" - self.powerview_config = {} - self.discovered_ip = None - self.discovered_name = None + self.powerview_config: dict[str, str] = {} + self.discovered_ip: str | None = None + self.discovered_name: str | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, Any] = {} if user_input is not None: info, error = await self._async_validate_or_error(user_input[CONF_HOST]) - if not error: + if info and not error: await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} @@ -73,7 +76,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def _async_validate_or_error(self, host): + async def _async_validate_or_error( + self, host: str + ) -> tuple[dict[str, str], None] | tuple[None, str]: self._async_abort_entries_match({CONF_HOST: host}) try: @@ -110,21 +115,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.discovered_name = name return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self): + async def async_step_discovery_confirm(self) -> FlowResult: """Confirm dhcp or homekit discovery.""" # If we already have the host configured do # not open connections to it if we can avoid it. + assert self.discovered_ip and self.discovered_name self.context[CONF_HOST] = self.discovered_ip for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self.discovered_ip: return self.async_abort(reason="already_in_progress") self._async_abort_entries_match({CONF_HOST: self.discovered_ip}) - info, error = await self._async_validate_or_error(self.discovered_ip) if error: return self.async_abort(reason=error) + assert info is not None await self.async_set_unique_id(info["unique_id"], raise_on_progress=False) self._abort_if_unique_id_configured({CONF_HOST: self.discovered_ip}) @@ -134,7 +140,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } return await self.async_step_link() - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with Powerview.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 18fe1cd0a69..6d050bc1dbd 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import logging from math import ceil from typing import Any @@ -137,7 +137,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP - self._forced_resync = None + self._forced_resync: Callable[[], None] | None = None @property def assumed_state(self) -> bool: @@ -291,7 +291,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._async_complete_schedule_update, ) - async def _async_complete_schedule_update(self, _): + async def _async_complete_schedule_update(self, _: datetime) -> None: """Update status of the cover.""" _LOGGER.debug("Processing scheduled update for %s", self.name) self._scheduled_transition_update = None @@ -382,7 +382,7 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): return hd_position_to_hass(self.positions.vane, self._max_tilt) @property - def transition_steps(self): + def transition_steps(self) -> int: """Return the steps to make a move.""" return hd_position_to_hass( self.positions.primary, MAX_POSITION diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 0c09917d35b..4676a8d1505 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -11,8 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import HDEntity -from .model import PowerviewEntryData +from .model import PowerviewDeviceInfo, PowerviewEntryData async def async_setup_entry( @@ -22,7 +23,7 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - pvscenes = [] + pvscenes: list[PowerViewScene] = [] for raw_scene in pv_entry.scene_data.values(): scene = PvScene(raw_scene, pv_entry.api) room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") @@ -37,7 +38,13 @@ class PowerViewScene(HDEntity, Scene): _attr_icon = "mdi:blinds" - def __init__(self, coordinator, device_info, room_name, scene): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + scene: PvScene, + ) -> None: """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene From 3c7a9272fa952cf317267b6dc55f2c7fae608381 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:09:48 +0100 Subject: [PATCH 0393/1544] Enable strict typing for intent (#107282) --- .strict-typing | 1 + homeassistant/components/intent/__init__.py | 23 ++++++++++++++++----- homeassistant/helpers/intent.py | 6 +++--- mypy.ini | 10 +++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/.strict-typing b/.strict-typing index 501779c4700..4a1876bbadf 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* homeassistant.components.integration.* +homeassistant.components.intent.* homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.islamic_prayer_times.* diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 306f169106b..5756b78b4de 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,6 +1,10 @@ """The Intent integration.""" -import logging +from __future__ import annotations +import logging +from typing import Any, Protocol + +from aiohttp import web import voluptuous as vol from homeassistant.components import http @@ -69,6 +73,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class IntentPlatformProtocol(Protocol): + """Define the format that intent platforms can have.""" + + async def async_setup_intents(self, hass: HomeAssistant) -> None: + """Set up platform intents.""" + + class OnOffIntentHandler(intent.ServiceIntentHandler): """Intent handler for on/off that handles covers too.""" @@ -249,7 +260,9 @@ class NevermindIntentHandler(intent.IntentHandler): return intent_obj.create_response() -async def _async_process_intent(hass: HomeAssistant, domain: str, platform): +async def _async_process_intent( + hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol +) -> None: """Process the intents of an integration.""" await platform.async_setup_intents(hass) @@ -268,9 +281,9 @@ class IntentHandleView(http.HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle intent with name/data.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] language = hass.config.language try: @@ -286,7 +299,7 @@ class IntentHandleView(http.HomeAssistantView): intent_result.async_set_speech(str(err)) if intent_result is None: - intent_result = intent.IntentResponse(language=language) + intent_result = intent.IntentResponse(language=language) # type: ignore[unreachable] intent_result.async_set_speech("Sorry, I couldn't handle that") return self.json(intent_result) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 056f972e7f7..ee326558467 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Collection, Iterable +from collections.abc import Collection, Coroutine, Iterable import dataclasses from dataclasses import dataclass from enum import Enum @@ -451,7 +451,7 @@ class ServiceIntentHandler(IntentHandler): else: speech_name = states[0].name - service_coros = [] + service_coros: list[Coroutine[Any, Any, None]] = [] for state in states: service_coros.append(self.async_call_service(intent_obj, state)) @@ -507,7 +507,7 @@ class ServiceIntentHandler(IntentHandler): ) ) - async def _run_then_background(self, task: asyncio.Task) -> None: + async def _run_then_background(self, task: asyncio.Task[Any]) -> None: """Run task with timeout to (hopefully) catch validation errors. After the timeout the task will continue to run in the background. diff --git a/mypy.ini b/mypy.ini index 3315704745f..d0bebf791c3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2041,6 +2041,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.intent.*] +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.ipp.*] check_untyped_defs = true disallow_incomplete_defs = true From 394385fdeb0a30adce026417285ecbc33df14d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Mon, 8 Jan 2024 10:15:30 +0100 Subject: [PATCH 0394/1544] Fix Luftdaten sensor id string (#107506) Luftdaten: fix sensor id string --- homeassistant/components/luftdaten/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index e990142923f..b7d0a90b511 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "station_id": "Sensor ID", + "sensor_id": "Sensor ID", "show_on_map": "Show on map" } } From 5fe96390f54bde28bbfe190f111ca0578ec4af88 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 10:37:17 +0100 Subject: [PATCH 0395/1544] Add zone devices to AnthemAV (#107192) --- .../components/anthemav/media_player.py | 26 ++++++++++++------- .../components/anthemav/test_media_player.py | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index c13e6389bfc..0a9edeb2269 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -50,6 +50,7 @@ class AnthemAVR(MediaPlayerEntity): """Entity reading values from Anthem AVR protocol.""" _attr_has_entity_name = True + _attr_name = None _attr_should_poll = False _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_icon = "mdi:audio-video" @@ -77,18 +78,23 @@ class AnthemAVR(MediaPlayerEntity): self._zone_number = zone_number self._zone = avr.zones[zone_number] if zone_number > 1: - self._attr_name = f"zone {zone_number}" - self._attr_unique_id = f"{mac_address}_{zone_number}" + unique_id = f"{mac_address}_{zone_number}" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Zone {zone_number}", + manufacturer=MANUFACTURER, + model=model, + via_device=(DOMAIN, mac_address), + ) else: - self._attr_name = None self._attr_unique_id = mac_address - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac_address)}, - name=name, - manufacturer=MANUFACTURER, - model=model, - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac_address)}, + name=name, + manufacturer=MANUFACTURER, + model=model, + ) self.set_states() async def async_added_to_hass(self) -> None: diff --git a/tests/components/anthemav/test_media_player.py b/tests/components/anthemav/test_media_player.py index b4e8808f4e9..9dd8af24efb 100644 --- a/tests/components/anthemav/test_media_player.py +++ b/tests/components/anthemav/test_media_player.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry ("entity_id", "entity_name"), [ ("media_player.anthem_av", "Anthem AV"), - ("media_player.anthem_av_zone_2", "Anthem AV zone 2"), + ("media_player.zone_2", "Zone 2"), ], ) async def test_zones_loaded( From 14bf778c10cfb5a1933647aebc5c425a5bb1d5a3 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:37:35 +0100 Subject: [PATCH 0396/1544] Cleanup device registry for tedee when a lock is removed (#106994) * remove removed locks * move duplicated code to function * remove entities by removing device * add new locks automatically * add locks from coordinator * smaller pr * remove snapshot * move lock removal to coordinator * change comment * Update tests/components/tedee/test_init.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_init.py Co-authored-by: Joost Lekkerkerker * test lock unavailable * move logic to function * resolve merge conflicts * no need to call keys() * no need to call keys() * check for change first * readability * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tedee/test_lock.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tedee/coordinator.py | 42 ++++++++++++++----- homeassistant/components/tedee/entity.py | 7 +++- homeassistant/components/tedee/lock.py | 5 --- tests/components/tedee/test_lock.py | 32 +++++++++++++- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 6b4ecdae026..064af41ac89 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN @@ -50,7 +51,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): ) self._next_get_locks = time.time() - self._current_locks: set[int] = set() + self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] @property @@ -84,15 +85,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): ", ".join(map(str, self.tedee_client.locks_dict.keys())), ) - if not self._current_locks: - self._current_locks = set(self.tedee_client.locks_dict.keys()) - - if new_locks := set(self.tedee_client.locks_dict.keys()) - self._current_locks: - _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) - for lock_id in new_locks: - for callback in self.new_lock_callbacks: - callback(lock_id) - + self._async_add_remove_locks() return self.tedee_client.locks_dict async def _async_update(self, update_fn: Callable[[], Awaitable[None]]) -> None: @@ -109,3 +102,32 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): raise UpdateFailed("Error while updating data: %s" % str(ex)) from ex except (TedeeClientException, TimeoutError) as ex: raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex + + def _async_add_remove_locks(self) -> None: + """Add new locks, remove non-existing locks.""" + if not self._locks_last_update: + self._locks_last_update = set(self.tedee_client.locks_dict) + + if ( + current_locks := set(self.tedee_client.locks_dict) + ) == self._locks_last_update: + return + + # remove old locks + if removed_locks := self._locks_last_update - current_locks: + _LOGGER.debug("Removed locks: %s", ", ".join(map(str, removed_locks))) + device_registry = dr.async_get(self.hass) + for lock_id in removed_locks: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, str(lock_id))} + ): + device_registry.async_remove_device(device.id) + + # add new locks + if new_locks := current_locks - self._locks_last_update: + _LOGGER.debug("New locks found: %s", ", ".join(map(str, new_locks))) + for lock_id in new_locks: + for callback in self.new_lock_callbacks: + callback(lock_id) + + self._locks_last_update = current_locks diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index 6dfcbebe3de..ef75affebbc 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -38,9 +38,14 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._lock = self.coordinator.data[self._lock.lock_id] + self._lock = self.coordinator.data.get(self._lock.lock_id, self._lock) super()._handle_coordinator_update() + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + class TedeeDescriptionEntity(TedeeEntity): """Base class for Tedee device entities.""" diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 1025942d787..a01d13c3bbb 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -74,11 +74,6 @@ class TedeeLockEntity(TedeeEntity, LockEntity): """Return true if lock is jammed.""" return self._lock.is_state_jammed - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._lock.is_connected - async def async_unlock(self, **kwargs: Any) -> None: """Unlock the door.""" try: diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 95a57078f56..fca1ae2b07f 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = pytest.mark.usefixtures("init_integration") @@ -210,6 +210,36 @@ async def test_update_failed( assert state.state == STATE_UNAVAILABLE +async def test_cleanup_removed_locks( + hass: HomeAssistant, + mock_tedee: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure removed locks are cleaned up.""" + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + locks = [device.name for device in devices] + assert "Lock-1A2B" in locks + + # remove a lock and wait for coordinator + mock_tedee.locks_dict.pop(12345) + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + locks = [device.name for device in devices] + assert "Lock-1A2B" not in locks + + async def test_new_lock( hass: HomeAssistant, mock_tedee: MagicMock, From 82e0fc5f4ebcd60e54fa36976bb4091fcd277b62 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 8 Jan 2024 10:38:44 +0100 Subject: [PATCH 0397/1544] Use parametrize in drop connect binary sensor tests (#107111) --- .../snapshots/test_binary_sensor.ambr | 358 ++++++++++++++++++ .../drop_connect/test_binary_sensor.py | 246 +++++------- 2 files changed, 451 insertions(+), 153 deletions(-) create mode 100644 tests/components/drop_connect/snapshots/test_binary_sensor.ambr diff --git a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f822cd5e252 --- /dev/null +++ b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr @@ -0,0 +1,358 @@ +# serializer version: 1 +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_leak_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_255_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Hub DROP-1_C0FFEE Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_notification_unread-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_notification_unread', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bell-ring', + 'original_name': 'Notification unread', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pending_notification', + 'unique_id': 'DROP-1_C0FFEE_255_pending_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[hub][binary_sensor.hub_drop_1_c0ffee_notification_unread-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hub DROP-1_C0FFEE Notification unread', + 'icon': 'mdi:bell-ring', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_drop_1_c0ffee_notification_unread', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[leak][binary_sensor.leak_detector_leak_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.leak_detector_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_20_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[leak][binary_sensor.leak_detector_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Leak Detector Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.leak_detector_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[protection_valve][binary_sensor.protection_valve_leak_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.protection_valve_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_78_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[protection_valve][binary_sensor.protection_valve_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Protection Valve Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.protection_valve_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_leak_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.pump_controller_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_83_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Pump Controller Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.pump_controller_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_pump_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.pump_controller_pump_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-pump', + 'original_name': 'Pump status', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'DROP-1_C0FFEE_83_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[pump_controller][binary_sensor.pump_controller_pump_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pump Controller Pump status', + 'icon': 'mdi:water-pump', + }), + 'context': , + 'entity_id': 'binary_sensor.pump_controller_pump_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[ro_filter][binary_sensor.ro_filter_leak_detected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ro_filter_leak_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pipe-leak', + 'original_name': 'Leak detected', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak', + 'unique_id': 'DROP-1_C0FFEE_255_leak', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[ro_filter][binary_sensor.ro_filter_leak_detected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'RO Filter Leak detected', + 'icon': 'mdi:pipe-leak', + }), + 'context': , + 'entity_id': 'binary_sensor.ro_filter_leak_detected', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[softener][binary_sensor.softener_reserve_capacity_in_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.softener_reserve_capacity_in_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Reserve capacity in use', + 'platform': 'drop_connect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_in_use', + 'unique_id': 'DROP-1_C0FFEE_0_reserve_in_use', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[softener][binary_sensor.softener_reserve_capacity_in_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Softener Reserve capacity in use', + 'icon': 'mdi:water', + }), + 'context': , + 'entity_id': 'binary_sensor.softener_reserve_capacity_in_use', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py index 2f54e8fb791..895921291ef 100644 --- a/tests/components/drop_connect/test_binary_sensor.py +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -1,7 +1,13 @@ """Test DROP binary sensor entities.""" -from homeassistant.const import STATE_OFF, STATE_ON +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import ( TEST_DATA_HUB, @@ -19,9 +25,6 @@ from .common import ( TEST_DATA_RO_FILTER, TEST_DATA_RO_FILTER_RESET, TEST_DATA_RO_FILTER_TOPIC, - TEST_DATA_SALT, - TEST_DATA_SALT_RESET, - TEST_DATA_SALT_TOPIC, TEST_DATA_SOFTENER, TEST_DATA_SOFTENER_RESET, TEST_DATA_SOFTENER_TOPIC, @@ -30,167 +33,104 @@ from .common import ( config_entry_protection_valve, config_entry_pump_controller, config_entry_ro_filter, - config_entry_salt, config_entry_softener, ) -from tests.common import async_fire_mqtt_message +from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient -async def test_binary_sensors_hub( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient +@pytest.mark.parametrize( + ("config_entry", "topic", "reset", "data"), + [ + (config_entry_hub(), TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET, TEST_DATA_HUB), + ( + config_entry_leak(), + TEST_DATA_LEAK_TOPIC, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK, + ), + ( + config_entry_softener(), + TEST_DATA_SOFTENER_TOPIC, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER, + ), + ( + config_entry_protection_valve(), + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE, + ), + ( + config_entry_pump_controller(), + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER, + ), + ( + config_entry_ro_filter(), + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER, + ), + ], + ids=[ + "hub", + "leak", + "softener", + "protection_valve", + "pump_controller", + "ro_filter", + ], +) +async def test_sensors( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + topic: str, + reset: str, + data: str, ) -> None: - """Test DROP binary sensors for hubs.""" - entry = config_entry_hub() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) + """Test DROP sensors.""" + config_entry.add_to_hass(hass) - pending_notifications_sensor_name = ( - "binary_sensor.hub_drop_1_c0ffee_notification_unread" + with patch( + "homeassistant.components.drop_connect.PLATFORMS", [Platform.BINARY_SENSOR] + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) - assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF - leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" - assert hass.states.get(leak_sensor_name).state == STATE_OFF - async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id).state == STATE_OFF + + async_fire_mqtt_message(hass, topic, reset) await hass.async_block_till_done() - assert hass.states.get(pending_notifications_sensor_name).state == STATE_OFF - assert hass.states.get(leak_sensor_name).state == STATE_OFF - async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) - await hass.async_block_till_done() - assert hass.states.get(pending_notifications_sensor_name).state == STATE_ON - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - -async def test_binary_sensors_salt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for salt sensors.""" - entry = config_entry_salt() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - salt_sensor_name = "binary_sensor.salt_sensor_salt_low" - assert hass.states.get(salt_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) - await hass.async_block_till_done() - assert hass.states.get(salt_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) - await hass.async_block_till_done() - assert hass.states.get(salt_sensor_name).state == STATE_ON - - -async def test_binary_sensors_leak( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for leak detectors.""" - entry = config_entry_leak() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - leak_sensor_name = "binary_sensor.leak_detector_leak_detected" - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_ON - - -async def test_binary_sensors_softener( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for softeners.""" - entry = config_entry_softener() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" - assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) - await hass.async_block_till_done() - assert hass.states.get(reserve_in_use_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) - await hass.async_block_till_done() - assert hass.states.get(reserve_in_use_sensor_name).state == STATE_ON - - -async def test_binary_sensors_protection_valve( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for protection valves.""" - entry = config_entry_protection_valve() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - leak_sensor_name = "binary_sensor.protection_valve_leak_detected" - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - async_fire_mqtt_message( - hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_OFF - async_fire_mqtt_message( - hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id).state == STATE_OFF + + async_fire_mqtt_message(hass, topic, data) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_ON - - -async def test_binary_sensors_pump_controller( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for pump controllers.""" - entry = config_entry_pump_controller() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - leak_sensor_name = "binary_sensor.pump_controller_leak_detected" - assert hass.states.get(leak_sensor_name).state == STATE_OFF - pump_sensor_name = "binary_sensor.pump_controller_pump_status" - assert hass.states.get(pump_sensor_name).state == STATE_OFF - - async_fire_mqtt_message( - hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET - ) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_OFF - assert hass.states.get(pump_sensor_name).state == STATE_OFF - - async_fire_mqtt_message( - hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER - ) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_ON - assert hass.states.get(pump_sensor_name).state == STATE_ON - - -async def test_binary_sensors_ro_filter( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> None: - """Test DROP binary sensors for RO filters.""" - entry = config_entry_ro_filter() - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - - leak_sensor_name = "binary_sensor.ro_filter_leak_detected" - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_OFF - - async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) - await hass.async_block_till_done() - assert hass.states.get(leak_sensor_name).state == STATE_ON + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) From b22cd2deaa0103cd202aed969c764965f0018d7f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:40:49 +0100 Subject: [PATCH 0398/1544] Enable strict typing for system_health (#107283) --- .strict-typing | 1 + .../components/system_health/__init__.py | 21 ++++++++++++++----- mypy.ini | 10 +++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4a1876bbadf..f90add81fad 100644 --- a/.strict-typing +++ b/.strict-typing @@ -380,6 +380,7 @@ homeassistant.components.switchbee.* homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* +homeassistant.components.system_health.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 32970bc4fe5..cd3cad8024e 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable import dataclasses from datetime import datetime import logging -from typing import Any +from typing import Any, Protocol import aiohttp import voluptuous as vol @@ -30,13 +30,22 @@ INFO_CALLBACK_TIMEOUT = 5 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +class SystemHealthProtocol(Protocol): + """Define the format of system_health platforms.""" + + def async_register( + self, hass: HomeAssistant, register: SystemHealthRegistration + ) -> None: + """Register system health callbacks.""" + + @bind_hass @callback def async_register_info( hass: HomeAssistant, domain: str, info_callback: Callable[[HomeAssistant], Awaitable[dict]], -): +) -> None: """Register an info callback. Deprecated. @@ -61,7 +70,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _register_system_health_platform(hass, integration_domain, platform): +async def _register_system_health_platform( + hass: HomeAssistant, integration_domain: str, platform: SystemHealthProtocol +) -> None: """Register a system health platform.""" platform.async_register(hass, SystemHealthRegistration(hass, integration_domain)) @@ -89,7 +100,7 @@ async def get_integration_info( @callback -def _format_value(val): +def _format_value(val: Any) -> Any: """Format a system health value.""" if isinstance(val, datetime): return {"value": val.isoformat(), "type": "date"} @@ -207,7 +218,7 @@ class SystemHealthRegistration: self, info_callback: Callable[[HomeAssistant], Awaitable[dict]], manage_url: str | None = None, - ): + ) -> None: """Register an info callback.""" self.info_callback = info_callback self.manage_url = manage_url diff --git a/mypy.ini b/mypy.ini index d0bebf791c3..a24cad9ee7d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3562,6 +3562,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.system_health.*] +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.systemmonitor.*] check_untyped_defs = true disallow_incomplete_defs = true From c30bf1f6e1ec359c735e2bd9336c30a1f1e63b29 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:43:40 +0100 Subject: [PATCH 0399/1544] Enable strict typing for nightscout (#107307) --- .strict-typing | 1 + homeassistant/components/nightscout/config_flow.py | 14 +++++++++----- homeassistant/components/nightscout/sensor.py | 2 +- homeassistant/components/nightscout/utils.py | 4 +++- mypy.ini | 10 ++++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index f90add81fad..648379875da 100644 --- a/.strict-typing +++ b/.strict-typing @@ -289,6 +289,7 @@ homeassistant.components.netatmo.* homeassistant.components.network.* homeassistant.components.nextdns.* homeassistant.components.nfandroidtv.* +homeassistant.components.nightscout.* homeassistant.components.nissan_leaf.* homeassistant.components.no_ip.* homeassistant.components.notify.* diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py index 1f3f62835bc..98e075ba3c9 100644 --- a/homeassistant/components/nightscout/config_flow.py +++ b/homeassistant/components/nightscout/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Nightscout integration.""" from asyncio import TimeoutError as AsyncIOTimeoutError import logging +from typing import Any from aiohttp import ClientError, ClientResponseError from py_nightscout import Api as NightscoutAPI @@ -8,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries, exceptions from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .utils import hash_from_url @@ -17,10 +19,10 @@ _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema({vol.Required(CONF_URL): str, vol.Optional(CONF_API_KEY): str}) -async def _validate_input(data): +async def _validate_input(data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" - url = data[CONF_URL] - api_key = data.get(CONF_API_KEY) + url: str = data[CONF_URL] + api_key: str | None = data.get(CONF_API_KEY) try: api = NightscoutAPI(url, api_secret=api_key) status = await api.get_server_status() @@ -40,9 +42,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: unique_id = hash_from_url(user_input[CONF_URL]) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index f60c70cc67c..851610ee374 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -40,7 +40,7 @@ class NightscoutSensor(SensorEntity): _attr_native_unit_of_measurement = "mg/dL" _attr_icon = "mdi:cloud-question" - def __init__(self, api: NightscoutAPI, name, unique_id) -> None: + def __init__(self, api: NightscoutAPI, name: str, unique_id: str | None) -> None: """Initialize the Nightscout sensor.""" self.api = api self._attr_unique_id = unique_id diff --git a/homeassistant/components/nightscout/utils.py b/homeassistant/components/nightscout/utils.py index 4d262ee6439..ac9ce1a3384 100644 --- a/homeassistant/components/nightscout/utils.py +++ b/homeassistant/components/nightscout/utils.py @@ -1,7 +1,9 @@ """Nightscout util functions.""" +from __future__ import annotations + import hashlib -def hash_from_url(url: str): +def hash_from_url(url: str) -> str: """Hash url to create a unique ID.""" return hashlib.sha256(url.encode("utf-8")).hexdigest() diff --git a/mypy.ini b/mypy.ini index a24cad9ee7d..2766b47b471 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2651,6 +2651,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nightscout.*] +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.nissan_leaf.*] check_untyped_defs = true disallow_incomplete_defs = true From d0e6ce193cd7729b23fd6d4ee229a997f4548699 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:44:47 +0100 Subject: [PATCH 0400/1544] Enable strict typing for tod (#107284) --- .strict-typing | 1 + homeassistant/components/tod/binary_sensor.py | 26 ++++++++++--------- mypy.ini | 10 +++++++ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.strict-typing b/.strict-typing index 648379875da..b27475b69a1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -397,6 +397,7 @@ homeassistant.components.tile.* homeassistant.components.tilt_ble.* homeassistant.components.time.* homeassistant.components.time_date.* +homeassistant.components.tod.* homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index c3f2c75e07b..d274960c211 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, time, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, TypeGuard import voluptuous as vol @@ -35,6 +35,8 @@ from .const import ( CONF_BEFORE_TIME, ) +SunEventType = Literal["sunrise", "sunset"] + _LOGGER = logging.getLogger(__name__) ATTR_AFTER = "after" @@ -60,7 +62,7 @@ async def async_setup_entry( ) -> None: """Initialize Times of the Day config entry.""" if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return after = cv.time(config_entry.options[CONF_AFTER_TIME]) @@ -83,7 +85,7 @@ async def async_setup_platform( ) -> None: """Set up the ToD sensors.""" if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return after = config[CONF_AFTER] @@ -97,7 +99,7 @@ async def async_setup_platform( async_add_entities([sensor]) -def _is_sun_event(sun_event): +def _is_sun_event(sun_event: time | SunEventType) -> TypeGuard[SunEventType]: """Return true if event is sun event not time.""" return sun_event in (SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) @@ -172,8 +174,8 @@ class TodSensor(BinarySensorEntity): # Calculate the today's event utc time or # if not available take next after_event_date = get_astral_event_date( - self.hass, str(self._after), nowutc - ) or get_astral_event_next(self.hass, str(self._after), nowutc) + self.hass, self._after, nowutc + ) or get_astral_event_next(self.hass, self._after, nowutc) else: # Convert local time provided to UTC today # datetime.combine(date, time, tzinfo) is not supported @@ -188,13 +190,13 @@ class TodSensor(BinarySensorEntity): # Calculate the today's event utc time or if not available take # next before_event_date = get_astral_event_date( - self.hass, str(self._before), nowutc - ) or get_astral_event_next(self.hass, str(self._before), nowutc) + self.hass, self._before, nowutc + ) or get_astral_event_next(self.hass, self._before, nowutc) # Before is earlier than after if before_event_date < after_event_date: # Take next day for before before_event_date = get_astral_event_next( - self.hass, str(self._before), after_event_date + self.hass, self._before, after_event_date ) else: # Convert local time provided to UTC today, see above @@ -248,7 +250,7 @@ class TodSensor(BinarySensorEntity): assert self._time_before is not None if _is_sun_event(self._after): self._time_after = get_astral_event_next( - self.hass, str(self._after), self._time_after - self._after_offset + self.hass, self._after, self._time_after - self._after_offset ) self._time_after += self._after_offset else: @@ -259,7 +261,7 @@ class TodSensor(BinarySensorEntity): if _is_sun_event(self._before): self._time_before = get_astral_event_next( - self.hass, str(self._before), self._time_before - self._before_offset + self.hass, self._before, self._time_before - self._before_offset ) self._time_before += self._before_offset else: @@ -274,7 +276,7 @@ class TodSensor(BinarySensorEntity): self._calculate_next_update() @callback - def _clean_up_listener(): + def _clean_up_listener() -> None: if self._unsub_update is not None: self._unsub_update() self._unsub_update = None diff --git a/mypy.ini b/mypy.ini index 2766b47b471..e3bcca10837 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3732,6 +3732,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tod.*] +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.todo.*] check_untyped_defs = true disallow_incomplete_defs = true From 3632d6be46441b00c93f0a2c4f46bd3e0f52bac3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:45:51 +0100 Subject: [PATCH 0401/1544] Enable strict typing for dlna_dms (#107305) --- .strict-typing | 1 + homeassistant/components/dlna_dms/dms.py | 4 ++-- homeassistant/components/dlna_dms/media_source.py | 2 +- mypy.ini | 10 ++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index b27475b69a1..b79b50fd9cb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -138,6 +138,7 @@ homeassistant.components.dhcp.* homeassistant.components.diagnostics.* homeassistant.components.discovergy.* homeassistant.components.dlna_dmr.* +homeassistant.components.dlna_dms.* homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 62ff2be7d5b..54cca744360 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -332,7 +332,7 @@ class DmsDeviceSource: @property def usn(self) -> str: """Get the USN (Unique Service Name) for the wrapped UPnP device end-point.""" - return self.config_entry.data[CONF_DEVICE_ID] + return self.config_entry.data[CONF_DEVICE_ID] # type: ignore[no-any-return] @property def udn(self) -> str: @@ -347,7 +347,7 @@ class DmsDeviceSource: @property def source_id(self) -> str: """Return a unique ID (slug) for this source for people to use in URLs.""" - return self.config_entry.data[CONF_SOURCE_ID] + return self.config_entry.data[CONF_SOURCE_ID] # type: ignore[no-any-return] @property def icon(self) -> str | None: diff --git a/homeassistant/components/dlna_dms/media_source.py b/homeassistant/components/dlna_dms/media_source.py index c1245997c7a..399398fa5b9 100644 --- a/homeassistant/components/dlna_dms/media_source.py +++ b/homeassistant/components/dlna_dms/media_source.py @@ -25,7 +25,7 @@ from .const import DOMAIN, LOGGER, PATH_OBJECT_ID_FLAG, ROOT_OBJECT_ID, SOURCE_S from .dms import DidlPlayMedia, get_domain_data -async def async_get_media_source(hass: HomeAssistant): +async def async_get_media_source(hass: HomeAssistant) -> DmsMediaSource: """Set up DLNA DMS media source.""" LOGGER.debug("Setting up DLNA media sources") return DmsMediaSource(hass) diff --git a/mypy.ini b/mypy.ini index e3bcca10837..62ad39da8e2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1141,6 +1141,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.dlna_dms.*] +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.dnsip.*] check_untyped_defs = true disallow_incomplete_defs = true From e7cc26d0287c553dfe90c501bd87c708fd3574e8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:49:15 +0100 Subject: [PATCH 0402/1544] Improve folder_watcher typing (#107271) --- .../components/folder_watcher/__init__.py | 138 ++++++++++-------- 1 file changed, 76 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index cd979d51457..41a20360ff3 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -1,13 +1,25 @@ """Component for monitoring activity on a folder.""" +from __future__ import annotations + import logging import os +from typing import cast import voluptuous as vol -from watchdog.events import PatternMatchingEventHandler +from watchdog.events import ( + FileClosedEvent, + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, + FileSystemEvent, + FileSystemMovedEvent, + PatternMatchingEventHandler, +) from watchdog.observers import Observer from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -42,8 +54,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the folder watcher.""" conf = config[DOMAIN] for watcher in conf: - path = watcher[CONF_FOLDER] - patterns = watcher[CONF_PATTERNS] + path: str = watcher[CONF_FOLDER] + patterns: list[str] = watcher[CONF_PATTERNS] if not hass.config.is_allowed_path(path): _LOGGER.error("Folder %s is not valid or allowed", path) return False @@ -52,70 +64,72 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def create_event_handler(patterns, hass): +def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler: """Return the Watchdog EventHandler object.""" - class EventHandler(PatternMatchingEventHandler): - """Class for handling Watcher events.""" - - def __init__(self, patterns, hass): - """Initialise the EventHandler.""" - super().__init__(patterns) - self.hass = hass - - def process(self, event, moved=False): - """On Watcher event, fire HA event.""" - _LOGGER.debug("process(%s)", event) - if not event.is_directory: - folder, file_name = os.path.split(event.src_path) - fireable = { - "event_type": event.event_type, - "path": event.src_path, - "file": file_name, - "folder": folder, - } - - if moved: - dest_folder, dest_file_name = os.path.split(event.dest_path) - fireable.update( - { - "dest_path": event.dest_path, - "dest_file": dest_file_name, - "dest_folder": dest_folder, - } - ) - self.hass.bus.fire( - DOMAIN, - fireable, - ) - - def on_modified(self, event): - """File modified.""" - self.process(event) - - def on_moved(self, event): - """File moved.""" - self.process(event, moved=True) - - def on_created(self, event): - """File created.""" - self.process(event) - - def on_deleted(self, event): - """File deleted.""" - self.process(event) - - def on_closed(self, event): - """File closed.""" - self.process(event) - return EventHandler(patterns, hass) +class EventHandler(PatternMatchingEventHandler): + """Class for handling Watcher events.""" + + def __init__(self, patterns: list[str], hass: HomeAssistant) -> None: + """Initialise the EventHandler.""" + super().__init__(patterns) + self.hass = hass + + def process(self, event: FileSystemEvent, moved: bool = False) -> None: + """On Watcher event, fire HA event.""" + _LOGGER.debug("process(%s)", event) + if not event.is_directory: + folder, file_name = os.path.split(event.src_path) + fireable = { + "event_type": event.event_type, + "path": event.src_path, + "file": file_name, + "folder": folder, + } + + if moved: + event = cast(FileSystemMovedEvent, event) + dest_folder, dest_file_name = os.path.split(event.dest_path) + fireable.update( + { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + ) + self.hass.bus.fire( + DOMAIN, + fireable, + ) + + def on_modified(self, event: FileModifiedEvent) -> None: + """File modified.""" + self.process(event) + + def on_moved(self, event: FileMovedEvent) -> None: + """File moved.""" + self.process(event, moved=True) + + def on_created(self, event: FileCreatedEvent) -> None: + """File created.""" + self.process(event) + + def on_deleted(self, event: FileDeletedEvent) -> None: + """File deleted.""" + self.process(event) + + def on_closed(self, event: FileClosedEvent) -> None: + """File closed.""" + self.process(event) + + class Watcher: """Class for starting Watchdog.""" - def __init__(self, path, patterns, hass): + def __init__(self, path: str, patterns: list[str], hass: HomeAssistant) -> None: """Initialise the watchdog observer.""" self._observer = Observer() self._observer.schedule( @@ -124,11 +138,11 @@ class Watcher: hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - def startup(self, event): + def startup(self, event: Event) -> None: """Start the watcher.""" self._observer.start() - def shutdown(self, event): + def shutdown(self, event: Event) -> None: """Shutdown the watcher.""" self._observer.stop() self._observer.join() From cc67fd8a3ce5db0e4f893cff892360fdc84662c9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 8 Jan 2024 04:51:58 -0500 Subject: [PATCH 0403/1544] Reduce polling rate in Blink (#107386) --- homeassistant/components/blink/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index d53d23c4344..aaf666208a6 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 300 class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): From 080484f2f6f75484e51678de1e86a72279210e2a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:10:16 +0100 Subject: [PATCH 0404/1544] Remove tedee device safely from registry (#107529) remove device safely from registry --- homeassistant/components/tedee/coordinator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 064af41ac89..c846f2a8d9a 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -121,7 +121,10 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): if device := device_registry.async_get_device( identifiers={(DOMAIN, str(lock_id))} ): - device_registry.async_remove_device(device.id) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) # add new locks if new_locks := current_locks - self._locks_last_update: From d7be7f5ae19e82316f74675be070e374632418f8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 8 Jan 2024 09:11:19 -0500 Subject: [PATCH 0405/1544] Bump blinkpy to 0.22.5 (#107537) bump blinkpy 0.22.5 --- homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index a1268919052..6e9d912f332 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.4"] + "requirements": ["blinkpy==0.22.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9e9cfb5f2e3..22b5008a025 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -550,7 +550,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.4 +blinkpy==0.22.5 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3b52bece76..7336e4dd68e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -469,7 +469,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.4 +blinkpy==0.22.5 # homeassistant.components.blue_current bluecurrent-api==1.0.6 From 0d44a1eb66c11ff7a4ec5605898d918ea64fa6e8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:32:37 +0100 Subject: [PATCH 0406/1544] Bump mcstatus to v11.1.1 (#107546) * Bump mcstatus to 11.1.0 * Bump mcstatus to v11.1.1 --- homeassistant/components/minecraft_server/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/minecraft_server/const.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 73a7dc18d09..a00936852f0 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "gold", - "requirements": ["mcstatus==11.0.0"] + "requirements": ["mcstatus==11.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22b5008a025..486da149faa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1246,7 +1246,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==11.0.0 +mcstatus==11.1.1 # homeassistant.components.meater meater-python==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7336e4dd68e..e806b8fbc07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==11.0.0 +mcstatus==11.1.1 # homeassistant.components.meater meater-python==0.0.8 diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 56be9132f19..92d6c647d8f 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -41,6 +41,7 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), icon=None, + enforces_secure_chat=False, latency=5, ) From 13bfeef1da7a6bca63d83d1aa4981d0e903b136e Mon Sep 17 00:00:00 2001 From: FlorianOosterhof <2793431+FlorianOosterhof@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:37:11 +0100 Subject: [PATCH 0407/1544] Unlock the precision of sensors of the opentherm_gw integration (#107227) * Unlock the precision of sensors of the opentherm_gw integration * Add a suggested_display_precision attribute to all opentherm_gw sensors. --- .../components/opentherm_gw/const.py | 159 ++++++++++++++++-- .../components/opentherm_gw/sensor.py | 19 ++- 2 files changed, 160 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 7dc2d206912..82d982b2fa9 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -51,6 +51,8 @@ TRANSLATE_SOURCE = { gw_vars.THERMOSTAT: "Thermostat", } +SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION = 1 + BINARY_SENSOR_INFO: dict[str, list] = { # [device_class, friendly_name format, [status source, ...]] gw_vars.DATA_MASTER_CH_ENABLED: [ @@ -214,324 +216,453 @@ BINARY_SENSOR_INFO: dict[str, list] = { } SENSOR_INFO: dict[str, list] = { - # [device_class, unit, friendly_name, [status source, ...]] + # [device_class, unit, friendly_name, suggested_display_precision, [status source, ...]] gw_vars.DATA_CONTROL_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_MEMBERID: [ None, None, "Thermostat Member ID {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MEMBERID: [ None, None, "Boiler Member ID {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_OEM_FAULT: [ None, None, "Boiler OEM Fault Code {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_COOLING_CONTROL: [ None, PERCENTAGE, "Cooling Control Signal {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CONTROL_SETPOINT_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT_OVRD: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MAX_CAPACITY: [ SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ None, PERCENTAGE, "Boiler Minimum Modulation Level {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_REL_MOD_LEVEL: [ None, PERCENTAGE, "Relative Modulation Level {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_PRESS: [ SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_FLOW_RATE: [ None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_SETPOINT_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_ROOM_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_OUTSIDE_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_RETURN_WATER_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SOLAR_STORAGE_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SOLAR_COLL_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_WATER_TEMP_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_TEMP_2: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_EXHAUST_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_DHW_MAX_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_DHW_MIN_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_CH_MAX_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_CH_MIN_SETP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MAX_CH_SETPOINT: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_OEM_DIAG: [ None, None, "OEM Diagnostic Code {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_TOTAL_BURNER_STARTS: [ None, "starts", "Total Burner Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_STARTS: [ None, "starts", "Central Heating Pump Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_STARTS: [ None, "starts", "Hot Water Pump Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_STARTS: [ None, "starts", "Hot Water Burner Starts {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_TOTAL_BURNER_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_HOURS: [ SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_OT_VERSION: [ None, None, "Thermostat OpenTherm Version {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_OT_VERSION: [ None, None, "Boiler OpenTherm Version {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_PRODUCT_TYPE: [ None, None, "Thermostat Product Type {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_MASTER_PRODUCT_VERSION: [ None, None, "Thermostat Product Version {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_PRODUCT_TYPE: [ None, None, "Boiler Product Type {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_SLAVE_PRODUCT_VERSION: [ None, None, "Boiler Product Version {}", + None, [gw_vars.BOILER, gw_vars.THERMOSTAT], ], - gw_vars.OTGW_MODE: [None, None, "Gateway/Monitor Mode {}", [gw_vars.OTGW]], + gw_vars.OTGW_MODE: [ + None, + None, + "Gateway/Monitor Mode {}", + None, + [gw_vars.OTGW], + ], gw_vars.OTGW_DHW_OVRD: [ None, None, "Gateway Hot Water Override Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_ABOUT: [ + None, + None, + "Gateway Firmware Version {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_BUILD: [ + None, + None, + "Gateway Firmware Build {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_CLOCKMHZ: [ + None, + None, + "Gateway Clock Speed {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_A: [ + None, + None, + "Gateway LED A Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_B: [ + None, + None, + "Gateway LED B Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_C: [ + None, + None, + "Gateway LED C Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_D: [ + None, + None, + "Gateway LED D Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_E: [ + None, + None, + "Gateway LED E Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_LED_F: [ + None, + None, + "Gateway LED F Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_GPIO_A: [ + None, + None, + "Gateway GPIO A Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_GPIO_B: [ + None, + None, + "Gateway GPIO B Mode {}", + None, [gw_vars.OTGW], ], - gw_vars.OTGW_ABOUT: [None, None, "Gateway Firmware Version {}", [gw_vars.OTGW]], - gw_vars.OTGW_BUILD: [None, None, "Gateway Firmware Build {}", [gw_vars.OTGW]], - gw_vars.OTGW_CLOCKMHZ: [None, None, "Gateway Clock Speed {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_A: [None, None, "Gateway LED A Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_B: [None, None, "Gateway LED B Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_C: [None, None, "Gateway LED C Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_D: [None, None, "Gateway LED D Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_E: [None, None, "Gateway LED E Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_LED_F: [None, None, "Gateway LED F Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_GPIO_A: [None, None, "Gateway GPIO A Mode {}", [gw_vars.OTGW]], - gw_vars.OTGW_GPIO_B: [None, None, "Gateway GPIO B Mode {}", [gw_vars.OTGW]], gw_vars.OTGW_SB_TEMP: [ SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", + SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, [gw_vars.OTGW], ], gw_vars.OTGW_SETP_OVRD_MODE: [ None, None, "Gateway Room Setpoint Override Mode {}", + None, + [gw_vars.OTGW], + ], + gw_vars.OTGW_SMART_PWR: [ + None, + None, + "Gateway Smart Power Mode {}", + None, [gw_vars.OTGW], ], - gw_vars.OTGW_SMART_PWR: [None, None, "Gateway Smart Power Mode {}", [gw_vars.OTGW]], gw_vars.OTGW_THRM_DETECT: [ None, None, "Gateway Thermostat Detection {}", + None, [gw_vars.OTGW], ], gw_vars.OTGW_VREF: [ None, None, "Gateway Reference Voltage Setting {}", + None, [gw_vars.OTGW], ], } diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 09fbb0ef6ee..5848d50ad95 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -28,7 +28,8 @@ async def async_setup_entry( device_class = info[0] unit = info[1] friendly_name_format = info[2] - status_sources = info[3] + suggested_display_precision = info[3] + status_sources = info[4] for source in status_sources: sensors.append( @@ -39,6 +40,7 @@ async def async_setup_entry( device_class, unit, friendly_name_format, + suggested_display_precision, ) ) @@ -51,7 +53,16 @@ class OpenThermSensor(SensorEntity): _attr_should_poll = False _attr_entity_registry_enabled_default = False - def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format): + def __init__( + self, + gw_dev, + var, + source, + device_class, + unit, + friendly_name_format, + suggested_display_precision, + ): """Initialize the OpenTherm Gateway sensor.""" self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, f"{var}_{source}_{gw_dev.gw_id}", hass=gw_dev.hass @@ -68,6 +79,8 @@ class OpenThermSensor(SensorEntity): self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + if suggested_display_precision: + self._attr_suggested_display_precision = suggested_display_precision self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, gw_dev.gw_id)}, manufacturer="Schelte Bron", @@ -97,7 +110,5 @@ class OpenThermSensor(SensorEntity): def receive_report(self, status): """Handle status updates from the component.""" value = status[self._source].get(self._var) - if isinstance(value, float): - value = f"{value:2.1f}" self._attr_native_value = value self.async_write_ha_state() From fdf71b2687729a0c0ca9f5d4074cc6432215efc3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 8 Jan 2024 17:01:19 +0100 Subject: [PATCH 0408/1544] Bump reolink_aio to 0.8.6 (#107541) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index d5116af0071..5670aea87ad 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.5"] + "requirements": ["reolink-aio==0.8.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 486da149faa..fd01db6e267 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.5 +reolink-aio==0.8.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e806b8fbc07..c1dabc71c83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1813,7 +1813,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.5 +reolink-aio==0.8.6 # homeassistant.components.rflink rflink==0.0.65 From 0d946c62dc50f9054fd8f15e04c7fe6cd1543e3a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Mon, 8 Jan 2024 17:17:20 +0100 Subject: [PATCH 0409/1544] Bump pytedee_async to 0.2.10 (#107540) * bump tedee * bump tedee --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index f170d116ff7..558137672d6 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.6"] + "requirements": ["pytedee-async==0.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd01db6e267..29700711dbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.6 +pytedee-async==0.2.10 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1dabc71c83..fb9e6bd79a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1644,7 +1644,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.6 +pytedee-async==0.2.10 # homeassistant.components.motionmount python-MotionMount==0.3.1 From 20610645fb82e7021c632e57f9a5cc8002ab64bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 8 Jan 2024 19:01:43 +0100 Subject: [PATCH 0410/1544] Pop the mocked config flow, restore the original with mock_config_flow (#107567) Pop the mocked config flow, restore the original if it existed --- tests/common.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/common.py b/tests/common.py index 4e68bcf4357..02c7150588d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1321,8 +1321,13 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, @contextmanager def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: """Mock a config flow handler.""" - with patch.dict(config_entries.HANDLERS, {domain: config_flow}): - yield + original_handler = config_entries.HANDLERS.get(domain) + config_entries.HANDLERS[domain] = config_flow + _LOGGER.info("Adding mock config flow: %s", domain) + yield + config_entries.HANDLERS.pop(domain) + if original_handler: + config_entries.HANDLERS[domain] = original_handler def mock_integration( From 4bb2a3ad92823e346ab7d201bd6da9d86db790e8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 8 Jan 2024 12:23:06 -0600 Subject: [PATCH 0411/1544] Specific Assist errors for domain/device class (#107302) * Specific errors for domain/device class * Don't log exception * Check device class first * Refactor guard clauses * Test default error --- .../components/conversation/default_agent.py | 49 +++++++++++++++ homeassistant/helpers/intent.py | 31 ++++++++-- .../conversation/test_default_agent.py | 59 ++++++++++++++++++- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 6af75b2f0a8..3f36e98f85a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -269,7 +269,22 @@ class DefaultAgent(AbstractConversationAgent): language, assistant=DOMAIN, ) + except intent.NoStatesMatchedError as no_states_error: + # Intent was valid, but no entities matched the constraints. + error_response_type, error_response_args = _get_no_states_matched_response( + no_states_error + ) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) except intent.IntentHandleError: + # Intent was valid and entities matched constraints, but an error + # occurred during handling. _LOGGER.exception("Intent handling error") return _make_error_result( language, @@ -863,6 +878,40 @@ def _get_unmatched_response( return error_response_type, error_response_args +def _get_no_states_matched_response( + no_states_error: intent.NoStatesMatchedError, +) -> tuple[ResponseType, dict[str, Any]]: + """Return error response type and template arguments for error.""" + if not ( + no_states_error.area + and (no_states_error.device_classes or no_states_error.domains) + ): + # Device class and domain must be paired with an area for the error + # message. + return ResponseType.NO_INTENT, {} + + error_response_args: dict[str, Any] = {"area": no_states_error.area} + + # Check device classes first, since it's more specific than domain + if no_states_error.device_classes: + # No exposed entities of a particular class in an area. + # Example: "close the bedroom windows" + # + # Only use the first device class for the error message + error_response_args["device_class"] = next(iter(no_states_error.device_classes)) + + return ResponseType.NO_DEVICE_CLASS, error_response_args + + # No exposed entities of a domain in an area. + # Example: "turn on lights in kitchen" + assert no_states_error.domains + # + # Only use the first domain for the error message + error_response_args["domain"] = next(iter(no_states_error.domains)) + + return ResponseType.NO_DOMAIN, error_response_args + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index ee326558467..26468f1fdb7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -109,8 +109,8 @@ async def async_handle( except vol.Invalid as err: _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err) raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err - except IntentHandleError: - raise + except IntentError: + raise # bubble up intent related errors except Exception as err: raise IntentUnexpectedError(f"Error handling {intent_type}") from err @@ -135,6 +135,25 @@ class IntentUnexpectedError(IntentError): """Unexpected error while handling intent.""" +class NoStatesMatchedError(IntentError): + """Error when no states match the intent's constraints.""" + + def __init__( + self, + name: str | None, + area: str | None, + domains: set[str] | None, + device_classes: set[str] | None, + ) -> None: + """Initialize error.""" + super().__init__() + + self.name = name + self.area = area + self.domains = domains + self.device_classes = device_classes + + def _is_device_class( state: State, entity: entity_registry.RegistryEntry | None, @@ -421,8 +440,12 @@ class ServiceIntentHandler(IntentHandler): ) if not states: - raise IntentHandleError( - f"No entities matched for: name={name}, area={area}, domains={domains}, device_classes={device_classes}", + # No states matched constraints + raise NoStatesMatchedError( + name=name, + area=area_name, + domains=domains, + device_classes=device_classes, ) response = await self.async_handle_states(intent_obj, states, area) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index ac126aa7c6b..2e815edf1e1 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -129,7 +129,7 @@ async def test_exposed_areas( # This should be an error because the lights in that area are not exposed assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS # But we can still ask questions about the bedroom, even with no exposed entities result = await conversation.async_converse( @@ -455,6 +455,38 @@ async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: assert result.response.speech["plain"]["speech"] == "No area named missing area" +async def test_error_no_exposed_for_domain( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no entities for a domain are exposed in an area.""" + area_registry.async_get_or_create("kitchen") + result = await conversation.async_converse( + hass, "turn on the lights in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] == "kitchen does not contain a light" + ) + + +async def test_error_no_exposed_for_device_class( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no entities of a device class are exposed in an area.""" + area_registry.async_get_or_create("bedroom") + result = await conversation.async_converse( + hass, "open bedroom windows", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] == "bedroom does not contain a window" + ) + + async def test_error_match_failure(hass: HomeAssistant, init_components) -> None: """Test response with complete match failure.""" with patch( @@ -471,6 +503,31 @@ async def test_error_match_failure(hass: HomeAssistant, init_components) -> None ) +async def test_no_states_matched_default_error( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test default response when no states match and slots are missing.""" + area_registry.async_get_or_create("kitchen") + + with patch( + "homeassistant.components.conversation.default_agent.intent.async_handle", + side_effect=intent.NoStatesMatchedError(None, None, None, None), + ): + result = await conversation.async_converse( + hass, "turn on lights in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I couldn't understand that" + ) + + async def test_empty_aliases( hass: HomeAssistant, init_components, From e349608f92104740f4b17e484b6f5635cff1fcfe Mon Sep 17 00:00:00 2001 From: nic <31355096+nabbi@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:30:44 -0600 Subject: [PATCH 0412/1544] Retry zoneminder connection setup (#107519) * zoneminder setup retry connection Makes ZM setup be async for enabling connection retry attempts This also requires zm-py version bump v0.5.4 as that dependency was patched in conjunction to resolve this issue Closes #105271 Signed-off-by: Nic Boet * ruff format Signed-off-by: Nic Boet * ruff fixes Signed-off-by: Nic Boet * RequestsConnectionError Signed-off-by: Nic Boet * revert async changes Signed-off-by: Nic Boet --------- Signed-off-by: Nic Boet --- homeassistant/components/zoneminder/__init__.py | 10 +++++++++- homeassistant/components/zoneminder/camera.py | 6 ++++-- homeassistant/components/zoneminder/manifest.json | 2 +- homeassistant/components/zoneminder/sensor.py | 5 ++++- homeassistant/components/zoneminder/switch.py | 6 ++++-- requirements_all.txt | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 5e9c881af85..1ff73048440 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -1,6 +1,7 @@ """Support for ZoneMinder.""" import logging +from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from zoneminder.zm import ZoneMinder @@ -75,7 +76,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data[DOMAIN][host_name] = zm_client - success = zm_client.login() and success + try: + success = zm_client.login() and success + except RequestsConnectionError as ex: + _LOGGER.error( + "ZoneMinder connection failure to %s: %s", + host_name, + ex, + ) def set_active_state(call: ServiceCall) -> None: """Set the ZoneMinder run state to the given state name.""" diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index e87e6f814cc..d8b2aa805e7 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -8,6 +8,7 @@ from zoneminder.zm import ZoneMinder from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -28,8 +29,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch monitors from ZoneMinder host: %s") - return + raise PlatformNotReady( + "Camera could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: _LOGGER.info("Initializing camera %s", monitor.id) diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json index 309ce43101c..f441a800555 100644 --- a/homeassistant/components/zoneminder/manifest.json +++ b/homeassistant/components/zoneminder/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zoneminder", "iot_class": "local_polling", "loggers": ["zoneminder"], - "requirements": ["zm-py==0.5.3"] + "requirements": ["zm-py==0.5.4"] } diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index c995e84343b..47863b5a5df 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -77,7 +78,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch any monitors from ZoneMinder") + raise PlatformNotReady( + "Sensor could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: sensors.append(ZMSensorMonitors(monitor)) diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 985866272a6..b722ef53a77 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -11,6 +11,7 @@ from zoneminder.zm import ZoneMinder from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -42,8 +43,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch monitors from ZoneMinder") - return + raise PlatformNotReady( + "Switch could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) diff --git a/requirements_all.txt b/requirements_all.txt index 29700711dbe..dc2e1dd898e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ zigpy-znp==0.12.1 zigpy==0.60.4 # homeassistant.components.zoneminder -zm-py==0.5.3 +zm-py==0.5.4 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From 438ba7eaadd908f6a2ba0309ff005c09e9b688d2 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 8 Jan 2024 14:32:29 -0500 Subject: [PATCH 0413/1544] Add software version to Blink device info (#107548) * add firmware to device * Version from attributes --- homeassistant/components/blink/alarm_control_panel.py | 1 + homeassistant/components/blink/camera.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 8e0750d1373..d0f8529b6db 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -65,6 +65,7 @@ class BlinkSyncModuleHA( name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, serial_number=sync.serial, + sw_version=sync.attributes.get("version"), ) self._update_attr() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 4d05aea88a5..f9c72e7e682 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -79,6 +79,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, serial_number=camera.serial, + sw_version=camera.attributes.get("version"), name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, From 8150754b9b27a6d73d2162780819129568057245 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 8 Jan 2024 21:08:19 +0100 Subject: [PATCH 0414/1544] Improve led_ble generic typing (#107534) --- homeassistant/components/led_ble/light.py | 4 ++-- homeassistant/components/led_ble/models.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 5fba73ef808..a1da82dfe6d 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -38,7 +38,7 @@ async def async_setup_entry( async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)]) -class LEDBLEEntity(CoordinatorEntity, LightEntity): +class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): """Representation of LEDBLE device.""" _attr_supported_color_modes = {ColorMode.RGB, ColorMode.WHITE} @@ -47,7 +47,7 @@ class LEDBLEEntity(CoordinatorEntity, LightEntity): _attr_supported_features = LightEntityFeature.EFFECT def __init__( - self, coordinator: DataUpdateCoordinator, device: LEDBLE, name: str + self, coordinator: DataUpdateCoordinator[None], device: LEDBLE, name: str ) -> None: """Initialize an ledble light.""" super().__init__(coordinator) diff --git a/homeassistant/components/led_ble/models.py b/homeassistant/components/led_ble/models.py index 611d484ea61..0eda9439f11 100644 --- a/homeassistant/components/led_ble/models.py +++ b/homeassistant/components/led_ble/models.py @@ -14,4 +14,4 @@ class LEDBLEData: title: str device: LEDBLE - coordinator: DataUpdateCoordinator + coordinator: DataUpdateCoordinator[None] From ca886de3cacdaf59f56411dc91206a4acd0eba46 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 8 Jan 2024 22:03:25 +0100 Subject: [PATCH 0415/1544] Remove deprecated YAML support from OpenSky (#107585) --- .../components/opensky/config_flow.py | 28 ++--- homeassistant/components/opensky/sensor.py | 58 +-------- tests/components/opensky/test_config_flow.py | 112 +----------------- tests/components/opensky/test_sensor.py | 22 ---- 4 files changed, 12 insertions(+), 208 deletions(-) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index a0cd6bc54c2..87621ea3508 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -25,10 +25,14 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import CONF_CONTRIBUTING_USER, DEFAULT_NAME, DOMAIN -from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE +from .const import ( + CONF_ALTITUDE, + CONF_CONTRIBUTING_USER, + DEFAULT_ALTITUDE, + DEFAULT_NAME, + DOMAIN, +) class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -77,24 +81,6 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_config: ConfigType) -> FlowResult: - """Import config from yaml.""" - entry_data = { - CONF_LATITUDE: import_config.get(CONF_LATITUDE, self.hass.config.latitude), - CONF_LONGITUDE: import_config.get( - CONF_LONGITUDE, self.hass.config.longitude - ), - } - self._async_abort_entries_match(entry_data) - return self.async_create_entry( - title=import_config.get(CONF_NAME, DEFAULT_NAME), - data=entry_data, - options={ - CONF_RADIUS: import_config[CONF_RADIUS] * 1000, - CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), - }, - ) - class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): """OpenSky Options flow handler.""" diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index e6a165b36ee..9cae0366357 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -1,66 +1,16 @@ """Sensor for the Open Sky Network.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ALTITUDE, DEFAULT_ALTITUDE, DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER from .coordinator import OpenSkyDataUpdateCoordinator -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_RADIUS): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string, - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_ALTITUDE, default=DEFAULT_ALTITUDE): vol.Coerce(float), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the OpenSky sensor platform from yaml.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "OpenSky", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index 7fa19762ddf..5207ac52f0c 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -9,14 +9,12 @@ from homeassistant import data_entry_flow from homeassistant.components.opensky.const import ( CONF_ALTITUDE, CONF_CONTRIBUTING_USER, - DEFAULT_NAME, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_PASSWORD, CONF_RADIUS, CONF_USERNAME, @@ -59,114 +57,6 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: } -@pytest.mark.parametrize( - ("config", "title", "data", "options"), - [ - ( - {CONF_RADIUS: 10.0}, - DEFAULT_NAME, - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - { - CONF_RADIUS: 10.0, - CONF_NAME: "My home", - }, - "My home", - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - { - CONF_RADIUS: 10.0, - CONF_LATITUDE: 10.0, - CONF_LONGITUDE: -100.0, - }, - DEFAULT_NAME, - { - CONF_LATITUDE: 10.0, - CONF_LONGITUDE: -100.0, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 0, - }, - ), - ( - {CONF_RADIUS: 10.0, CONF_ALTITUDE: 100.0}, - DEFAULT_NAME, - { - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - { - CONF_RADIUS: 10000.0, - CONF_ALTITUDE: 100.0, - }, - ), - ], -) -async def test_import_flow( - hass: HomeAssistant, - config: dict[str, Any], - title: str, - data: dict[str, Any], - options: dict[str, Any], -) -> None: - """Test the import flow.""" - with patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == title - assert result["options"] == options - assert result["data"] == data - - -async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: - """Test the import flow when same location already exists.""" - MockConfigEntry( - domain=DOMAIN, - title=DEFAULT_NAME, - data={}, - options={ - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - CONF_RADIUS: 10.0, - CONF_ALTITUDE: 100.0, - }, - ).add_to_hass(hass) - with patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - CONF_RADIUS: 10.0, - CONF_ALTITUDE: 100.0, - }, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - @pytest.mark.parametrize( ("user_input", "error"), [ diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 3429d5eec7e..27c45d1b8ca 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -6,38 +6,16 @@ from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.opensky.const import ( - DOMAIN, EVENT_OPENSKY_ENTRY, EVENT_OPENSKY_EXIT, ) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import get_states_response_fixture from .conftest import ComponentSetup from tests.common import MockConfigEntry, async_fire_time_changed -LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} - - -async def test_legacy_migration(hass: HomeAssistant) -> None: - """Test migration from yaml to config flow.""" - with patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states.json"), - ): - assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 - async def test_sensor( hass: HomeAssistant, From e8acccce052f4f380089d0f94712288fd14a3190 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 8 Jan 2024 22:28:04 +0100 Subject: [PATCH 0416/1544] Catch missing inverter in Enphase Envoy (#106730) * bug: prevent invalid key when empty invereter arrays is returned. Some envoy fw versions return an empty inverter array every 4 hours when no production is taking place. Prevent collection failure due to this as other data seems fine. Inveretrs will show unknown during this cycle. * refactor: replace try/catch with test and make warning debug * Update homeassistant/components/enphase_envoy/sensor.py * Update homeassistant/components/enphase_envoy/sensor.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/enphase_envoy/sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 1dfd72dcaf3..2ae9dca63ba 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -479,10 +479,20 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity): ) @property - def native_value(self) -> datetime.datetime | float: + def native_value(self) -> datetime.datetime | float | None: """Return the state of the sensor.""" inverters = self.data.inverters assert inverters is not None + # Some envoy fw versions return an empty inverter array every 4 hours when + # no production is taking place. Prevent collection failure due to this + # as other data seems fine. Inverters will show unknown during this cycle. + if self._serial_number not in inverters: + _LOGGER.debug( + "Inverter %s not in returned inverters array (size: %s)", + self._serial_number, + len(inverters), + ) + return None return self.entity_description.value_fn(inverters[self._serial_number]) From 9a81a29ce2b21b086d27ff5f3a1f3e5c5d2944ef Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Mon, 8 Jan 2024 22:59:12 +0100 Subject: [PATCH 0417/1544] Let babel handle the locale separator in holiday (#107571) --- homeassistant/components/holiday/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 33268de92b6..07da19167d7 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -47,7 +47,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) try: - locale = Locale(self.hass.config.language.replace("-", "_")) + locale = Locale.parse(self.hass.config.language, sep="-") except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" @@ -87,7 +87,7 @@ class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - locale = Locale(self.hass.config.language.replace("-", "_")) + locale = Locale.parse(self.hass.config.language, sep="-") except UnknownLocaleError: # Default to (US) English if language not recognized by babel # Mainly an issue with English flavors such as "en-GB" From bb78b75d498b3a3e9ae47733a8d4c0f1bbedacc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 12:02:29 -1000 Subject: [PATCH 0418/1544] Bump pymeteoclimatic to 0.1.0 (#107583) --- homeassistant/components/meteoclimatic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteoclimatic/manifest.json b/homeassistant/components/meteoclimatic/manifest.json index d7cc64727c8..31c97f9baf2 100644 --- a/homeassistant/components/meteoclimatic/manifest.json +++ b/homeassistant/components/meteoclimatic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteoclimatic", "iot_class": "cloud_polling", "loggers": ["meteoclimatic"], - "requirements": ["pymeteoclimatic==0.0.6"] + "requirements": ["pymeteoclimatic==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc2e1dd898e..3a9e0c848bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1918,7 +1918,7 @@ pymediaroom==0.6.5.4 pymelcloud==2.5.8 # homeassistant.components.meteoclimatic -pymeteoclimatic==0.0.6 +pymeteoclimatic==0.1.0 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb9e6bd79a1..c0b6c317a24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1463,7 +1463,7 @@ pymata-express==1.19 pymelcloud==2.5.8 # homeassistant.components.meteoclimatic -pymeteoclimatic==0.0.6 +pymeteoclimatic==0.1.0 # homeassistant.components.mochad pymochad==0.2.0 From ea2178a53d1a1cde165d89a147ecd485cd664604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 13:10:15 -1000 Subject: [PATCH 0419/1544] Fix tractive tests using a dict for the unique_id (#107602) --- tests/components/tractive/test_config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py index 6cd1a4efca8..6dd6f119d45 100644 --- a/tests/components/tractive/test_config_flow.py +++ b/tests/components/tractive/test_config_flow.py @@ -24,9 +24,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] is None - with patch( - "aiotractive.api.API.user_id", return_value={"user_id": "user_id"} - ), patch( + with patch("aiotractive.api.API.user_id", return_value="user_id"), patch( "homeassistant.components.tractive.async_setup_entry", return_value=True, ) as mock_setup_entry: From 86603b332ad399ec4fe16db0aa4677ee93202ac1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 13:24:57 -1000 Subject: [PATCH 0420/1544] Bump aiohttp-zlib-ng to 0.3.1 (#107595) --- homeassistant/components/http/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index b5d4de43cd0..647b7e42a3a 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.0" + "aiohttp-zlib-ng==0.3.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b53ff8cc428..ffd6b4bfa1a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.0 +aiohttp-zlib-ng==0.3.1 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a588cb19bc0..ddaca9f5b83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.0", + "aiohttp-zlib-ng==0.3.1", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 46e6fcb4a32..6246d45be10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.0 +aiohttp-zlib-ng==0.3.1 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index 3a9e0c848bf..035fddec931 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,7 +263,7 @@ aiohomekit==3.1.2 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.3.0 +aiohttp-zlib-ng==0.3.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0b6c317a24..4a898450ad9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -239,7 +239,7 @@ aiohomekit==3.1.2 aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.3.0 +aiohttp-zlib-ng==0.3.1 # homeassistant.components.emulated_hue # homeassistant.components.http From 82dc8260c68778398443dfb67908fc24b81ffa56 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Mon, 8 Jan 2024 22:01:15 -0800 Subject: [PATCH 0421/1544] Bump pywemo to 1.4.0 (#107623) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index c0428e62b71..71a1eac62a8 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pywemo"], - "requirements": ["pywemo==1.3.0"], + "requirements": ["pywemo==1.4.0"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index 035fddec931..da1a3d6e043 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2328,7 +2328,7 @@ pyweatherflowudp==1.4.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.3.0 +pywemo==1.4.0 # homeassistant.components.wilight pywilight==0.0.74 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a898450ad9..92e74f7545c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1768,7 +1768,7 @@ pyweatherflowudp==1.4.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.3.0 +pywemo==1.4.0 # homeassistant.components.wilight pywilight==0.0.74 From 05d205ae7a92a71fdb2eb9668a2d2f66a25b788e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 20:20:15 -1000 Subject: [PATCH 0422/1544] Small cleanups to number entity (#107624) --- homeassistant/components/number/__init__.py | 25 +++++++++++---------- homeassistant/components/number/const.py | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 55b281e02e1..c95381d09c2 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -424,22 +424,22 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: - assert native_unit_of_measurement - assert unit_of_measurement + if TYPE_CHECKING: + assert native_unit_of_measurement + assert unit_of_measurement value_s = str(value) prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 # Suppress ValueError (Could not convert value to float) with suppress(ValueError): - value_new: float = UNIT_CONVERTERS[device_class].convert( - value, + value_new: float = UNIT_CONVERTERS[device_class].converter_factory( native_unit_of_measurement, unit_of_measurement, - ) + )(value) # Round to the wanted precision - value = method(value_new, prec) + return method(value_new, prec) return value @@ -453,21 +453,22 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement if native_unit_of_measurement != unit_of_measurement: - assert native_unit_of_measurement - assert unit_of_measurement + if TYPE_CHECKING: + assert native_unit_of_measurement + assert unit_of_measurement - value = UNIT_CONVERTERS[device_class].convert( - value, + return UNIT_CONVERTERS[device_class].converter_factory( unit_of_measurement, native_unit_of_measurement, - ) + )(value) return value @callback def async_registry_entry_updated(self) -> None: """Run when the entity registry entry has been updated.""" - assert self.registry_entry + if TYPE_CHECKING: + assert self.registry_entry if ( (number_options := self.registry_entry.options.get(DOMAIN)) and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT)) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a2d7c066af7..aa9988f8987 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -475,7 +475,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.WIND_SPEED: set(UnitOfSpeed), } -UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = { +UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, } From 955c70b8f11fa276c74cb529c8eeb93a54c8929c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 20:59:29 -1000 Subject: [PATCH 0423/1544] Fix cloudflare tests using a dict for the unique id (#107601) fix cloudflare tests using a dict for the unique id --- tests/components/cloudflare/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index 8ba8b23b65f..6b9e77dcb2a 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -57,7 +57,7 @@ async def init_integration( *, data: dict = ENTRY_CONFIG, options: dict = ENTRY_OPTIONS, - unique_id: str = MOCK_ZONE, + unique_id: str = MOCK_ZONE["name"], skip_setup: bool = False, ) -> MockConfigEntry: """Set up the Cloudflare integration in Home Assistant.""" From a0b00d78b1364b606884881d30d6a1a5bf1a54c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 21:16:19 -1000 Subject: [PATCH 0424/1544] Avoid duplicate property lookups in camera state_attributes (#107627) --- homeassistant/components/camera/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ce75f064d47..5a78728697b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -726,17 +726,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera state attributes.""" attrs = {"access_token": self.access_tokens[-1]} - if self.model: - attrs["model_name"] = self.model + if model := self.model: + attrs["model_name"] = model - if self.brand: - attrs["brand"] = self.brand + if brand := self.brand: + attrs["brand"] = brand - if self.motion_detection_enabled: - attrs["motion_detection"] = self.motion_detection_enabled + if motion_detection_enabled := self.motion_detection_enabled: + attrs["motion_detection"] = motion_detection_enabled - if self.frontend_stream_type: - attrs["frontend_stream_type"] = self.frontend_stream_type + if frontend_stream_type := self.frontend_stream_type: + attrs["frontend_stream_type"] = frontend_stream_type return attrs From 9ca09bd6f0956266769dfe100983deb0124aeb9e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 9 Jan 2024 08:18:22 +0100 Subject: [PATCH 0425/1544] Tado unavailable state to device tracker (#107542) * Adding unavailable state to device tracker * Small fixes --- homeassistant/components/tado/device_tracker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 9c50318639d..e9d85abd2da 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -123,6 +123,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): """A Tado Device Tracker entity.""" _attr_should_poll = False + _attr_available = False def __init__( self, @@ -150,6 +151,17 @@ class TadoDeviceTrackerEntity(TrackerEntity): ) device = self._tado.data["mobile_device"][self._device_id] + self._attr_available = False + _LOGGER.debug( + "Tado device %s has geoTracking state %s", + device["name"], + device["settings"]["geoTrackingEnabled"], + ) + + if device["settings"]["geoTrackingEnabled"] is False: + return + + self._attr_available = True self._active = False if device.get("location") is not None and device["location"]["atHome"]: _LOGGER.debug("Tado device %s is at home", device["name"]) From 49e3c740cced2f4752c8c50ed58379c5d969e92c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 21:51:35 -1000 Subject: [PATCH 0426/1544] Small cleanups to temperature helper (#107625) --- homeassistant/helpers/temperature.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index 5a35f1bee13..15d38063f63 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -24,17 +24,14 @@ def display_temp( raise TypeError(f"Temperature is not a number: {temperature}") if temperature_unit != ha_unit: - temperature = TemperatureConverter.convert( - temperature, temperature_unit, ha_unit + temperature = TemperatureConverter.converter_factory(temperature_unit, ha_unit)( + temperature ) # Round in the units appropriate if precision == PRECISION_HALVES: - temperature = round(temperature * 2) / 2.0 - elif precision == PRECISION_TENTHS: - temperature = round(temperature, 1) + return round(temperature * 2) / 2.0 + if precision == PRECISION_TENTHS: + return round(temperature, 1) # Integer as a fall back (PRECISION_WHOLE) - else: - temperature = round(temperature) - - return temperature + return round(temperature) From 1e4d10efe1188c3405b5c66f1def3e4b96cdc096 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Jan 2024 21:51:56 -1000 Subject: [PATCH 0427/1544] Add caching to the distance calculation utility (#107626) --- homeassistant/util/location.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index b2ef7330660..9e9c434822e 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -5,6 +5,7 @@ detect_location_info and elevation are mocked by default during tests. from __future__ import annotations import asyncio +from functools import lru_cache import math from typing import Any, NamedTuple @@ -57,6 +58,7 @@ async def async_detect_location_info( return LocationInfo(**data) +@lru_cache def distance( lat1: float | None, lon1: float | None, lat2: float, lon2: float ) -> float | None: From 3a36117c0891a291c05d38113f90d9ceab473551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 9 Jan 2024 08:27:16 +0000 Subject: [PATCH 0428/1544] Bump idasen-ha to 2.5 (#107607) --- homeassistant/components/idasen_desk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 0a96a976bb3..80e07fe1065 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.4"] + "requirements": ["idasen-ha==2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index da1a3d6e043..6e22272006d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1090,7 +1090,7 @@ ical==6.1.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.4 +idasen-ha==2.5 # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 92e74f7545c..b0191916bda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -874,7 +874,7 @@ ical==6.1.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.4 +idasen-ha==2.5 # homeassistant.components.network ifaddr==0.2.0 From 3c53693fe37b2ead58f8ca9e3f2310555e8dac28 Mon Sep 17 00:00:00 2001 From: vexofp Date: Tue, 9 Jan 2024 06:32:27 -0500 Subject: [PATCH 0429/1544] Prevent toggle from calling stop on covers which do not support it (#106848) * Prevent toggle from calling stop on covers which do not support it * Update homeassistant/components/cover/__init__.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/cover/__init__.py | 2 +- tests/components/cover/test_init.py | 22 +++++++++- .../custom_components/test/cover.py | 44 +++++++++++++------ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 945585de522..89f79ca9d7a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -481,7 +481,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _get_toggle_function( self, fns: dict[str, Callable[_P, _R]] ) -> Callable[_P, _R]: - if CoverEntityFeature.STOP | self.supported_features and ( + if self.supported_features & CoverEntityFeature.STOP and ( self.is_closing or self.is_opening ): return fns["stop"] diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 480d1ef83aa..0503017f634 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -34,7 +34,8 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - # ent3 = cover with simple tilt functions and no position # ent4 = cover with all tilt functions but no position # ent5 = cover with all functions - ent1, ent2, ent3, ent4, ent5 = platform.ENTITIES + # ent6 = cover with only open/close, but also reports opening/closing + ent1, ent2, ent3, ent4, ent5, ent6 = platform.ENTITIES # Test init all covers should be open assert is_open(hass, ent1) @@ -42,6 +43,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_open(hass, ent3) assert is_open(hass, ent4) assert is_open(hass, ent5) + assert is_open(hass, ent6) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -49,13 +51,15 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) - # entities without stop should be closed and with stop should be closing + # entities should be either closed or closing, depending on if they report transitional states assert is_closed(hass, ent1) assert is_closing(hass, ent2) assert is_closed(hass, ent3) assert is_closed(hass, ent4) assert is_closing(hass, ent5) + assert is_closing(hass, ent6) # call basic toggle services and set different cover position states await call_service(hass, SERVICE_TOGGLE, ent1) @@ -65,6 +69,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent4) set_cover_position(ent5, 15) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) # entities should be in correct state depending on the SUPPORT_STOP feature and cover position assert is_open(hass, ent1) @@ -72,6 +77,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_open(hass, ent3) assert is_open(hass, ent4) assert is_open(hass, ent5) + assert is_opening(hass, ent6) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -79,6 +85,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) # entities should be in correct state depending on the SUPPORT_STOP feature and cover position assert is_closed(hass, ent1) @@ -86,6 +93,12 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_closed(hass, ent3) assert is_closed(hass, ent4) assert is_opening(hass, ent5) + assert is_closing(hass, ent6) + + # Without STOP but still reports opening/closing has a 4th possible toggle state + set_state(ent6, STATE_CLOSED) + await call_service(hass, SERVICE_TOGGLE, ent6) + assert is_opening(hass, ent6) def call_service(hass, service, ent): @@ -100,6 +113,11 @@ def set_cover_position(ent, position) -> None: ent._values["current_cover_position"] = position +def set_state(ent, state) -> None: + """Set the state of a cover.""" + ent._values["state"] = state + + def is_open(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_OPEN) diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 2a57412ea9e..dc89b95981b 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,6 +2,8 @@ Call init before using it in your tests to ensure clean test data. """ +from typing import Any + from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING @@ -70,6 +72,13 @@ def init(empty=False): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION, ), + MockCover( + name="Simple with opening/closing cover", + is_on=True, + unique_id="unique_opening_closing_cover", + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + reports_opening_closing=True, + ), ] ) @@ -84,50 +93,59 @@ async def async_setup_platform( class MockCover(MockEntity, CoverEntity): """Mock Cover class.""" + def __init__( + self, reports_opening_closing: bool | None = None, **values: Any + ) -> None: + """Initialize a mock cover entity.""" + + super().__init__(**values) + self._reports_opening_closing = ( + reports_opening_closing + if reports_opening_closing is not None + else CoverEntityFeature.STOP in self.supported_features + ) + @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & CoverEntityFeature.STOP: - return self.current_cover_position == 0 + if "state" in self._values and self._values["state"] == STATE_CLOSED: + return True - if "state" in self._values: - return self._values["state"] == STATE_CLOSED - return False + return self.current_cover_position == 0 @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & CoverEntityFeature.STOP: - if "state" in self._values: - return self._values["state"] == STATE_OPENING + if "state" in self._values: + return self._values["state"] == STATE_OPENING return False @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & CoverEntityFeature.STOP: - if "state" in self._values: - return self._values["state"] == STATE_CLOSING + if "state" in self._values: + return self._values["state"] == STATE_CLOSING return False def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & CoverEntityFeature.STOP: + if self._reports_opening_closing: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & CoverEntityFeature.STOP: + if self._reports_opening_closing: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED def stop_cover(self, **kwargs) -> None: """Stop cover.""" + assert CoverEntityFeature.STOP in self.supported_features self._values["state"] = STATE_CLOSED if self.is_closed else STATE_OPEN @property From 29dd70ccfb18598e353dea70df139f7d6616596c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 9 Jan 2024 13:08:40 +0100 Subject: [PATCH 0430/1544] Fix tplink_lte setup (#107642) --- homeassistant/components/tplink_lte/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 378fd0a35d4..5ac3085520e 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -94,7 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: tasks = [_setup_lte(hass, conf) for conf in domain_config] if tasks: - await asyncio.wait(tasks) + await asyncio.gather(*tasks) for conf in domain_config: for notify_conf in conf.get(CONF_NOTIFY, []): From c9d0134b8b9ec4a068e64ab327473c8938ff6255 Mon Sep 17 00:00:00 2001 From: Manuel Dipolt Date: Tue, 9 Jan 2024 13:56:50 +0100 Subject: [PATCH 0431/1544] Remove deprecated line in osoenergy (#107553) --- homeassistant/components/osoenergy/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py index a7632b19bcb..28b037f9cc5 100644 --- a/homeassistant/components/osoenergy/config_flow.py +++ b/homeassistant/components/osoenergy/config_flow.py @@ -22,7 +22,6 @@ class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a OSO Energy config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self) -> None: """Initialize.""" From fd533e46ddf523bbf65c686dbe761ea5eb1a973b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 14:08:58 +0100 Subject: [PATCH 0432/1544] Correct state class in `mobile_app` tests (#107646) Correct right state class in `mobile_app` tests --- tests/components/mobile_app/test_sensor.py | 8 ++++---- tests/components/mobile_app/test_webhook.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index f7c4a5690db..c1414533fd7 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -53,7 +53,7 @@ async def test_sensor( "type": "sensor", "entity_category": "diagnostic", "unique_id": "battery_temp", - "state_class": "total", + "state_class": "measurement", "unit_of_measurement": UnitOfTemperature.CELSIUS, }, }, @@ -73,7 +73,7 @@ async def test_sensor( # unit of temperature sensor is automatically converted to the system UoM assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" - assert entity.attributes["state_class"] == "total" + assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" assert entity.state == state1 @@ -175,7 +175,7 @@ async def test_sensor_migration( "type": "sensor", "entity_category": "diagnostic", "unique_id": unique_id, - "state_class": "total", + "state_class": "measurement", "unit_of_measurement": UnitOfTemperature.CELSIUS, }, }, @@ -195,7 +195,7 @@ async def test_sensor_migration( # unit of temperature sensor is automatically converted to the system UoM assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" - assert entity.attributes["state_class"] == "total" + assert entity.attributes["state_class"] == "measurement" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery Temperature" assert entity.state == state1 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 6fe272fbc40..c5e5801cda8 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -962,7 +962,7 @@ async def test_reregister_sensor( "state": 100, "type": "sensor", "unique_id": "abcd", - "state_class": "total", + "state_class": "measurement", "device_class": "battery", "entity_category": "diagnostic", "icon": "mdi:new-icon", From 15cee586379a1e156ba287f416f261913fef0ea7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 14:32:13 +0100 Subject: [PATCH 0433/1544] Remove deprecated YAML support from zodiac (#107584) --- homeassistant/components/zodiac/__init__.py | 41 +------------------ .../components/zodiac/config_flow.py | 4 -- tests/components/zodiac/test_config_flow.py | 20 +-------- 3 files changed, 4 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/zodiac/__init__.py b/homeassistant/components/zodiac/__init__.py index 48d1d8aa7aa..1e7c1f3bc43 100644 --- a/homeassistant/components/zodiac/__init__.py +++ b/homeassistant/components/zodiac/__init__.py @@ -1,45 +1,8 @@ """The zodiac component.""" -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -CONFIG_SCHEMA = vol.Schema( - {vol.Optional(DOMAIN): {}}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the zodiac component.""" - if DOMAIN in config: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.1.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Zodiac", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - return True +from homeassistant.core import HomeAssistant async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/zodiac/config_flow.py b/homeassistant/components/zodiac/config_flow.py index ebc0a819d1d..4acb3873031 100644 --- a/homeassistant/components/zodiac/config_flow.py +++ b/homeassistant/components/zodiac/config_flow.py @@ -25,7 +25,3 @@ class ZodiacConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data={}) return self.async_show_form(step_id="user") - - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/tests/components/zodiac/test_config_flow.py b/tests/components/zodiac/test_config_flow.py index 18a512e0b45..4b5baefb0f2 100644 --- a/tests/components/zodiac/test_config_flow.py +++ b/tests/components/zodiac/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.zodiac.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,7 +36,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +@pytest.mark.parametrize("source", [SOURCE_USER]) async def test_single_instance_allowed( hass: HomeAssistant, source: str, @@ -52,19 +52,3 @@ async def test_single_instance_allowed( assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" - - -async def test_import_flow( - hass: HomeAssistant, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "Zodiac" - assert result.get("data") == {} - assert result.get("options") == {} From 249e10d8c7f9304a161f3a287a4aca142f7855d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jan 2024 03:55:20 -1000 Subject: [PATCH 0434/1544] Fix dlink test mutating config entry after its adding to hass (#107604) --- tests/components/dlink/conftest.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 5618a6645ca..73e6baa2666 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -43,9 +43,9 @@ CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( ComponentSetup = Callable[[], Awaitable[None]] -def create_entry(hass: HomeAssistant) -> MockConfigEntry: +def create_entry(hass: HomeAssistant, unique_id: str | None = None) -> MockConfigEntry: """Create fixture for adding config entry in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA, unique_id=unique_id) entry.add_to_hass(hass) return entry @@ -59,9 +59,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture def config_entry_with_uid(hass: HomeAssistant) -> MockConfigEntry: """Add config entry with unique ID in Home Assistant.""" - config_entry = create_entry(hass) - config_entry.unique_id = "aa:bb:cc:dd:ee:ff" - return config_entry + return create_entry(hass, unique_id="aa:bb:cc:dd:ee:ff") @pytest.fixture From 5d259586e57901bea7f62f5b4dbd9fdb58a82b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Tue, 9 Jan 2024 15:17:52 +0100 Subject: [PATCH 0435/1544] Airthings cloud: Add myself as codeowner (#107654) Add myself as codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/airthings/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index dabd49b34e0..d041f9c4bcd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,8 +45,8 @@ build.json @home-assistant/supervisor /tests/components/airnow/ @asymworks /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 -/homeassistant/components/airthings/ @danielhiversen -/tests/components/airthings/ @danielhiversen +/homeassistant/components/airthings/ @danielhiversen @LaStrada +/tests/components/airthings/ @danielhiversen @LaStrada /homeassistant/components/airthings_ble/ @vincegio @LaStrada /tests/components/airthings_ble/ @vincegio @LaStrada /homeassistant/components/airtouch4/ @samsinnamon diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index da7f30679c6..f8dad08c5d1 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -1,7 +1,7 @@ { "domain": "airthings", "name": "Airthings", - "codeowners": ["@danielhiversen"], + "codeowners": ["@danielhiversen", "@LaStrada"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", From 4dbaa576a7b3f5c7f7f727e033c3517bc497ff6b Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 9 Jan 2024 10:06:04 -0500 Subject: [PATCH 0436/1544] Remove unused option flow from blink (#106735) * Remove unused option flow * remove update listener * adjust scan_interval to original default * default scn interval back to 30s --- homeassistant/components/blink/__init__.py | 7 --- homeassistant/components/blink/config_flow.py | 43 ++--------------- tests/components/blink/test_config_flow.py | 47 +------------------ 3 files changed, 4 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index d83c2686563..50c7fad516a 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -107,7 +107,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -129,9 +128,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - blink: Blink = hass.data[DOMAIN][entry.entry_id].api - blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 4326c6cb86c..aaacbb9390c 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -9,46 +9,17 @@ from blinkpy.auth import Auth, LoginError, TokenRefreshFailed from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import ( - CONF_PASSWORD, - CONF_PIN, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaFlowFormStep, - SchemaOptionsFlowHandler, -) -from .const import DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN +from .const import DEVICE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) -SIMPLE_OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): selector.NumberSelector( - selector.NumberSelectorConfig( - mode=selector.NumberSelectorMode.BOX, - unit_of_measurement="seconds", - ), - ), - } -) - - -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(next_step="simple_options"), - "simple_options": SchemaFlowFormStep(SIMPLE_OPTIONS_SCHEMA), -} - async def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" @@ -78,14 +49,6 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the blink flow.""" self.auth: Auth | None = None - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler: - """Get options flow for this handler.""" - return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index ada38451754..e5ce3c83fb7 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Blink config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch from blinkpy.auth import LoginError from blinkpy.blinkpy import BlinkSetupError @@ -8,8 +8,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.blink import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry - async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -258,46 +256,3 @@ async def test_reauth_shows_user_step(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "blink@example.com", "password": "example"}, - options={}, - entry_id=1, - version=3, - ) - config_entry.add_to_hass(hass) - - mock_auth = AsyncMock( - startup=Mock(return_value=True), check_key_required=Mock(return_value=False) - ) - mock_blink = AsyncMock(cameras=Mock(), sync=Mock()) - - with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( - "homeassistant.components.blink.Blink", return_value=mock_blink - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - config_entry.entry_id, context={"show_advanced_options": False} - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "simple_options" - - with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( - "homeassistant.components.blink.Blink", return_value=mock_blink - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"scan_interval": 5}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == {"scan_interval": 5} - await hass.async_block_till_done() - - assert mock_blink.refresh_rate == 5 From c62e79f9ee6435a6bb460c6ae084030f4d8b0cc7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 16:06:18 +0100 Subject: [PATCH 0437/1544] Use right state class for kWh sensor in `homekit_controller` (#107644) --- homeassistant/components/homekit_controller/sensor.py | 2 +- .../homekit_controller/snapshots/test_init.ambr | 8 ++++---- .../specific_devices/test_connectsense.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index eb5b99e126d..ebfba110e48 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -164,7 +164,7 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { key=CharacteristicsTypes.VENDOR_CONNECTSENSE_ENERGY_KW_HOUR, name="Energy kWh", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: HomeKitSensorEntityDescription( diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 2f38229aef8..476ab390217 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -2477,7 +2477,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': 'TestData', 'device_class': None, @@ -2505,7 +2505,7 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'InWall Outlet-0394DE Energy kWh', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', @@ -2518,7 +2518,7 @@ ]), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': 'TestData', 'device_class': None, @@ -2546,7 +2546,7 @@ 'attributes': dict({ 'device_class': 'energy', 'friendly_name': 'InWall Outlet-0394DE Energy kWh', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 9e08c6fed0a..94a91bb0417 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -50,7 +50,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), @@ -80,7 +80,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), @@ -129,7 +129,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), @@ -159,7 +159,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, + capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), From 1a6418d361473ba9123dcb73958aa4870728601a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 16:06:36 +0100 Subject: [PATCH 0438/1544] Use right state class in `filter` test (#107643) --- tests/components/filter/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 1f93875a001..ffb306a23c1 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -276,14 +276,14 @@ async def test_setup( "icon": "mdi:test", ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test") assert state.attributes["icon"] == "mdi:test" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT assert state.state == "1.0" entity_id = entity_registry.async_get_entity_id( From 33dd6f66e396097196925be252d6f7f1ddf6dc93 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 16:30:21 +0100 Subject: [PATCH 0439/1544] Correct device class in `sql` tests (#107663) --- tests/components/sql/__init__.py | 5 +++-- tests/components/sql/test_sensor.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 9cdd026bd3b..1d3ce0878c3 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + UnitOfInformation, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.trigger_template_entity import ( @@ -170,10 +171,10 @@ YAML_CONFIG = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_UNIT_OF_MEASUREMENT: UnitOfInformation.MEBIBYTES, CONF_UNIQUE_ID: "unique_id_12345", CONF_VALUE_TEMPLATE: "{{ value }}", - CONF_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, } } diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 9ac22f48312..6c2686cb6fe 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfInformation, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -401,9 +402,9 @@ async def test_attributes_from_yaml_setup( state = hass.states.get("sensor.get_value") assert state.state == "5" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE + assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - assert state.attributes["unit_of_measurement"] == "MiB" + assert state.attributes["unit_of_measurement"] == UnitOfInformation.MEBIBYTES async def test_binary_data_from_yaml_setup( From 29cac5b093443a86044224ed5365fbbb70760840 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 9 Jan 2024 10:48:01 -0500 Subject: [PATCH 0440/1544] Bump Python-Roborock to 0.39.0 (#107547) * bump to 0.39.0 * add new strings * change strings --- homeassistant/components/roborock/__init__.py | 2 +- .../components/roborock/config_flow.py | 2 +- .../components/roborock/manifest.json | 2 +- .../components/roborock/strings.json | 26 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 124 ++++++++++++++++++ 7 files changed, 153 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index ff49b352c18..25c1fbb041f 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -8,9 +8,9 @@ import logging from typing import Any from roborock import RoborockException, RoborockInvalidCredentials -from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData +from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 201631f0825..82c513a1b97 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -5,7 +5,6 @@ from collections.abc import Mapping import logging from typing import Any -from roborock.api import RoborockApiClient from roborock.containers import UserData from roborock.exceptions import ( RoborockAccountDoesNotExist, @@ -14,6 +13,7 @@ from roborock.exceptions import ( RoborockInvalidEmail, RoborockUrlException, ) +from roborock.web_api import RoborockApiClient import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index c149b9fcf7f..b2567b89abe 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.38.0", + "python-roborock==0.39.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 67660816de7..72a1035f5ca 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -136,7 +136,11 @@ "washing_the_mop": "Washing the mop", "going_to_wash_the_mop": "Going to wash the mop", "charging_complete": "Charging complete", - "device_offline": "Device offline" + "device_offline": "Device offline", + "unknown": "Unknown", + "locked": "Locked", + "air_drying_stopping": "Air drying stopping", + "egg_attack": "Cupid mode" } }, "total_cleaning_time": { @@ -174,7 +178,25 @@ "filter_blocked": "Filter blocked", "invisible_wall_detected": "Invisible wall detected", "cannot_cross_carpet": "Cannot cross carpet", - "internal_error": "Internal error" + "internal_error": "Internal error", + "strainer_error": "Filter is wet or blocked", + "compass_error": "Strong magnetic field detected", + "dock": "Dock not connected to power", + "visual_sensor": "Camera error", + "light_touch": "Wall sensor error", + "collect_dust_error_3": "Clean auto-empty dock", + "collect_dust_error_4": "Auto empty dock voltage error", + "mopping_roller_1": "Wash roller may be jammed", + "mopping_roller_error_2": "Wash roller not lowered properly", + "clear_water_box_hoare": "Check the clean water tank", + "dirty_water_box_hoare": "Check the dirty water tank", + "sink_strainer_hoare": "Reinstall the water filter", + "clear_water_box_exception": "Clean water tank empty", + "clear_brush_exception": "Check that the water filter has been correctly installed", + "clear_brush_exception_2": "Positioning button error", + "filter_screen_exception": "Clean the dock water filter", + "mopping_roller_2": "[%key:component::roborock::entity::sensor::vacuum_error::state::mopping_roller_1%]", + "temperature_protection": "Unit temperature protection" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 6e22272006d..556d6dc0bc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.38.0 +python-roborock==0.39.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0191916bda..6b6f77f520c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1702,7 +1702,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==0.38.0 +python-roborock==0.39.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 6d851e41bce..9176e2552ef 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -36,6 +36,65 @@ 'roborock_device_info': dict({ 'device': dict({ 'activeTime': 1672364449, + 'deviceFeatures': dict({ + 'anyStateTransitGotoSupported': True, + 'avoidCollisionSupported': False, + 'backChargeAutoWashSupported': False, + 'carefulSlowMapSupported': False, + 'carpetDeepCleanSupported': False, + 'carpetShowOnMap': False, + 'carpetSupported': True, + 'cleanRouteFastModeSupported': False, + 'cliffZoneSupported': False, + 'currentMapRestoreEnabled': False, + 'customDoorSillSupported': False, + 'customWaterBoxDistanceSupported': False, + 'downloadTestVoiceSupported': False, + 'dryingSupported': False, + 'dustCollectionSettingSupported': False, + 'eggModeSupported': False, + 'flowLedSettingSupported': False, + 'fwFilterObstacleSupported': True, + 'ignoreUnknownMapObjectSupported': True, + 'mapBeautifyInternalDebugSupported': False, + 'mapCarpetAddSupported': False, + 'mopPathSupported': False, + 'multiMapSegmentTimerSupported': False, + 'newDataForCleanHistory': False, + 'newDataForCleanHistoryDetail': False, + 'offlineMapSupported': False, + 'photoUploadSupported': False, + 'recordAllowed': True, + 'resegmentSupported': False, + 'roomNameSupported': False, + 'rpcRetrySupported': False, + 'setChildSupported': True, + 'shakeMopSetSupported': False, + 'showCleanFinishReasonSupported': True, + 'smartDoorSillSupported': False, + 'stuckZoneSupported': False, + 'supportBackupMap': False, + 'supportCleanEstimate': False, + 'supportCustomDnd': False, + 'supportCustomModeInCleaning': False, + 'supportFloorDirection': False, + 'supportFloorEdit': False, + 'supportFurniture': False, + 'supportIncrementalMap': True, + 'supportQuickMapBuilder': False, + 'supportRemoteControlInCall': False, + 'supportRoomTag': False, + 'supportSetSwitchMapMode': False, + 'supportSetVolumeInCall': True, + 'supportSmartGlobalCleanWithCustomMode': False, + 'supportSmartScene': False, + 'supportedValleyElectricity': False, + 'unsaveMapReasonSupported': False, + 'videoMonitorSupported': True, + 'videoSettingSupported': True, + 'washThenChargeCmdSupported': False, + 'wifiManageSupported': False, + }), 'deviceStatus': dict({ '120': 0, '121': 8, @@ -208,7 +267,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ + 'cleaningBrushTimeLeft': 1079935, 'cleaningBrushWorkTimes': 65, + 'dustCollectionTimeLeft': 80975, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -219,6 +280,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, + 'strainerTimeLeft': 539935, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ @@ -310,6 +372,65 @@ 'roborock_device_info': dict({ 'device': dict({ 'activeTime': 1672364449, + 'deviceFeatures': dict({ + 'anyStateTransitGotoSupported': True, + 'avoidCollisionSupported': False, + 'backChargeAutoWashSupported': False, + 'carefulSlowMapSupported': False, + 'carpetDeepCleanSupported': False, + 'carpetShowOnMap': False, + 'carpetSupported': True, + 'cleanRouteFastModeSupported': False, + 'cliffZoneSupported': False, + 'currentMapRestoreEnabled': False, + 'customDoorSillSupported': False, + 'customWaterBoxDistanceSupported': False, + 'downloadTestVoiceSupported': False, + 'dryingSupported': False, + 'dustCollectionSettingSupported': False, + 'eggModeSupported': False, + 'flowLedSettingSupported': False, + 'fwFilterObstacleSupported': True, + 'ignoreUnknownMapObjectSupported': True, + 'mapBeautifyInternalDebugSupported': False, + 'mapCarpetAddSupported': False, + 'mopPathSupported': False, + 'multiMapSegmentTimerSupported': False, + 'newDataForCleanHistory': False, + 'newDataForCleanHistoryDetail': False, + 'offlineMapSupported': False, + 'photoUploadSupported': False, + 'recordAllowed': True, + 'resegmentSupported': False, + 'roomNameSupported': False, + 'rpcRetrySupported': False, + 'setChildSupported': True, + 'shakeMopSetSupported': False, + 'showCleanFinishReasonSupported': True, + 'smartDoorSillSupported': False, + 'stuckZoneSupported': False, + 'supportBackupMap': False, + 'supportCleanEstimate': False, + 'supportCustomDnd': False, + 'supportCustomModeInCleaning': False, + 'supportFloorDirection': False, + 'supportFloorEdit': False, + 'supportFurniture': False, + 'supportIncrementalMap': True, + 'supportQuickMapBuilder': False, + 'supportRemoteControlInCall': False, + 'supportRoomTag': False, + 'supportSetSwitchMapMode': False, + 'supportSetVolumeInCall': True, + 'supportSmartGlobalCleanWithCustomMode': False, + 'supportSmartScene': False, + 'supportedValleyElectricity': False, + 'unsaveMapReasonSupported': False, + 'videoMonitorSupported': True, + 'videoSettingSupported': True, + 'washThenChargeCmdSupported': False, + 'wifiManageSupported': False, + }), 'deviceStatus': dict({ '120': 0, '121': 8, @@ -482,7 +603,9 @@ 'squareMeterCleanArea': 1159.2, }), 'consumable': dict({ + 'cleaningBrushTimeLeft': 1079935, 'cleaningBrushWorkTimes': 65, + 'dustCollectionTimeLeft': 80975, 'dustCollectionWorkTimes': 25, 'filterElementWorkTime': 0, 'filterTimeLeft': 465618, @@ -493,6 +616,7 @@ 'sensorTimeLeft': 33618, 'sideBrushTimeLeft': 645618, 'sideBrushWorkTime': 74382, + 'strainerTimeLeft': 539935, 'strainerWorkTimes': 65, }), 'lastCleanRecord': dict({ From 71dcbb95ab50b53fa76621e1b14162e65f241847 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 18:38:42 +0100 Subject: [PATCH 0441/1544] Remove deprecated services from Ezviz (#107582) --- homeassistant/components/ezviz/camera.py | 104 +------------------ homeassistant/components/ezviz/const.py | 3 - homeassistant/components/ezviz/services.yaml | 60 +---------- homeassistant/components/ezviz/strings.json | 69 ------------ 4 files changed, 2 insertions(+), 234 deletions(-) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index e42968603e4..6397d8a27dc 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError -import voluptuous as vol from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature @@ -17,34 +16,19 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - issue_registry as ir, -) +from homeassistant.helpers import discovery_flow from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) from .const import ( - ATTR_DIRECTION, - ATTR_ENABLE, - ATTR_LEVEL, ATTR_SERIAL, - ATTR_SPEED, CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DIR_DOWN, - DIR_LEFT, - DIR_RIGHT, - DIR_UP, DOMAIN, - SERVICE_ALARM_SOUND, - SERVICE_ALARM_TRIGGER, - SERVICE_PTZ, SERVICE_WAKE_DEVICE, ) from .coordinator import EzvizDataUpdateCoordinator @@ -126,35 +110,10 @@ async def async_setup_entry( platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_PTZ, - { - vol.Required(ATTR_DIRECTION): vol.In( - [DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT] - ), - vol.Required(ATTR_SPEED): cv.positive_int, - }, - "perform_ptz", - ) - - platform.async_register_entity_service( - SERVICE_ALARM_TRIGGER, - { - vol.Required(ATTR_ENABLE): cv.positive_int, - }, - "perform_sound_alarm", - ) - platform.async_register_entity_service( SERVICE_WAKE_DEVICE, {}, "perform_wake_device" ) - platform.async_register_entity_service( - SERVICE_ALARM_SOUND, - {vol.Required(ATTR_LEVEL): cv.positive_int}, - "perform_alarm_sound", - ) - class EzvizCamera(EzvizEntity, Camera): """An implementation of a EZVIZ security camera.""" @@ -251,70 +210,9 @@ class EzvizCamera(EzvizEntity, Camera): return self._rtsp_stream - def perform_ptz(self, direction: str, speed: int) -> None: - """Perform a PTZ action on the camera.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_ptz", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_ptz", - ) - - try: - self.coordinator.ezviz_client.ptz_control( - str(direction).upper(), self._serial, "START", speed - ) - self.coordinator.ezviz_client.ptz_control( - str(direction).upper(), self._serial, "STOP", speed - ) - - except HTTPError as err: - raise HTTPError("Cannot perform PTZ") from err - - def perform_sound_alarm(self, enable: int) -> None: - """Sound the alarm on a camera.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_depreciation_sound_alarm", - breaks_in_ha_version="2024.3.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_depreciation_sound_alarm", - ) - - try: - self.coordinator.ezviz_client.sound_alarm(self._serial, enable) - except HTTPError as err: - raise HTTPError("Cannot sound alarm") from err - def perform_wake_device(self) -> None: """Basically wakes the camera by querying the device.""" try: self.coordinator.ezviz_client.get_detection_sensibility(self._serial) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot wake device") from err - - def perform_alarm_sound(self, level: int) -> None: - """Enable/Disable movement sound alarm.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_alarm_sound_level", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_alarm_sound_level", - ) - try: - self.coordinator.ezviz_client.alarm_sound(self._serial, level, 1) - except HTTPError as err: - raise HTTPError( - "Cannot set alarm sound level for on movement detected" - ) from err diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index c28d84552d6..651110dd5d7 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -24,10 +24,7 @@ ATTR_LEVEL = "level" ATTR_TYPE = "type_value" # Service names -SERVICE_PTZ = "ptz" -SERVICE_ALARM_TRIGGER = "sound_alarm" SERVICE_WAKE_DEVICE = "wake_device" -SERVICE_ALARM_SOUND = "alarm_sound" SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" # Defaults diff --git a/homeassistant/components/ezviz/services.yaml b/homeassistant/components/ezviz/services.yaml index 7d1cda2fa63..756a0fc67ef 100644 --- a/homeassistant/components/ezviz/services.yaml +++ b/homeassistant/components/ezviz/services.yaml @@ -1,46 +1,3 @@ -alarm_sound: - target: - entity: - integration: ezviz - domain: camera - fields: - level: - required: true - example: 0 - default: 0 - selector: - number: - min: 0 - max: 2 - step: 1 - mode: box -ptz: - target: - entity: - integration: ezviz - domain: camera - fields: - direction: - required: true - example: "up" - default: "up" - selector: - select: - options: - - "up" - - "down" - - "left" - - "right" - speed: - required: true - example: 5 - default: 5 - selector: - number: - min: 1 - max: 9 - step: 1 - mode: box set_alarm_detection_sensibility: target: entity: @@ -66,22 +23,7 @@ set_alarm_detection_sensibility: options: - "0" - "3" -sound_alarm: - target: - entity: - integration: ezviz - domain: camera - fields: - enable: - required: true - example: 1 - default: 1 - selector: - number: - min: 1 - max: 2 - step: 1 - mode: box + wake_device: target: entity: diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 11ec31fee4a..58ac9dfde09 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -59,41 +59,6 @@ } } }, - "issues": { - "service_deprecation_alarm_sound_level": { - "title": "Ezviz Alarm sound level service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_deprecation_alarm_sound_level::title%]", - "description": "Ezviz Alarm sound level service is deprecated and will be removed.\nTo set the Alarm sound level, you can instead use the `select.select_option` service targetting the Warning sound entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, - "service_depreciation_ptz": { - "title": "EZVIZ PTZ service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_ptz::title%]", - "description": "EZVIZ PTZ service is deprecated and will be removed.\nTo move the camera, you can instead use the `button.press` service targetting the PTZ* entities.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." - } - } - } - }, - "service_depreciation_sound_alarm": { - "title": "Ezviz Sound alarm service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ezviz::issues::service_depreciation_sound_alarm::title%]", - "description": "Ezviz Sound alarm service is deprecated and will be removed.\nTo sound the alarm, you can instead use the `siren.toggle` service targeting the Siren entity.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to fix this issue." - } - } - } - } - }, "entity": { "select": { "alarm_sound_mode": { @@ -219,30 +184,6 @@ } }, "services": { - "alarm_sound": { - "name": "Set warning sound level.", - "description": "Setx movement warning sound level.", - "fields": { - "level": { - "name": "Sound level", - "description": "Sound level (2 is disabled, 1 intensive, 0 soft)." - } - } - }, - "ptz": { - "name": "PTZ", - "description": "Moves the camera to the direction, with defined speed.", - "fields": { - "direction": { - "name": "Direction", - "description": "Direction to move camera (up, down, left, right)." - }, - "speed": { - "name": "Speed", - "description": "Speed of movement (from 1 to 9)." - } - } - }, "set_alarm_detection_sensibility": { "name": "Detection sensitivity", "description": "Sets the detection sensibility level.", @@ -257,16 +198,6 @@ } } }, - "sound_alarm": { - "name": "Sound alarm", - "description": "Sounds the alarm on your camera.", - "fields": { - "enable": { - "name": "Alarm sound", - "description": "Enter 1 or 2 (1=disable, 2=enable)." - } - } - }, "wake_device": { "name": "Wake camera", "description": "This can be used to wake the camera/device from hibernation." From 3141b92027c0333e5be15e62a48e84ad7781e19d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 18:39:05 +0100 Subject: [PATCH 0442/1544] Remove deprecated services from Huawei LTE (#107578) --- .../components/huawei_lte/__init__.py | 46 +------------------ homeassistant/components/huawei_lte/const.py | 4 -- .../components/huawei_lte/services.yaml | 14 ------ .../components/huawei_lte/strings.json | 26 ----------- 4 files changed, 1 insertion(+), 89 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 42a1e066ac7..ba276625730 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -13,7 +13,6 @@ from xml.parsers.expat import ExpatError from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection -from huawei_lte_api.enums.device import ControlModeEnum from huawei_lte_api.exceptions import ( LoginErrorInvalidCredentialsException, ResponseErrorException, @@ -51,7 +50,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -59,8 +57,6 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, ATTR_CONFIG_ENTRY_ID, - BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, - BUTTON_KEY_RESTART, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, @@ -84,8 +80,6 @@ from .const import ( KEY_WLAN_WIFI_FEATURE_SWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, NOTIFY_SUPPRESS_TIMEOUT, - SERVICE_CLEAR_TRAFFIC_STATISTICS, - SERVICE_REBOOT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, @@ -533,45 +527,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("%s: router %s unavailable", service.service, url) return - if service.service == SERVICE_CLEAR_TRAFFIC_STATISTICS: - create_issue( - hass, - DOMAIN, - "service_clear_traffic_statistics_moved_to_button", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="service_changed_to_button", - translation_placeholders={ - "service": service.service, - "button": BUTTON_KEY_CLEAR_TRAFFIC_STATISTICS, - }, - ) - if router.suspended: - _LOGGER.debug("%s: ignored, integration suspended", service.service) - return - result = router.client.monitoring.set_clear_traffic() - _LOGGER.debug("%s: %s", service.service, result) - elif service.service == SERVICE_REBOOT: - create_issue( - hass, - DOMAIN, - "service_reboot_moved_to_button", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="service_changed_to_button", - translation_placeholders={ - "service": service.service, - "button": BUTTON_KEY_RESTART, - }, - ) - if router.suspended: - _LOGGER.debug("%s: ignored, integration suspended", service.service) - return - result = router.client.device.set_control(ControlModeEnum.REBOOT) - _LOGGER.debug("%s: %s", service.service, result) - elif service.service == SERVICE_RESUME_INTEGRATION: + if service.service == SERVICE_RESUME_INTEGRATION: # Login will be handled automatically on demand router.suspended = False _LOGGER.debug("%s: %s", service.service, "done") diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index eba0f3ce90b..af9bfd330e9 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -19,14 +19,10 @@ UPDATE_SIGNAL = f"{DOMAIN}_update" CONNECTION_TIMEOUT = 10 NOTIFY_SUPPRESS_TIMEOUT = 30 -SERVICE_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" -SERVICE_REBOOT = "reboot" SERVICE_RESUME_INTEGRATION = "resume_integration" SERVICE_SUSPEND_INTEGRATION = "suspend_integration" ADMIN_SERVICES = { - SERVICE_CLEAR_TRAFFIC_STATISTICS, - SERVICE_REBOOT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, } diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml index 9d0cf5d91e6..a90b0b1df74 100644 --- a/homeassistant/components/huawei_lte/services.yaml +++ b/homeassistant/components/huawei_lte/services.yaml @@ -1,17 +1,3 @@ -clear_traffic_statistics: - fields: - url: - example: http://192.168.100.1/ - selector: - text: - -reboot: - fields: - url: - example: http://192.168.100.1/ - selector: - text: - resume_integration: fields: url: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 225146799a3..a1a3f5c9416 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -309,33 +309,7 @@ } } }, - "issues": { - "service_changed_to_button": { - "title": "Service changed to a button", - "description": "The {service} service is deprecated, use the corresponding {button} button instead." - } - }, "services": { - "clear_traffic_statistics": { - "name": "Clear traffic statistics", - "description": "Clears traffic statistics.", - "fields": { - "url": { - "name": "[%key:common::config_flow::data::url%]", - "description": "URL of router to clear; optional when only one is configured." - } - } - }, - "reboot": { - "name": "Reboot", - "description": "Reboots router.", - "fields": { - "url": { - "name": "[%key:common::config_flow::data::url%]", - "description": "URL of router to reboot; optional when only one is configured." - } - } - }, "resume_integration": { "name": "Resume integration", "description": "Resumes suspended integration.", From d1c1eb84289ae48be9b276246f7ad9cdd559057e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 9 Jan 2024 18:39:31 +0100 Subject: [PATCH 0443/1544] Add test for avoid triggering ping device tracker `home` after reload (#107107) --- tests/components/ping/test_device_tracker.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index d91cb46da0c..de6b4918262 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,4 +1,5 @@ """Test the binary sensor platform of ping.""" +from collections.abc import Generator from datetime import timedelta from unittest.mock import patch @@ -17,6 +18,16 @@ from homeassistant.util.yaml import dump from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files +@pytest.fixture +def entity_registry_enabled_by_default() -> Generator[None, None, None]: + """Test fixture that ensures ping device_tracker entities are enabled in the registry.""" + with patch( + "homeassistant.components.ping.device_tracker.PingDeviceTracker.entity_registry_enabled_default", + return_value=True, + ): + yield + + @pytest.mark.usefixtures("setup_integration") async def test_setup_and_update( hass: HomeAssistant, @@ -125,3 +136,38 @@ async def test_import_delete_known_devices( await hass.async_block_till_done() assert len(remove_device_from_config.mock_calls) == 1 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +async def test_reload_not_triggering_home( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, +): + """Test if reload/restart does not trigger home when device is unavailable.""" + assert hass.states.get("device_tracker.10_10_10_10").state == "home" + + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host("10.10.10.10", 5, []), + ): + # device should be "not_home" after consider_home interval + freezer.tick(timedelta(minutes=5, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.10_10_10_10").state == "not_home" + + # reload config entry + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # device should still be "not_home" after a reload + assert hass.states.get("device_tracker.10_10_10_10").state == "not_home" + + # device should be "home" after the next refresh + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.10_10_10_10").state == "home" From ab6b9fe891a7677de8bbc0d23c4ff60d50f0a61b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jan 2024 07:46:57 -1000 Subject: [PATCH 0444/1544] Avoid total_seconds conversion in bond keep alive (#107618) --- homeassistant/components/bond/entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 03a5f444579..2c54ad8f3dd 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod from asyncio import Lock, TimeoutError as AsyncIOTimeoutError -from datetime import datetime, timedelta +from datetime import datetime import logging from aiohttp import ClientError @@ -27,8 +27,8 @@ from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) -_FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) -_BPUP_ALIVE_SCAN_INTERVAL = timedelta(seconds=60) +_FALLBACK_SCAN_INTERVAL = 10 +_BPUP_ALIVE_SCAN_INTERVAL = 60 class BondEntity(Entity): From 9859306718867d9fe021d41f6840d8d3ce0cc589 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 9 Jan 2024 19:16:45 +0100 Subject: [PATCH 0445/1544] Prevent overriding cached attribute as property (#107657) * Prevent overriding cached attribute as property * Remove debug --- homeassistant/helpers/entity.py | 4 ++++ tests/helpers/test_entity.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 55a32670288..706e3136a8a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -13,6 +13,7 @@ import logging import math import sys from timeit import default_timer as timer +from types import FunctionType from typing import ( TYPE_CHECKING, Any, @@ -381,6 +382,9 @@ class CachedProperties(type): # Check if an _attr_ class attribute exits and move it to __attr_. We check # __dict__ here because we don't care about _attr_ class attributes in parents. if attr_name in cls.__dict__: + attr = getattr(cls, attr_name) + if isinstance(attr, (FunctionType, property)): + raise TypeError(f"Can't override {attr_name} in subclass") setattr(cls, private_attr_name, getattr(cls, attr_name)) annotations = cls.__annotations__ if attr_name in annotations: diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index dd26b947f67..1dc878a8eba 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -2406,6 +2406,47 @@ async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> No assert getattr(ent[1], property) == values[0] +async def test_cached_entity_property_override(hass: HomeAssistant) -> None: + """Test overriding cached _attr_ raises.""" + + class EntityWithClassAttribute1(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution: str + + class EntityWithClassAttribute2(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution = "blabla" + + class EntityWithClassAttribute3(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution: str = "blabla" + + class EntityWithClassAttribute4(entity.Entity): + @property + def _attr_not_cached(self): + return "blabla" + + class EntityWithClassAttribute5(entity.Entity): + def _attr_not_cached(self): + return "blabla" + + with pytest.raises(TypeError): + + class EntityWithClassAttribute6(entity.Entity): + @property + def _attr_attribution(self): + return "🤡" + + with pytest.raises(TypeError): + + class EntityWithClassAttribute7(entity.Entity): + def _attr_attribution(self): + return "🤡" + + async def test_entity_report_deprecated_supported_features_values( caplog: pytest.LogCaptureFixture, ) -> None: From b739fa8c024129b19a8a6e891d6c9a41d58b4bae Mon Sep 17 00:00:00 2001 From: Yuval Aboulafia Date: Tue, 9 Jan 2024 22:01:11 +0200 Subject: [PATCH 0446/1544] Add missing 'state class' to Airvisual (#107666) --- homeassistant/components/airvisual_pro/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 6a8e32bc32c..00c87b02377 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -67,6 +67,7 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: status["battery"], ), AirVisualProMeasurementDescription( @@ -80,6 +81,7 @@ SENSOR_DESCRIPTIONS = ( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda settings, status, measurements, history: measurements[ "humidity" ], From 8181fbab5ccc369a6ca5f0c3f9a45910e7a65c48 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 9 Jan 2024 23:01:19 +0100 Subject: [PATCH 0447/1544] Fix `device_class` type for Shelly Gen1 sleeping sensors (#107683) --- homeassistant/components/shelly/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 89dc10f0530..c7d89f2d284 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -36,6 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType +from homeassistant.util.enum import try_parse_enum from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator @@ -969,7 +970,7 @@ def _build_block_description(entry: RegistryEntry) -> BlockSensorDescription: name="", icon=entry.original_icon, native_unit_of_measurement=entry.unit_of_measurement, - device_class=entry.original_device_class, + device_class=try_parse_enum(SensorDeviceClass, entry.original_device_class), ) From 554c27a31a69cee2346047182eb4eeb23f2be465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jan 2024 17:08:49 -1000 Subject: [PATCH 0448/1544] Clamp tplink color temp to valid range (#107695) --- homeassistant/components/tplink/light.py | 32 ++++++++++++++++++------ tests/components/tplink/test_light.py | 20 +++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 94bb4d287bb..c57fc9bfd85 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -222,6 +222,26 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): hue, sat = tuple(int(val) for val in hs_color) await self.device.set_hsv(hue, sat, brightness, transition=transition) + async def _async_set_color_temp( + self, color_temp: float | int, brightness: int | None, transition: int | None + ) -> None: + device = self.device + valid_temperature_range = device.valid_temperature_range + requested_color_temp = round(color_temp) + # Clamp color temp to valid range + # since if the light in a group we will + # get requests for color temps for the range + # of the group and not the light + clamped_color_temp = min( + valid_temperature_range.max, + max(valid_temperature_range.min, requested_color_temp), + ) + await device.set_color_temp( + clamped_color_temp, + brightness=brightness, + transition=transition, + ) + async def _async_turn_on_with_brightness( self, brightness: int | None, transition: int | None ) -> None: @@ -236,10 +256,8 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) if ATTR_COLOR_TEMP_KELVIN in kwargs: - await self.device.set_color_temp( - int(kwargs[ATTR_COLOR_TEMP_KELVIN]), - brightness=brightness, - transition=transition, + await self._async_set_color_temp( + kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition ) if ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) @@ -316,10 +334,8 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): # we have to set an HSV value to clear the effect # before we can set a color temp await self.device.set_hsv(0, 0, brightness) - await self.device.set_color_temp( - int(kwargs[ATTR_COLOR_TEMP_KELVIN]), - brightness=brightness, - transition=transition, + await self._async_set_color_temp( + kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition ) elif ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 348fcc50ce0..ada454e0192 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -264,6 +264,26 @@ async def test_color_temp_light( bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) bulb.set_color_temp.reset_mock() + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + async def test_brightness_only_light(hass: HomeAssistant) -> None: """Test a light.""" From bf6b9175a132d733b441b9ba1cf80f2709d65e3e Mon Sep 17 00:00:00 2001 From: Lars R Date: Wed, 10 Jan 2024 09:40:52 +0100 Subject: [PATCH 0449/1544] Add 'bitwise_xor' filter to jinja templates (#104942) Co-authored-by: Robert Resch --- homeassistant/helpers/template.py | 6 ++++++ tests/helpers/test_template.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ac37360d5e2..db4e333fa1a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2099,6 +2099,11 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def bitwise_xor(first_value, second_value): + """Perform a bitwise xor operation.""" + return first_value ^ second_value + + def struct_pack(value: Any | None, format_string: str) -> bytes | None: """Pack an object into a bytes object.""" try: @@ -2462,6 +2467,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or + self.filters["bitwise_xor"] = bitwise_xor self.filters["pack"] = struct_pack self.filters["unpack"] = struct_unpack self.filters["ord"] = ord diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b70c9479abb..bf48199d419 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2405,6 +2405,22 @@ def test_bitwise_or(hass: HomeAssistant) -> None: assert tpl.async_render() == 8 | 2 +@pytest.mark.parametrize( + ("value", "xor_value", "expected"), + [(8, 8, 0), (10, 2, 8), (0x8000, 0xFAFA, 31482), (True, False, 1), (True, True, 0)], +) +def test_bitwise_xor( + hass: HomeAssistant, value: Any, xor_value: Any, expected: int +) -> None: + """Test bitwise_xor method.""" + assert ( + template.Template("{{ value | bitwise_xor(xor_value) }}", hass).async_render( + {"value": value, "xor_value": xor_value} + ) + == expected + ) + + def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct pack method.""" From 0f79b6ac2a746e21ca3bc74eb4190c2db7960777 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 10 Jan 2024 09:43:04 +0100 Subject: [PATCH 0450/1544] Bump pytedee_async to 0.2.11 (#107707) --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 558137672d6..2a29b2610b3 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.10"] + "requirements": ["pytedee-async==0.2.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 556d6dc0bc4..6369a1c4ba6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.10 +pytedee-async==0.2.11 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b6f77f520c..9d11cb5614f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1644,7 +1644,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.10 +pytedee-async==0.2.11 # homeassistant.components.motionmount python-MotionMount==0.3.1 From 093e35f4d4fae7537d8c6bb600f4771bd69d749c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Wed, 10 Jan 2024 09:54:43 +0100 Subject: [PATCH 0451/1544] Remove myself as a codeowner from tado (#107708) --- CODEOWNERS | 4 ++-- homeassistant/components/tado/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d041f9c4bcd..8dba5e38df3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1304,8 +1304,8 @@ build.json @home-assistant/supervisor /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna -/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna +/homeassistant/components/tado/ @chiefdragon @erwindouna +/tests/components/tado/ @chiefdragon @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index bae637f3180..a4ef561b6ea 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"], + "codeowners": ["@chiefdragon", "@erwindouna"], "config_flow": true, "dhcp": [ { From 15e3af72d171ff0e7757307a576490d73cefac3c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 10 Jan 2024 12:09:10 +0100 Subject: [PATCH 0452/1544] Fix Tado unique mobile device dispatcher (#107631) * Add unique home ID device dispatch * Adding fixture for new setup * Minor refactor work * Add check for unlinked to different homes * If the interface returns an error * Proper error handling * Feedback fixes * Comments for error in client * Typo * Update homeassistant/components/tado/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/tado/__init__.py Co-authored-by: Martin Hjelmare * Update devices fix standard * Dispatch out of loop * Update dispatcher * Clean up --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/tado/__init__.py | 43 ++++++++++++++++--- homeassistant/components/tado/const.py | 2 +- .../components/tado/device_tracker.py | 12 +++--- .../tado/fixtures/mobile_devices.json | 26 +++++++++++ tests/components/tado/util.py | 5 +++ 5 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 tests/components/tado/fixtures/mobile_devices.json diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 7f166ccf01a..871d6c2e6b1 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -186,12 +186,13 @@ class TadoConnector: def get_mobile_devices(self): """Return the Tado mobile devices.""" - return self.tado.getMobileDevices() + return self.tado.get_mobile_devices() @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" self.update_devices() + self.update_mobile_devices() self.update_zones() self.update_home() @@ -203,17 +204,31 @@ class TadoConnector: _LOGGER.error("Unable to connect to Tado while updating mobile devices") return + if not mobile_devices: + _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id) + return + + # Errors are planned to be converted to exceptions + # in PyTado library, so this can be removed + if "errors" in mobile_devices and mobile_devices["errors"]: + _LOGGER.error( + "Error for home ID %s while updating mobile devices: %s", + self.home_id, + mobile_devices["errors"], + ) + return + for mobile_device in mobile_devices: self.data["mobile_device"][mobile_device["id"]] = mobile_device + _LOGGER.debug( + "Dispatching update to %s mobile device: %s", + self.home_id, + mobile_device, + ) - _LOGGER.debug( - "Dispatching update to %s mobile devices: %s", - self.home_id, - mobile_devices, - ) dispatcher_send( self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id), ) def update_devices(self): @@ -224,6 +239,20 @@ class TadoConnector: _LOGGER.error("Unable to connect to Tado while updating devices") return + if not devices: + _LOGGER.debug("No linked devices found for home ID %s", self.home_id) + return + + # Errors are planned to be converted to exceptions + # in PyTado library, so this can be removed + if "errors" in devices and devices["errors"]: + _LOGGER.error( + "Error for home ID %s while updating devices: %s", + self.home_id, + devices["errors"], + ) + return + for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index c14906c3a89..ee24af29b9d 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -179,7 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = { DOMAIN = "tado" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" -SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" +SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received_{}" UNIQUE_ID = "unique_id" DEFAULT_NAME = "Tado" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index e9d85abd2da..eb57aeaec79 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol @@ -22,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from . import TadoConnector from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED _LOGGER = logging.getLogger(__name__) @@ -90,7 +90,7 @@ async def async_setup_entry( entry.async_on_unload( async_dispatcher_connect( hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id), update_devices, ) ) @@ -99,12 +99,12 @@ async def async_setup_entry( @callback def add_tracked_entities( hass: HomeAssistant, - tado: Any, + tado: TadoConnector, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" - _LOGGER.debug("Fetching Tado devices from API") + _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") new_tracked = [] for device_key, device in tado.data["mobile_device"].items(): if device_key in tracked: @@ -129,7 +129,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self, device_id: str, device_name: str, - tado: Any, + tado: TadoConnector, ) -> None: """Initialize a Tado Device Tracker entity.""" super().__init__() @@ -181,7 +181,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id), self.on_demand_update, ) ) diff --git a/tests/components/tado/fixtures/mobile_devices.json b/tests/components/tado/fixtures/mobile_devices.json new file mode 100644 index 00000000000..80700a1e426 --- /dev/null +++ b/tests/components/tado/fixtures/mobile_devices.json @@ -0,0 +1,26 @@ +[ + { + "name": "Home", + "id": 123456, + "settings": { + "geoTrackingEnabled": false, + "specialOffersEnabled": false, + "onDemandLogRetrievalEnabled": false, + "pushNotifications": { + "lowBatteryReminder": true, + "awayModeReminder": true, + "homeModeReminder": true, + "openWindowReminder": true, + "energySavingsReportReminder": true, + "incidentDetection": true, + "energyIqReminder": false + } + }, + "deviceMetadata": { + "platform": "Android", + "osVersion": "14", + "model": "Samsung", + "locale": "nl" + } + } +] diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 21e0e255ed1..dd7c108c984 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -17,6 +17,7 @@ async def async_init_integration( token_fixture = "tado/token.json" devices_fixture = "tado/devices.json" + mobile_devices_fixture = "tado/mobile_devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" home_state_fixture = "tado/home_state.json" @@ -70,6 +71,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/devices", text=load_fixture(devices_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/mobileDevices", + text=load_fixture(mobile_devices_fixture), + ) m.get( "https://my.tado.com/api/v2/devices/WR1/", text=load_fixture(device_wr1_fixture), From 598e18ca8639d9fd2c94298b26857f2c2544dc8d Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 10 Jan 2024 13:23:02 +0100 Subject: [PATCH 0453/1544] Set proper sensor device class for swiss_public_transport (#106485) set proper sensor device class --- .../swiss_public_transport/coordinator.py | 35 ++++++++++++++----- .../swiss_public_transport/sensor.py | 11 ++++-- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 93b3312b099..97253d5776e 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for the swiss_public_transport integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import TypedDict @@ -21,9 +21,9 @@ _LOGGER = logging.getLogger(__name__) class DataConnection(TypedDict): """A connection data class.""" - departure: str - next_departure: str - next_on_departure: str + departure: datetime | None + next_departure: str | None + next_on_departure: str | None duration: str platform: str remaining_time: str @@ -58,18 +58,35 @@ class SwissPublicTransportDataUpdateCoordinator(DataUpdateCoordinator[DataConnec ) raise UpdateFailed from e - departure_time = dt_util.parse_datetime( - self._opendata.connections[0]["departure"] + departure_time = ( + dt_util.parse_datetime(self._opendata.connections[0]["departure"]) + if self._opendata.connections[0] is not None + else None ) + next_departure_time = ( + dt_util.parse_datetime(self._opendata.connections[1]["departure"]) + if self._opendata.connections[1] is not None + else None + ) + next_on_departure_time = ( + dt_util.parse_datetime(self._opendata.connections[2]["departure"]) + if self._opendata.connections[2] is not None + else None + ) + if departure_time: remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) else: remaining_time = None return DataConnection( - departure=self._opendata.connections[0]["departure"], - next_departure=self._opendata.connections[1]["departure"], - next_on_departure=self._opendata.connections[2]["departure"], + departure=departure_time, + next_departure=next_departure_time.isoformat() + if next_departure_time is not None + else None, + next_on_departure=next_on_departure_time.isoformat() + if next_on_departure_time is not None + else None, train_number=self._opendata.connections[0]["number"], platform=self._opendata.connections[0]["platform"], transfers=self._opendata.connections[0]["transfers"], diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 0e88cd2d3ad..ede2798f675 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -1,14 +1,18 @@ """Support for transport.opendata.ch.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback @@ -107,6 +111,7 @@ class SwissPublicTransportSensor( _attr_icon = "mdi:bus" _attr_has_entity_name = True _attr_translation_key = "departure" + _attr_device_class = SensorDeviceClass.TIMESTAMP def __init__( self, @@ -143,6 +148,6 @@ class SwissPublicTransportSensor( } @property - def native_value(self) -> str: + def native_value(self) -> datetime | None: """Return the state of the sensor.""" return self.coordinator.data["departure"] From 08e3178682cb720b6e7079b12078263fb10cf3d1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 10 Jan 2024 14:03:02 +0100 Subject: [PATCH 0454/1544] Allow configuration of min_gradient from UI to be negative in Trend (#107720) Allow configuration of min_gradient to be negative from UI --- homeassistant/components/trend/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 457522dca82..3d29618281a 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -67,7 +67,6 @@ async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.S CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, step="any", mode=selector.NumberSelectorMode.BOX, ), From 49bdfbd9caba47cb74a66750e2914b333b7d888a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Jan 2024 03:03:15 -1000 Subject: [PATCH 0455/1544] Bump govee-ble to 0.26.0 (#107706) --- homeassistant/components/govee_ble/manifest.json | 7 ++++++- homeassistant/generated/bluetooth.py | 6 ++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 5c47f116ce5..23bc20570e3 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -14,6 +14,11 @@ "local_name": "B5178*", "connectable": false }, + { + "manufacturer_id": 1, + "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", + "connectable": false + }, { "manufacturer_id": 6966, "service_uuid": "00008451-0000-1000-8000-00805f9b34fb", @@ -85,5 +90,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.24.0"] + "requirements": ["govee-ble==0.26.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 22710f31f87..cda39d8494f 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -120,6 +120,12 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "govee_ble", "local_name": "B5178*", }, + { + "connectable": False, + "domain": "govee_ble", + "manufacturer_id": 1, + "service_uuid": "0000ec88-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "govee_ble", diff --git a/requirements_all.txt b/requirements_all.txt index 6369a1c4ba6..d5b97fc558c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,7 +949,7 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.24.0 +govee-ble==0.26.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d11cb5614f..cdcd6f257d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -766,7 +766,7 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.24.0 +govee-ble==0.26.0 # homeassistant.components.gree greeclimate==1.4.1 From 3fba02a6929dee2c00309dedd7b3739c51be7700 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:06:49 +0100 Subject: [PATCH 0456/1544] Improve debug logs in Minecraft Server (#107672) Improve debug logs --- homeassistant/components/minecraft_server/api.py | 16 +++++++++++++--- .../components/minecraft_server/config_flow.py | 12 ++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index e44a02c9c78..d86f8453413 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -89,7 +89,7 @@ class MinecraftServer: self._server.timeout = DATA_UPDATE_TIMEOUT _LOGGER.debug( - "%s server instance created with address '%s'", + "Initialized %s server instance with address '%s'", self._server_type, self._address, ) @@ -98,7 +98,15 @@ class MinecraftServer: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: + _LOGGER.debug( + "Connection check of %s server failed: %s", + self._server_type, + self._get_error_message(error), + ) return False return True @@ -108,7 +116,9 @@ class MinecraftServer: status_response: BedrockStatusResponse | JavaStatusResponse if self._server is None: - raise MinecraftServerNotInitializedError() + raise MinecraftServerNotInitializedError( + f"Server instance with address '{self._address}' is not initialized" + ) try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 4f4c89fb0e6..022b7ed3991 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -44,17 +44,17 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): try: await api.async_initialize() - except MinecraftServerAddressError: - pass + except MinecraftServerAddressError as error: + _LOGGER.debug( + "Initialization of %s server failed: %s", + server_type, + error, + ) else: if await api.async_is_online(): config_data[CONF_TYPE] = server_type return self.async_create_entry(title=address, data=config_data) - _LOGGER.debug( - "Connection check to %s server '%s' failed", server_type, address - ) - # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" From 402ead8df2bc45545b5c5acb7aa88730cd0e0988 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:11:13 +0100 Subject: [PATCH 0457/1544] Add decorator typing [toon] (#107597) --- homeassistant/components/toon/helpers.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 4fb4daede65..41e6cd1c6bb 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -1,19 +1,30 @@ """Helpers for Toon.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging +from typing import Any, Concatenate, ParamSpec, TypeVar from toonapi import ToonConnectionError, ToonError +from .models import ToonEntity + +_ToonEntityT = TypeVar("_ToonEntityT", bound=ToonEntity) +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) -def toon_exception_handler(func): +def toon_exception_handler( + func: Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]], +) -> Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Toon calls to handle Toon exceptions. A decorator that wraps the passed in function, catches Toon errors, and handles the availability of the device in the data coordinator. """ - async def handler(self, *args, **kwargs): + async def handler(self: _ToonEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() From fbbe03c93c882173f4bb780051e803703f5cf745 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:13:05 +0100 Subject: [PATCH 0458/1544] Add decorator typing [soma] (#107559) --- homeassistant/components/soma/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index aa948703118..bbcc29d7853 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -1,5 +1,9 @@ """Support for Soma Smartshades.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging +from typing import Any, TypeVar from api.soma_api import SomaApi from requests import RequestException @@ -17,6 +21,8 @@ from homeassistant.helpers.typing import ConfigType from .const import API, DOMAIN, HOST, PORT from .utils import is_api_response_success +_SomaEntityT = TypeVar("_SomaEntityT", bound="SomaEntity") + _LOGGER = logging.getLogger(__name__) DEVICES = "devices" @@ -69,10 +75,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def soma_api_call(api_call): +def soma_api_call( + api_call: Callable[[_SomaEntityT], Coroutine[Any, Any, dict]], +) -> Callable[[_SomaEntityT], Coroutine[Any, Any, dict]]: """Soma api call decorator.""" - async def inner(self) -> dict: + async def inner(self: _SomaEntityT) -> dict: response = {} try: response_from_api = await api_call(self) From e91a159efa3641361adbef10f1553d9537855ce5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:14:33 +0100 Subject: [PATCH 0459/1544] Add decorator typing [modern_forms] (#107558) --- homeassistant/components/modern_forms/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index fafd7f9c8d2..78d2fafa078 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -1,8 +1,10 @@ """The Modern Forms integration.""" from __future__ import annotations +from collections.abc import Callable, Coroutine from datetime import timedelta import logging +from typing import Any, Concatenate, ParamSpec, TypeVar from aiomodernforms import ( ModernFormsConnectionError, @@ -24,6 +26,11 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN +_ModernFormsDeviceEntityT = TypeVar( + "_ModernFormsDeviceEntityT", bound="ModernFormsDeviceEntity" +) +_P = ParamSpec("_P") + SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ Platform.BINARY_SENSOR, @@ -64,14 +71,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def modernforms_exception_handler(func): +def modernforms_exception_handler( + func: Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Any], +) -> Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Modern Forms calls to handle Modern Forms exceptions. A decorator that wraps the passed in function, catches Modern Forms errors, and handles the availability of the device in the data coordinator. """ - async def handler(self, *args, **kwargs): + async def handler( + self: _ModernFormsDeviceEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: try: await func(self, *args, **kwargs) self.coordinator.async_update_listeners() From e5eb58b4563f7f783b0dc1b72aa13e7df53a4eae Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:34:25 +0100 Subject: [PATCH 0460/1544] Bump Pyenphase to 1.16.0 (#107719) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 4ae7760a56b..67d07f0d502 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.15.2"], + "requirements": ["pyenphase==1.16.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index d5b97fc558c..dbf6af74eaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1750,7 +1750,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.15.2 +pyenphase==1.16.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdcd6f257d2..bfe06e514a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1334,7 +1334,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.15.2 +pyenphase==1.16.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 956921a930f78178e73b9577f27124da40ecc780 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:55:28 +0100 Subject: [PATCH 0461/1544] Improvements for tedee integration (#107238) * improvements * wait another second before creating the entry * move delay to lib * move library bump to separate PR * move available back to lock from entity --- homeassistant/components/tedee/config_flow.py | 3 + homeassistant/components/tedee/entity.py | 5 -- homeassistant/components/tedee/lock.py | 5 ++ tests/components/tedee/test_config_flow.py | 55 ++----------------- 4 files changed, 14 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 27f455ee20c..075a4c998ea 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -6,6 +6,7 @@ from pytedee_async import ( TedeeAuthException, TedeeClient, TedeeClientException, + TedeeDataUpdateException, TedeeLocalAuthException, ) import voluptuous as vol @@ -46,6 +47,8 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_LOCAL_ACCESS_TOKEN] = "invalid_api_key" except TedeeClientException: errors[CONF_HOST] = "invalid_host" + except TedeeDataUpdateException: + errors["base"] = "cannot_connect" else: if self.reauth_entry: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index ef75affebbc..59e3354aa1a 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -41,11 +41,6 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]): self._lock = self.coordinator.data.get(self._lock.lock_id, self._lock) super()._handle_coordinator_update() - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self._lock.is_connected - class TedeeDescriptionEntity(TedeeEntity): """Base class for Tedee device entities.""" diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index a01d13c3bbb..1025942d787 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -74,6 +74,11 @@ class TedeeLockEntity(TedeeEntity, LockEntity): """Return true if lock is jammed.""" return self._lock.is_state_jammed + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._lock.is_connected + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the door.""" try: diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 4feb9bb8ca5..bc5b73aa4a9 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -1,7 +1,11 @@ """Test the Tedee config flow.""" from unittest.mock import MagicMock -from pytedee_async import TedeeClientException, TedeeLocalAuthException +from pytedee_async import ( + TedeeClientException, + TedeeDataUpdateException, + TedeeLocalAuthException, +) import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN @@ -72,6 +76,7 @@ async def test_flow_already_configured( TedeeLocalAuthException("boom."), {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, ), + (TedeeDataUpdateException("boom."), {"base": "cannot_connect"}), ], ) async def test_config_flow_errors( @@ -130,51 +135,3 @@ async def test_reauth_flow( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" - - -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (TedeeClientException("boom."), {CONF_HOST: "invalid_host"}), - ( - TedeeLocalAuthException("boom."), - {CONF_LOCAL_ACCESS_TOKEN: "invalid_api_key"}, - ), - ], -) -async def test_reauth_flow_errors( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_tedee: MagicMock, - side_effect: Exception, - error: dict[str, str], -) -> None: - """Test that the reauth flow errors.""" - - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "unique_id": mock_config_entry.unique_id, - "entry_id": mock_config_entry.entry_id, - }, - data={ - CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, - CONF_HOST: "192.168.1.42", - }, - ) - - mock_tedee.get_local_bridge.side_effect = side_effect - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == error - assert len(mock_tedee.get_local_bridge.mock_calls) == 1 From 5bdcbc4e8b3d58f7fdd7adea0c4711cc058b2627 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 10 Jan 2024 16:20:47 +0100 Subject: [PATCH 0462/1544] Redact sensitive data in alexa debug logging (#107676) * Redact sensitive data in alexa debug logging * Add wrappers to diagnostics module * Test http api log is redacted --- homeassistant/components/alexa/auth.py | 9 ++--- homeassistant/components/alexa/const.py | 3 ++ homeassistant/components/alexa/diagnostics.py | 34 +++++++++++++++++++ homeassistant/components/alexa/handlers.py | 1 - homeassistant/components/alexa/smart_home.py | 14 ++++++-- .../components/alexa/state_report.py | 11 ++++-- .../components/alexa/test_smart_home_http.py | 15 +++++--- 7 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/alexa/diagnostics.py diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 58095340146..527e51b5390 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -15,6 +15,9 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util +from .const import STORAGE_ACCESS_TOKEN, STORAGE_REFRESH_TOKEN +from .diagnostics import async_redact_lwa_params + _LOGGER = logging.getLogger(__name__) LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" @@ -24,8 +27,6 @@ PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 STORAGE_KEY = "alexa_auth" STORAGE_VERSION = 1 STORAGE_EXPIRE_TIME = "expire_time" -STORAGE_ACCESS_TOKEN = "access_token" -STORAGE_REFRESH_TOKEN = "refresh_token" class Auth: @@ -56,7 +57,7 @@ class Auth: } _LOGGER.debug( "Calling LWA to get the access token (first time), with: %s", - json.dumps(lwa_params), + json.dumps(async_redact_lwa_params(lwa_params)), ) return await self._async_request_new_token(lwa_params) @@ -133,7 +134,7 @@ class Auth: return None response_json = await response.json() - _LOGGER.debug("LWA response body : %s", response_json) + _LOGGER.debug("LWA response body : %s", async_redact_lwa_params(response_json)) access_token: str = response_json["access_token"] refresh_token: str = response_json["refresh_token"] diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index f71bc091106..abdef0cb566 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -90,6 +90,9 @@ API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} # we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode PRESET_MODE_NA = "-" +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/diagnostics.py b/homeassistant/components/alexa/diagnostics.py new file mode 100644 index 00000000000..54233a0f432 --- /dev/null +++ b/homeassistant/components/alexa/diagnostics.py @@ -0,0 +1,34 @@ +"""Diagnostics helpers for Alexa.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import callback + +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + +TO_REDACT_LWA = { + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + STORAGE_ACCESS_TOKEN, + STORAGE_REFRESH_TOKEN, +} + +TO_REDACT_AUTH = {"correlationToken", "token"} + + +@callback +def async_redact_lwa_params(lwa_params: dict[str, str]) -> dict[str, str]: + """Redact lwa_params.""" + return async_redact_data(lwa_params, TO_REDACT_LWA) + + +@callback +def async_redact_auth_data(mapping: Mapping[Any, Any]) -> dict[str, str]: + """React auth data.""" + return async_redact_data(mapping, TO_REDACT_AUTH) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 5613da52db5..68702bc0533 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -144,7 +144,6 @@ async def async_api_accept_grant( Async friendly. """ auth_code: str = directive.payload["grant"]["code"] - _LOGGER.debug("AcceptGrant code: %s", auth_code) if config.supports_auth: await config.async_accept_grant(auth_code) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a8101896116..88f66e93fc1 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -25,6 +25,7 @@ from .const import ( CONF_LOCALE, EVENT_ALEXA_SMART_HOME, ) +from .diagnostics import async_redact_auth_data from .errors import AlexaBridgeUnreachableError, AlexaError from .handlers import HANDLERS from .state_report import AlexaDirective @@ -149,12 +150,21 @@ class SmartHomeView(HomeAssistantView): user: User = request["hass_user"] message: dict[str, Any] = await request.json() - _LOGGER.debug("Received Alexa Smart Home request: %s", message) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Received Alexa Smart Home request: %s", + async_redact_auth_data(message), + ) response = await async_handle_message( hass, self.smart_home_config, message, context=core.Context(user_id=user.id) ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Sending Alexa Smart Home response: %s", + async_redact_auth_data(response), + ) + return b"" if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index f1cf13a0a7e..20e66dfa084 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -34,6 +34,7 @@ from .const import ( DOMAIN, Cause, ) +from .diagnostics import async_redact_auth_data from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink @@ -43,6 +44,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 +TO_REDACT = {"correlationToken", "token"} + class AlexaDirective: """An incoming Alexa directive.""" @@ -379,7 +382,9 @@ async def async_send_changereport_message( response_text = await response.text() if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug( + "Sent: %s", json.dumps(async_redact_auth_data(message_serialized)) + ) _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: @@ -533,7 +538,9 @@ async def async_send_doorbell_event_message( response_text = await response.text() if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug( + "Sent: %s", json.dumps(async_redact_auth_data(message_serialized)) + ) _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py index b0f78e958d7..1426eac5c5d 100644 --- a/tests/components/alexa/test_smart_home_http.py +++ b/tests/components/alexa/test_smart_home_http.py @@ -1,6 +1,7 @@ """Test Smart Home HTTP endpoints.""" from http import HTTPStatus import json +import logging from typing import Any import pytest @@ -44,11 +45,16 @@ async def do_http_discovery(config, hass, hass_client): ], ) async def test_http_api( - hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any] + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + hass_client: ClientSessionGenerator, + config: dict[str, Any], ) -> None: - """With `smart_home:` HTTP API is exposed.""" - response = await do_http_discovery(config, hass, hass_client) - response_data = await response.json() + """With `smart_home:` HTTP API is exposed and debug log is redacted.""" + with caplog.at_level(logging.DEBUG): + response = await do_http_discovery(config, hass, hass_client) + response_data = await response.json() + assert "'correlationToken': '**REDACTED**'" in caplog.text # Here we're testing just the HTTP view glue -- details of discovery are # covered in other tests. @@ -61,5 +67,4 @@ async def test_http_api_disabled( """Without `smart_home:`, the HTTP API is disabled.""" config = {"alexa": {}} response = await do_http_discovery(config, hass, hass_client) - assert response.status == HTTPStatus.NOT_FOUND From de9bb201358c0d64a3c9e88de515e9b55537a501 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 10 Jan 2024 16:23:42 +0100 Subject: [PATCH 0463/1544] Fix invalid alexa climate or water_heater state report with double listed targetSetpoint (#107673) --- homeassistant/components/alexa/capabilities.py | 16 ++++++++++------ tests/components/alexa/test_common.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 502912ee8de..ab3bd8591fd 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1112,13 +1112,17 @@ class AlexaThermostatController(AlexaCapability): """Return what properties this entity supports.""" properties = [{"name": "thermostatMode"}] supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: + if self.entity.domain == climate.DOMAIN: + if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + properties.append({"name": "lowerSetpoint"}) + properties.append({"name": "upperSetpoint"}) + if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) + elif ( + self.entity.domain == water_heater.DOMAIN + and supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE + ): properties.append({"name": "targetSetpoint"}) - if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: - properties.append({"name": "targetSetpoint"}) - if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: - properties.append({"name": "lowerSetpoint"}) - properties.append({"name": "upperSetpoint"}) return properties def properties_proactively_reported(self) -> bool: diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index d3ea1bcda3e..8c9cea526b6 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -224,9 +224,20 @@ class ReportedProperties: def assert_equal(self, namespace, name, value): """Assert a property is equal to a given value.""" + prop_set = None + prop_count = 0 for prop in self.properties: if prop["namespace"] == namespace and prop["name"] == name: assert prop["value"] == value - return prop + prop_set = prop + prop_count += 1 + + if prop_count > 1: + pytest.fail( + f"property {namespace}:{name} more than once in {self.properties!r}" + ) + + if prop_set: + return prop_set pytest.fail(f"property {namespace}:{name} not in {self.properties!r}") From 6a6c447c28afd6a4b80e945e246949b744c07245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 10 Jan 2024 16:36:20 +0100 Subject: [PATCH 0464/1544] Use new AEMET library data for sensor platform (#102972) Co-authored-by: Franck Nijhof Co-authored-by: Robert Resch --- homeassistant/components/aemet/const.py | 45 -- homeassistant/components/aemet/sensor.py | 346 ++++++++----- .../aemet/weather_update_coordinator.py | 485 +----------------- tests/components/aemet/test_sensor.py | 8 +- 4 files changed, 218 insertions(+), 666 deletions(-) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c3328fc1b5d..6b11e6aa70f 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -59,8 +59,6 @@ ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_CONDITION = "condition" -ATTR_API_FORECAST_DAILY = "forecast-daily" -ATTR_API_FORECAST_HOURLY = "forecast-hourly" ATTR_API_FORECAST_PRECIPITATION = "precipitation" ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" ATTR_API_FORECAST_TEMP = "temperature" @@ -101,49 +99,6 @@ CONDITIONS_MAP = { AOD_COND_SUNNY: ATTR_CONDITION_SUNNY, } -FORECAST_MONITORED_CONDITIONS = [ - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, -] -MONITORED_CONDITIONS = [ - ATTR_API_CONDITION, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_RAIN, - ATTR_API_RAIN_PROB, - ATTR_API_SNOW, - ATTR_API_SNOW_PROB, - ATTR_API_STATION_ID, - ATTR_API_STATION_NAME, - ATTR_API_STATION_TIMESTAMP, - ATTR_API_STORM_PROB, - ATTR_API_TEMPERATURE, - ATTR_API_TEMPERATURE_FEELING, - ATTR_API_TOWN_ID, - ATTR_API_TOWN_NAME, - ATTR_API_TOWN_TIMESTAMP, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, -] - -FORECAST_MODE_DAILY = "daily" -FORECAST_MODE_HOURLY = "hourly" -FORECAST_MODES = [ - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, -] -FORECAST_MODE_ATTR_API = { - FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, - FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, -} - FORECAST_MAP = { AOD_FORECAST_DAILY: { AOD_CONDITION: ATTR_FORECAST_CONDITION, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 76e691a4682..66b9c6351a6 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,6 +1,41 @@ """Support for the AEMET OpenData service.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FEEL_TEMP, + AOD_FORECAST_CURRENT, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_HUMIDITY, + AOD_ID, + AOD_NAME, + AOD_PRECIPITATION, + AOD_PRECIPITATION_PROBABILITY, + AOD_PRESSURE, + AOD_RAIN, + AOD_RAIN_PROBABILITY, + AOD_SNOW, + AOD_SNOW_PROBABILITY, + AOD_STATION, + AOD_STORM_PROBABILITY, + AOD_TEMP, + AOD_TEMP_MAX, + AOD_TEMP_MIN, + AOD_TIMESTAMP, + AOD_TOWN, + AOD_WEATHER, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, +) +from aemet_opendata.helpers import dict_nested_value + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -18,7 +53,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( @@ -51,172 +85,270 @@ from .const import ( ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_SPEED, ATTRIBUTION, + CONDITIONS_MAP, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODE_ATTR_API, - FORECAST_MODE_DAILY, - FORECAST_MODES, - FORECAST_MONITORED_CONDITIONS, - MONITORED_CONDITIONS, ) +from .entity import AemetEntity from .weather_update_coordinator import WeatherUpdateCoordinator -FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=ATTR_API_FORECAST_CONDITION, - name="Condition", + +@dataclass(frozen=True, kw_only=True) +class AemetSensorEntityDescription(SensorEntityDescription): + """A class that describes AEMET OpenData sensor entities.""" + + keys: list[str] | None = None + value_fn: Callable[[str], datetime | float | int | str | None] = lambda value: value + + +FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_CONDITION}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_CONDITION], + name="Daily forecast condition", + value_fn=CONDITIONS_MAP.get, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION, - name="Precipitation", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_CONDITION}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_CONDITION], + name="Hourly forecast condition", + value_fn=CONDITIONS_MAP.get, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_PRECIPITATION], + name="Hourly forecast precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - name="Precipitation probability", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}", + keys=[ + AOD_TOWN, + AOD_FORECAST_DAILY, + AOD_FORECAST_CURRENT, + AOD_PRECIPITATION_PROBABILITY, + ], + name="Daily forecast precipitation probability", native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP, - name="Temperature", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_PRECIPITATION_PROBABILITY}", + keys=[ + AOD_TOWN, + AOD_FORECAST_HOURLY, + AOD_FORECAST_CURRENT, + AOD_PRECIPITATION_PROBABILITY, + ], + name="Hourly forecast precipitation probability", + native_unit_of_measurement=PERCENTAGE, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TEMP}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MAX], + name="Daily forecast temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP_LOW, - name="Temperature Low", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TEMP_LOW}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TEMP_MIN], + name="Daily forecast temperature low", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TIME, - name="Time", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_TEMP}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TEMP], + name="Hourly forecast temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_TIME}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + name="Daily forecast time", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_BEARING, - name="Wind bearing", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_TIME}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + name="Hourly forecast time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ), + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_WIND_BEARING}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], + name="Daily forecast wind bearing", native_unit_of_measurement=DEGREE, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_MAX_SPEED, - name="Wind max speed", + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_BEARING}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], + name="Hourly forecast wind bearing", + native_unit_of_measurement=DEGREE, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_MAX_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED_MAX], + name="Hourly forecast wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), - SensorEntityDescription( - key=ATTR_API_FORECAST_WIND_SPEED, - name="Wind speed", + AemetSensorEntityDescription( + key=f"forecast-daily-{ATTR_API_FORECAST_WIND_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED], + name="Daily forecast wind speed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + ), + AemetSensorEntityDescription( + entity_registry_enabled_default=False, + key=f"forecast-hourly-{ATTR_API_FORECAST_WIND_SPEED}", + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_SPEED], + name="Hourly forecast wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), ) -WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + + +WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( + AemetSensorEntityDescription( key=ATTR_API_CONDITION, + keys=[AOD_WEATHER, AOD_CONDITION], name="Condition", + value_fn=CONDITIONS_MAP.get, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_HUMIDITY, + keys=[AOD_WEATHER, AOD_HUMIDITY], name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_PRESSURE, + keys=[AOD_WEATHER, AOD_PRESSURE], name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_RAIN, + keys=[AOD_WEATHER, AOD_RAIN], name="Rain", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_RAIN_PROB, + keys=[AOD_WEATHER, AOD_RAIN_PROBABILITY], name="Rain probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_SNOW, + keys=[AOD_WEATHER, AOD_SNOW], name="Snow", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_SNOW_PROB, + keys=[AOD_WEATHER, AOD_SNOW_PROBABILITY], name="Snow probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_ID, + keys=[AOD_STATION, AOD_ID], name="Station ID", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_NAME, + keys=[AOD_STATION, AOD_NAME], name="Station name", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STATION_TIMESTAMP, + keys=[AOD_STATION, AOD_TIMESTAMP], name="Station timestamp", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_STORM_PROB, + keys=[AOD_WEATHER, AOD_STORM_PROBABILITY], name="Storm probability", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TEMPERATURE, + keys=[AOD_WEATHER, AOD_TEMP], name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TEMPERATURE_FEELING, + keys=[AOD_WEATHER, AOD_FEEL_TEMP], name="Temperature feeling", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_ID, + keys=[AOD_TOWN, AOD_ID], name="Town ID", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_NAME, + keys=[AOD_TOWN, AOD_NAME], name="Town name", ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_TOWN_TIMESTAMP, + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP], name="Town timestamp", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_BEARING, + keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_MAX_SPEED, + keys=[AOD_WEATHER, AOD_WIND_SPEED_MAX], name="Wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + AemetSensorEntityDescription( key=ATTR_API_WIND_SPEED, + keys=[AOD_WEATHER, AOD_WIND_SPEED], name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, @@ -232,108 +364,46 @@ async def async_setup_entry( ) -> None: """Set up AEMET OpenData sensor entities based on a config entry.""" domain_data = hass.data[DOMAIN][config_entry.entry_id] - name = domain_data[ENTRY_NAME] - weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + name: str = domain_data[ENTRY_NAME] + coordinator: WeatherUpdateCoordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - unique_id = config_entry.unique_id - entities: list[AbstractAemetSensor] = [ - AemetSensor(name, unique_id, weather_coordinator, description) - for description in WEATHER_SENSOR_TYPES - if description.key in MONITORED_CONDITIONS - ] - entities.extend( - [ - AemetForecastSensor( - f"{domain_data[ENTRY_NAME]} {mode} Forecast", - f"{unique_id}-forecast-{mode}", - weather_coordinator, - mode, - description, + entities: list[AemetSensor] = [] + + for description in FORECAST_SENSORS + WEATHER_SENSORS: + if dict_nested_value(coordinator.data["lib"], description.keys) is not None: + entities.append( + AemetSensor( + name, + coordinator, + description, + config_entry, + ) ) - for mode in FORECAST_MODES - for description in FORECAST_SENSOR_TYPES - if description.key in FORECAST_MONITORED_CONDITIONS - ] - ) async_add_entities(entities) -class AbstractAemetSensor(CoordinatorEntity[WeatherUpdateCoordinator], SensorEntity): - """Abstract class for an AEMET OpenData sensor.""" +class AemetSensor(AemetEntity, SensorEntity): + """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION + entity_description: AemetSensorEntityDescription def __init__( self, - name, - unique_id, + name: str, coordinator: WeatherUpdateCoordinator, - description: SensorEntityDescription, + description: AemetSensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description self._attr_name = f"{name} {description.name}" - self._attr_unique_id = unique_id - - -class AemetSensor(AbstractAemetSensor): - """Implementation of an AEMET OpenData sensor.""" - - def __init__( - self, - name, - unique_id_prefix, - weather_coordinator: WeatherUpdateCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__( - name=name, - unique_id=f"{unique_id_prefix}-{description.key}", - coordinator=weather_coordinator, - description=description, - ) + self._attr_unique_id = f"{config_entry.unique_id}-{description.key}" @property def native_value(self): """Return the state of the device.""" - return self.coordinator.data.get(self.entity_description.key) - - -class AemetForecastSensor(AbstractAemetSensor): - """Implementation of an AEMET OpenData forecast sensor.""" - - def __init__( - self, - name, - unique_id_prefix, - weather_coordinator: WeatherUpdateCoordinator, - forecast_mode, - description: SensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__( - name=name, - unique_id=f"{unique_id_prefix}-{description.key}", - coordinator=weather_coordinator, - description=description, - ) - self._forecast_mode = forecast_mode - self._attr_entity_registry_enabled_default = ( - self._forecast_mode == FORECAST_MODE_DAILY - ) - - @property - def native_value(self): - """Return the state of the device.""" - forecast = None - forecasts = self.coordinator.data.get( - FORECAST_MODE_ATTR_API[self._forecast_mode] - ) - if forecasts: - forecast = forecasts[0].get(self.entity_description.key) - if self.entity_description.key == ATTR_API_FORECAST_TIME: - forecast = dt_util.parse_datetime(forecast) - return forecast + value = self.get_aemet_value(self.entity_description.keys) + return self.entity_description.value_fn(value) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index cd95a8e0854..04810077f28 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -7,117 +7,28 @@ import logging from typing import Any, Final, cast from aemet_opendata.const import ( - AEMET_ATTR_DATE, - AEMET_ATTR_DAY, - AEMET_ATTR_DIRECTION, - AEMET_ATTR_ELABORATED, - AEMET_ATTR_FEEL_TEMPERATURE, - AEMET_ATTR_FORECAST, - AEMET_ATTR_HUMIDITY, - AEMET_ATTR_MAX, - AEMET_ATTR_MIN, - AEMET_ATTR_PRECIPITATION, - AEMET_ATTR_PRECIPITATION_PROBABILITY, - AEMET_ATTR_SKY_STATE, - AEMET_ATTR_SNOW, - AEMET_ATTR_SNOW_PROBABILITY, - AEMET_ATTR_SPEED, - AEMET_ATTR_STATION_DATE, - AEMET_ATTR_STATION_HUMIDITY, - AEMET_ATTR_STATION_PRESSURE, - AEMET_ATTR_STATION_PRESSURE_SEA, - AEMET_ATTR_STATION_TEMPERATURE, - AEMET_ATTR_STORM_PROBABILITY, - AEMET_ATTR_TEMPERATURE, - AEMET_ATTR_WIND, - AEMET_ATTR_WIND_GUST, AOD_CONDITION, AOD_FORECAST, AOD_FORECAST_DAILY, AOD_FORECAST_HOURLY, AOD_TOWN, - ATTR_DATA, ) from aemet_opendata.exceptions import AemetError -from aemet_opendata.forecast import ForecastValue -from aemet_opendata.helpers import ( - dict_nested_value, - get_forecast_day_value, - get_forecast_hour_value, - get_forecast_interval_value, -) +from aemet_opendata.helpers import dict_nested_value from aemet_opendata.interface import AEMET from homeassistant.components.weather import Forecast from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util -from .const import ( - ATTR_API_CONDITION, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_DAILY, - ATTR_API_FORECAST_HOURLY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_RAIN, - ATTR_API_RAIN_PROB, - ATTR_API_SNOW, - ATTR_API_SNOW_PROB, - ATTR_API_STATION_ID, - ATTR_API_STATION_NAME, - ATTR_API_STATION_TIMESTAMP, - ATTR_API_STORM_PROB, - ATTR_API_TEMPERATURE, - ATTR_API_TEMPERATURE_FEELING, - ATTR_API_TOWN_ID, - ATTR_API_TOWN_NAME, - ATTR_API_TOWN_TIMESTAMP, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, - CONDITIONS_MAP, - DOMAIN, - FORECAST_MAP, -) +from .const import CONDITIONS_MAP, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) API_TIMEOUT: Final[int] = 120 -STATION_MAX_DELTA = timedelta(hours=2) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) -def format_condition(condition: str) -> str: - """Return condition from dict CONDITIONS_MAP.""" - val = ForecastValue.parse_condition(condition) - return CONDITIONS_MAP.get(val, val) - - -def format_float(value) -> float | None: - """Try converting string to float.""" - try: - return float(value) - except (TypeError, ValueError): - return None - - -def format_int(value) -> int | None: - """Try converting string to int.""" - try: - return int(value) - except (TypeError, ValueError): - return None - - class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -143,139 +54,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): await self.aemet.update() except AemetError as error: raise UpdateFailed(error) from error - weather_response = self.aemet.legacy_weather() - return self._convert_weather_response(weather_response) - - def _convert_weather_response(self, weather_response): - """Format the weather response correctly.""" - if not weather_response or not weather_response.hourly: - return None - - elaborated = dt_util.parse_datetime( - weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_ELABORATED] + "Z" - ) - now = dt_util.now() - now_utc = dt_util.utcnow() - hour = now.hour - - # Get current day - day = None - for cur_day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - cur_day_date = dt_util.parse_datetime(cur_day[AEMET_ATTR_DATE]) - if now.date() == cur_day_date.date(): - day = cur_day - break - - # Get latest station data - station_data = None - station_dt = None - if weather_response.station: - for _station_data in weather_response.station[ATTR_DATA]: - if AEMET_ATTR_STATION_DATE in _station_data: - _station_dt = dt_util.parse_datetime( - _station_data[AEMET_ATTR_STATION_DATE] + "Z" - ) - if not station_dt or _station_dt > station_dt: - station_data = _station_data - station_dt = _station_dt - - condition = None - humidity = None - pressure = None - rain = None - rain_prob = None - snow = None - snow_prob = None - station_id = None - station_name = None - station_timestamp = None - storm_prob = None - temperature = None - temperature_feeling = None - town_id = None - town_name = None - town_timestamp = dt_util.as_utc(elaborated) - wind_bearing = None - wind_max_speed = None - wind_speed = None - - # Get weather values - if day: - condition = self._get_condition(day, hour) - humidity = self._get_humidity(day, hour) - rain = self._get_rain(day, hour) - rain_prob = self._get_rain_prob(day, hour) - snow = self._get_snow(day, hour) - snow_prob = self._get_snow_prob(day, hour) - station_id = self._get_station_id() - station_name = self._get_station_name() - storm_prob = self._get_storm_prob(day, hour) - temperature = self._get_temperature(day, hour) - temperature_feeling = self._get_temperature_feeling(day, hour) - town_id = self._get_town_id() - town_name = self._get_town_name() - wind_bearing = self._get_wind_bearing(day, hour) - wind_max_speed = self._get_wind_max_speed(day, hour) - wind_speed = self._get_wind_speed(day, hour) - - # Overwrite weather values with closest station data (if present) - if station_data: - station_timestamp = dt_util.as_utc(station_dt) - if (now_utc - station_dt) <= STATION_MAX_DELTA: - if AEMET_ATTR_STATION_HUMIDITY in station_data: - humidity = format_float(station_data[AEMET_ATTR_STATION_HUMIDITY]) - if AEMET_ATTR_STATION_PRESSURE_SEA in station_data: - pressure = format_float( - station_data[AEMET_ATTR_STATION_PRESSURE_SEA] - ) - elif AEMET_ATTR_STATION_PRESSURE in station_data: - pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE]) - if AEMET_ATTR_STATION_TEMPERATURE in station_data: - temperature = format_float( - station_data[AEMET_ATTR_STATION_TEMPERATURE] - ) - else: - _LOGGER.warning("Station data is outdated") - - # Get forecast from weather data - forecast_daily = self._get_daily_forecast_from_weather_response( - weather_response, now - ) - forecast_hourly = self._get_hourly_forecast_from_weather_response( - weather_response, now - ) data = self.aemet.data() - forecasts: list[dict[str, Forecast]] = { - AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), - AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), - } return { - ATTR_API_CONDITION: condition, - ATTR_API_FORECAST_DAILY: forecast_daily, - ATTR_API_FORECAST_HOURLY: forecast_hourly, - ATTR_API_HUMIDITY: humidity, - ATTR_API_TEMPERATURE: temperature, - ATTR_API_TEMPERATURE_FEELING: temperature_feeling, - ATTR_API_PRESSURE: pressure, - ATTR_API_RAIN: rain, - ATTR_API_RAIN_PROB: rain_prob, - ATTR_API_SNOW: snow, - ATTR_API_SNOW_PROB: snow_prob, - ATTR_API_STATION_ID: station_id, - ATTR_API_STATION_NAME: station_name, - ATTR_API_STATION_TIMESTAMP: station_timestamp, - ATTR_API_STORM_PROB: storm_prob, - ATTR_API_TOWN_ID: town_id, - ATTR_API_TOWN_NAME: town_name, - ATTR_API_TOWN_TIMESTAMP: town_timestamp, - ATTR_API_WIND_BEARING: wind_bearing, - ATTR_API_WIND_MAX_SPEED: wind_max_speed, - ATTR_API_WIND_SPEED: wind_speed, - "forecast": forecasts, + "forecast": { + AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), + AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), + }, "lib": data, } @@ -297,262 +83,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): cur_forecast[ha_key] = value forecast_list += [cur_forecast] return cast(list[Forecast], forecast_list) - - def _get_daily_forecast_from_weather_response(self, weather_response, now): - if weather_response.daily: - parse = False - forecast = [] - for day in weather_response.daily[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) - if now.date() == day_date.date(): - parse = True - if parse: - cur_forecast = self._convert_forecast_day(day_date, day) - if cur_forecast: - forecast.append(cur_forecast) - return forecast - return None - - def _get_hourly_forecast_from_weather_response(self, weather_response, now): - if weather_response.hourly: - parse = False - hour = now.hour - forecast = [] - for day in weather_response.hourly[ATTR_DATA][0][AEMET_ATTR_FORECAST][ - AEMET_ATTR_DAY - ]: - day_date = dt_util.parse_datetime(day[AEMET_ATTR_DATE]) - hour_start = 0 - if now.date() == day_date.date(): - parse = True - hour_start = now.hour - if parse: - for hour in range(hour_start, 24): - cur_forecast = self._convert_forecast_hour(day_date, day, hour) - if cur_forecast: - forecast.append(cur_forecast) - return forecast - return None - - def _convert_forecast_day(self, date, day): - if not (condition := self._get_condition_day(day)): - return None - - return { - ATTR_API_FORECAST_CONDITION: condition, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( - day - ), - ATTR_API_FORECAST_TEMP: self._get_temperature_day(day), - ATTR_API_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), - ATTR_API_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), - ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), - } - - def _convert_forecast_hour(self, date, day, hour): - if not (condition := self._get_condition(day, hour)): - return None - - forecast_dt = date.replace(hour=hour, minute=0, second=0) - - return { - ATTR_API_FORECAST_CONDITION: condition, - ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( - day, hour - ), - ATTR_API_FORECAST_TEMP: self._get_temperature(day, hour), - ATTR_API_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_API_FORECAST_WIND_MAX_SPEED: self._get_wind_max_speed(day, hour), - ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), - ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), - } - - def _calc_precipitation(self, day, hour): - """Calculate the precipitation.""" - rain_value = self._get_rain(day, hour) or 0 - snow_value = self._get_snow(day, hour) or 0 - - if round(rain_value + snow_value, 1) == 0: - return None - return round(rain_value + snow_value, 1) - - def _calc_precipitation_prob(self, day, hour): - """Calculate the precipitation probability (hour).""" - rain_value = self._get_rain_prob(day, hour) or 0 - snow_value = self._get_snow_prob(day, hour) or 0 - - if rain_value == 0 and snow_value == 0: - return None - return max(rain_value, snow_value) - - @staticmethod - def _get_condition(day_data, hour): - """Get weather condition (hour) from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_SKY_STATE], hour) - if val: - return format_condition(val) - return None - - @staticmethod - def _get_condition_day(day_data): - """Get weather condition (day) from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_SKY_STATE]) - if val: - return format_condition(val) - return None - - @staticmethod - def _get_humidity(day_data, hour): - """Get humidity from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_HUMIDITY], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_precipitation_prob_day(day_data): - """Get humidity from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY]) - if val: - return format_int(val) - return None - - @staticmethod - def _get_rain(day_data, hour): - """Get rain from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_PRECIPITATION], hour) - if val: - return format_float(val) - return None - - @staticmethod - def _get_rain_prob(day_data, hour): - """Get rain probability from weather data.""" - val = get_forecast_interval_value( - day_data[AEMET_ATTR_PRECIPITATION_PROBABILITY], hour - ) - if val: - return format_int(val) - return None - - @staticmethod - def _get_snow(day_data, hour): - """Get snow from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_SNOW], hour) - if val: - return format_float(val) - return None - - @staticmethod - def _get_snow_prob(day_data, hour): - """Get snow probability from weather data.""" - val = get_forecast_interval_value(day_data[AEMET_ATTR_SNOW_PROBABILITY], hour) - if val: - return format_int(val) - return None - - def _get_station_id(self): - """Get station ID from weather data.""" - if self.aemet.station: - return self.aemet.station.get_id() - return None - - def _get_station_name(self): - """Get station name from weather data.""" - if self.aemet.station: - return self.aemet.station.get_name() - return None - - @staticmethod - def _get_storm_prob(day_data, hour): - """Get storm probability from weather data.""" - val = get_forecast_interval_value(day_data[AEMET_ATTR_STORM_PROBABILITY], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_temperature(day_data, hour): - """Get temperature (hour) from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE], hour) - return format_int(val) - - @staticmethod - def _get_temperature_day(day_data): - """Get temperature (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MAX - ) - return format_int(val) - - @staticmethod - def _get_temperature_low_day(day_data): - """Get temperature (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_TEMPERATURE], key=AEMET_ATTR_MIN - ) - return format_int(val) - - @staticmethod - def _get_temperature_feeling(day_data, hour): - """Get temperature from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_FEEL_TEMPERATURE], hour) - return format_int(val) - - def _get_town_id(self): - """Get town ID from weather data.""" - if self.aemet.town: - return self.aemet.town.get_id() - return None - - def _get_town_name(self): - """Get town name from weather data.""" - if self.aemet.town: - return self.aemet.town.get_name() - return None - - @staticmethod - def _get_wind_bearing(day_data, hour): - """Get wind bearing (hour) from weather data.""" - val = get_forecast_hour_value( - day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION - )[0] - return ForecastValue.parse_wind_direction(val) - - @staticmethod - def _get_wind_bearing_day(day_data): - """Get wind bearing (day) from weather data.""" - val = get_forecast_day_value( - day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION - ) - return ForecastValue.parse_wind_direction(val) - - @staticmethod - def _get_wind_max_speed(day_data, hour): - """Get wind max speed from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_WIND_GUST], hour) - if val: - return format_int(val) - return None - - @staticmethod - def _get_wind_speed(day_data, hour): - """Get wind speed (hour) from weather data.""" - val = get_forecast_hour_value( - day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_SPEED - )[0] - if val: - return format_int(val) - return None - - @staticmethod - def _get_wind_speed_day(day_data): - """Get wind speed (day) from weather data.""" - val = get_forecast_day_value(day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_SPEED) - if val: - return format_int(val) - return None diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 7b6f02f8b06..46b08f929c9 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -93,7 +93,7 @@ async def test_aemet_weather_create_sensors( assert state.state == "1004.4" state = hass.states.get("sensor.aemet_rain") - assert state.state == "1.8" + assert state.state == "7.0" state = hass.states.get("sensor.aemet_rain_probability") assert state.state == "100" @@ -132,10 +132,10 @@ async def test_aemet_weather_create_sensors( assert state.state == "2021-01-09T11:47:45+00:00" state = hass.states.get("sensor.aemet_wind_bearing") - assert state.state == "90.0" + assert state.state == "122.0" state = hass.states.get("sensor.aemet_wind_max_speed") - assert state.state == "24" + assert state.state == "12.2" state = hass.states.get("sensor.aemet_wind_speed") - assert state.state == "15" + assert state.state == "3.2" From 7d18ad6fe74e2d4d6e2eeb254cdeee1861c87278 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Jan 2024 07:14:18 -1000 Subject: [PATCH 0465/1544] Reduce discovery flow matching overhead (#107709) --- homeassistant/data_entry_flow.py | 16 +++++++++------- tests/test_data_entry_flow.py | 8 ++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 63ba565582a..c017744689c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -201,11 +201,13 @@ class FlowManager(abc.ABC): If match_context is passed, only return flows with a context that is a superset of match_context. """ - return any( - flow - for flow in self._async_progress_by_handler(handler, match_context) - if flow.init_data == data - ) + if not (flows := self._handler_progress_index.get(handler)): + return False + match_items = match_context.items() + for progress in flows: + if match_items <= progress.context.items() and progress.init_data == data: + return True + return False @callback def async_get(self, flow_id: str) -> FlowResult: @@ -265,11 +267,11 @@ class FlowManager(abc.ABC): is a superset of match_context. """ if not match_context: - return list(self._handler_progress_index.get(handler, [])) + return list(self._handler_progress_index.get(handler, ())) match_context_items = match_context.items() return [ progress - for progress in self._handler_progress_index.get(handler, set()) + for progress in self._handler_progress_index.get(handler, ()) if match_context_items <= progress.context.items() ] diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 602b21c15bc..155d78e2c64 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -546,6 +546,14 @@ async def test_async_has_matching_flow( ) -> None: """Test we can check for matching flows.""" manager.hass = hass + assert ( + manager.async_has_matching_flow( + "test", + {"source": config_entries.SOURCE_HOMEKIT}, + {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + ) + is False + ) @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): From 9036d675885856816711e258b0f33354b3822c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 10 Jan 2024 20:33:44 +0100 Subject: [PATCH 0466/1544] Rename AEMET weather_update_coordinator (#107740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * aemet: rename weather_update_coordinator Use "coordinator" instead, like other integrations. Signed-off-by: Álvaro Fernández Rojas * coverage: remove AEMET coordinator Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .coveragerc | 1 - homeassistant/components/aemet/__init__.py | 2 +- .../aemet/{weather_update_coordinator.py => coordinator.py} | 0 homeassistant/components/aemet/entity.py | 2 +- homeassistant/components/aemet/sensor.py | 2 +- homeassistant/components/aemet/weather.py | 2 +- tests/components/aemet/test_coordinator.py | 4 +--- tests/components/aemet/test_weather.py | 4 +--- 8 files changed, 6 insertions(+), 11 deletions(-) rename homeassistant/components/aemet/{weather_update_coordinator.py => coordinator.py} (100%) diff --git a/.coveragerc b/.coveragerc index 664dbb666f3..b8c7f949c01 100644 --- a/.coveragerc +++ b/.coveragerc @@ -28,7 +28,6 @@ omit = homeassistant/components/adguard/sensor.py homeassistant/components/adguard/switch.py homeassistant/components/ads/* - homeassistant/components/aemet/weather_update_coordinator.py homeassistant/components/aftership/__init__.py homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 843693d2dc3..5c288b206d0 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -18,7 +18,7 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, PLATFORMS, ) -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/coordinator.py similarity index 100% rename from homeassistant/components/aemet/weather_update_coordinator.py rename to homeassistant/components/aemet/coordinator.py diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py index 527ff046104..b83c0c98807 100644 --- a/homeassistant/components/aemet/entity.py +++ b/homeassistant/components/aemet/entity.py @@ -8,7 +8,7 @@ from aemet_opendata.helpers import dict_nested_value from homeassistant.components.weather import Forecast from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 66b9c6351a6..f51bdcf765a 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -90,8 +90,8 @@ from .const import ( ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, ) +from .coordinator import WeatherUpdateCoordinator from .entity import AemetEntity -from .weather_update_coordinator import WeatherUpdateCoordinator @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index b7b3c31ab5b..d49b62c9509 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -38,8 +38,8 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, WEATHER_FORECAST_MODES, ) +from .coordinator import WeatherUpdateCoordinator from .entity import AemetEntity -from .weather_update_coordinator import WeatherUpdateCoordinator async def async_setup_entry( diff --git a/tests/components/aemet/test_coordinator.py b/tests/components/aemet/test_coordinator.py index 067fc30a2c0..a91256a9518 100644 --- a/tests/components/aemet/test_coordinator.py +++ b/tests/components/aemet/test_coordinator.py @@ -4,9 +4,7 @@ from unittest.mock import patch from aemet_opendata.exceptions import AemetError from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.aemet.weather_update_coordinator import ( - WEATHER_UPDATE_INTERVAL, -) +from homeassistant.components.aemet.coordinator import WEATHER_UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 695087bb738..1f323413174 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -7,9 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.aemet.const import ATTRIBUTION, DOMAIN -from homeassistant.components.aemet.weather_update_coordinator import ( - WEATHER_UPDATE_INTERVAL, -) +from homeassistant.components.aemet.coordinator import WEATHER_UPDATE_INTERVAL from homeassistant.components.weather import ( ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, From bdba6f41c9d70b7c9c2c5b65ea9b8dc23721f629 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Wed, 10 Jan 2024 21:41:16 +0200 Subject: [PATCH 0467/1544] Bump aioswitcher to 3.4.1 (#107730) * switcher: added support for device_key logic included in aioswitcher==3.4.1 * switcher: small fix * switcher: after lint * switcher: fix missing device_key in tests * remove device_key function * fix missing device_key in tests --- homeassistant/components/switcher_kis/__init__.py | 3 ++- homeassistant/components/switcher_kis/button.py | 4 +++- homeassistant/components/switcher_kis/climate.py | 4 +++- homeassistant/components/switcher_kis/cover.py | 4 +++- homeassistant/components/switcher_kis/diagnostics.py | 2 +- homeassistant/components/switcher_kis/manifest.json | 2 +- homeassistant/components/switcher_kis/switch.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/switcher_kis/consts.py | 8 ++++++++ tests/components/switcher_kis/test_diagnostics.py | 1 + 11 files changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index fb6ded99346..051c5d2b72a 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -89,8 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # New device - create device _LOGGER.info( - "Discovered Switcher device - id: %s, name: %s, type: %s (%s)", + "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s)", device.device_id, + device.device_key, device.name, device.device_type.value, device.device_type.hex_rep, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 5a1b7c821d2..2085398232f 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -142,7 +142,9 @@ class SwitcherThermostatButtonEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await self.entity_description.press_fn(swapi, self._remote) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 809e3d6a3ad..272d3ccf6ef 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -162,7 +162,9 @@ class SwitcherClimateEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await swapi.control_breeze_device(self._remote, **kwargs) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index c627f361d7d..1e34ddd2325 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -98,7 +98,9 @@ class SwitcherCoverEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py index 93b3c36bd21..765a3dde9e7 100644 --- a/homeassistant/components/switcher_kis/diagnostics.py +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import DATA_DEVICE, DOMAIN -TO_REDACT = {"device_id", "ip_address", "mac_address"} +TO_REDACT = {"device_id", "device_key", "ip_address", "mac_address"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 9accda95912..055c92cc2fa 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.3.0"] + "requirements": ["aioswitcher==3.4.1"] } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ef8564b3770..f37e16aa513 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -111,7 +111,9 @@ class SwitcherBaseSwitchEntity( try: async with SwitcherType1Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/requirements_all.txt b/requirements_all.txt index dbf6af74eaa..45a18853fa2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioslimproto==2.3.3 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.3.0 +aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfe06e514a6..7369d916aea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aioslimproto==2.3.3 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.3.0 +aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index eaf6a69cb3d..aa0370bd347 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -26,6 +26,10 @@ DUMMY_DEVICE_ID1 = "a123bc" DUMMY_DEVICE_ID2 = "cafe12" DUMMY_DEVICE_ID3 = "bada77" DUMMY_DEVICE_ID4 = "bbd164" +DUMMY_DEVICE_KEY1 = "18" +DUMMY_DEVICE_KEY2 = "01" +DUMMY_DEVICE_KEY3 = "12" +DUMMY_DEVICE_KEY4 = "07" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME3 = "Breeze AB39" @@ -67,6 +71,7 @@ DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, DeviceState.ON, DUMMY_DEVICE_ID1, + DUMMY_DEVICE_KEY1, DUMMY_IP_ADDRESS1, DUMMY_MAC_ADDRESS1, DUMMY_DEVICE_NAME1, @@ -78,6 +83,7 @@ DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater( DeviceType.V4, DeviceState.ON, DUMMY_DEVICE_ID2, + DUMMY_DEVICE_KEY2, DUMMY_IP_ADDRESS2, DUMMY_MAC_ADDRESS2, DUMMY_DEVICE_NAME2, @@ -91,6 +97,7 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter( DeviceType.RUNNER, DeviceState.ON, DUMMY_DEVICE_ID4, + DUMMY_DEVICE_KEY4, DUMMY_IP_ADDRESS4, DUMMY_MAC_ADDRESS4, DUMMY_DEVICE_NAME4, @@ -102,6 +109,7 @@ DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( DeviceType.BREEZE, DeviceState.ON, DUMMY_DEVICE_ID3, + DUMMY_DEVICE_KEY3, DUMMY_IP_ADDRESS3, DUMMY_MAC_ADDRESS3, DUMMY_DEVICE_NAME3, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index f238bceb39e..f49ab99ba6c 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -25,6 +25,7 @@ async def test_diagnostics( { "auto_shutdown": "02:00:00", "device_id": REDACTED, + "device_key": REDACTED, "device_state": { "__type": "", "repr": "", From c74bef265a3f48173429aa53a2a4e428b942c205 Mon Sep 17 00:00:00 2001 From: bubonicbob <12600312+bubonicbob@users.noreply.github.com> Date: Wed, 10 Jan 2024 13:21:53 -0800 Subject: [PATCH 0468/1544] Update powerwall for tesla_powerwall 0.5.0 which is async (#107164) Co-authored-by: J. Nick Koston --- .../components/powerwall/__init__.py | 168 +++++++++++------- .../components/powerwall/binary_sensor.py | 8 +- .../components/powerwall/config_flow.py | 82 +++++---- homeassistant/components/powerwall/const.py | 1 - .../components/powerwall/manifest.json | 2 +- homeassistant/components/powerwall/models.py | 18 +- homeassistant/components/powerwall/sensor.py | 41 +++-- .../components/powerwall/strings.json | 8 +- homeassistant/components/powerwall/switch.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../powerwall/fixtures/meters_empty.json | 1 + .../powerwall/fixtures/sitemaster.json | 7 +- .../components/powerwall/fixtures/status.json | 11 +- tests/components/powerwall/mocks.py | 89 ++++++---- .../powerwall/test_binary_sensor.py | 22 ++- .../components/powerwall/test_config_flow.py | 72 +++++++- tests/components/powerwall/test_init.py | 17 +- tests/components/powerwall/test_sensor.py | 41 ++++- tests/components/powerwall/test_switch.py | 14 +- 20 files changed, 401 insertions(+), 209 deletions(-) create mode 100644 tests/components/powerwall/fixtures/meters_empty.json diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 8587101a42a..8fcc56e449f 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -1,17 +1,18 @@ """The Tesla Powerwall integration.""" from __future__ import annotations -import contextlib +import asyncio +from contextlib import AsyncExitStack from datetime import timedelta import logging +from typing import Optional -import requests +from aiohttp import CookieJar from tesla_powerwall import ( AccessDeniedError, - APIError, + ApiError, MissingAttributeError, Powerwall, - PowerwallError, PowerwallUnreachableError, ) @@ -20,17 +21,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.network import is_ip_address -from .const import ( - DOMAIN, - POWERWALL_API_CHANGED, - POWERWALL_COORDINATOR, - POWERWALL_HTTP_SESSION, - UPDATE_INTERVAL, -) +from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL from .models import PowerwallBaseInfo, PowerwallData, PowerwallRuntimeData CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -70,11 +66,11 @@ class PowerwallDataManager: """Return true if the api has changed out from under us.""" return self.runtime_data[POWERWALL_API_CHANGED] - def _recreate_powerwall_login(self) -> None: + async def _recreate_powerwall_login(self) -> None: """Recreate the login on auth failure.""" if self.power_wall.is_authenticated(): - self.power_wall.logout() - self.power_wall.login(self.password or "") + await self.power_wall.logout() + await self.power_wall.login(self.password or "") async def async_update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" @@ -82,17 +78,17 @@ class PowerwallDataManager: _LOGGER.debug("Checking if update failed") if self.api_changed: raise UpdateFailed("The powerwall api has changed") - return await self.hass.async_add_executor_job(self._update_data) + return await self._update_data() - def _update_data(self) -> PowerwallData: + async def _update_data(self) -> PowerwallData: """Fetch data from API endpoint.""" _LOGGER.debug("Updating data") for attempt in range(2): try: if attempt == 1: - self._recreate_powerwall_login() - data = _fetch_powerwall_data(self.power_wall) - except PowerwallUnreachableError as err: + await self._recreate_powerwall_login() + data = await _fetch_powerwall_data(self.power_wall) + except (asyncio.TimeoutError, PowerwallUnreachableError) as err: raise UpdateFailed("Unable to fetch data from powerwall") from err except MissingAttributeError as err: _LOGGER.error("The powerwall api has changed: %s", str(err)) @@ -112,7 +108,7 @@ class PowerwallDataManager: _LOGGER.debug("Access denied, trying to reauthenticate") # there is still an attempt left to authenticate, # so we continue in the loop - except APIError as err: + except ApiError as err: raise UpdateFailed(f"Updated failed due to {err}, will retry") from err else: return data @@ -121,33 +117,38 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" - http_session = requests.Session() ip_address: str = entry.data[CONF_IP_ADDRESS] password: str | None = entry.data.get(CONF_PASSWORD) - power_wall = Powerwall(ip_address, http_session=http_session) - try: - base_info = await hass.async_add_executor_job( - _login_and_fetch_base_info, power_wall, ip_address, password - ) - except PowerwallUnreachableError as err: - http_session.close() - raise ConfigEntryNotReady from err - except MissingAttributeError as err: - http_session.close() - # The error might include some important information about what exactly changed. - _LOGGER.error("The powerwall api has changed: %s", str(err)) - persistent_notification.async_create( - hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE - ) - return False - except AccessDeniedError as err: - _LOGGER.debug("Authentication failed", exc_info=err) - http_session.close() - raise ConfigEntryAuthFailed from err - except APIError as err: - http_session.close() - raise ConfigEntryNotReady from err + http_session = async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) + + async with AsyncExitStack() as stack: + power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False) + stack.push_async_callback(power_wall.close) + + try: + base_info = await _login_and_fetch_base_info( + power_wall, ip_address, password + ) + + # Cancel closing power_wall on success + stack.pop_all() + except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + raise ConfigEntryNotReady from err + except MissingAttributeError as err: + # The error might include some important information about what exactly changed. + _LOGGER.error("The powerwall api has changed: %s", str(err)) + persistent_notification.async_create( + hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE + ) + return False + except AccessDeniedError as err: + _LOGGER.debug("Authentication failed", exc_info=err) + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din if gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id): @@ -156,7 +157,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: runtime_data = PowerwallRuntimeData( api_changed=False, base_info=base_info, - http_session=http_session, coordinator=None, api_instance=power_wall, ) @@ -183,44 +183,76 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _login_and_fetch_base_info( +async def _login_and_fetch_base_info( power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: - power_wall.login(password) - return call_base_info(power_wall, host) + await power_wall.login(password) + return await _call_base_info(power_wall, host) -def call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: +async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: """Return PowerwallBaseInfo for the device.""" - # Make sure the serial numbers always have the same order - gateway_din = None - with contextlib.suppress(AssertionError, PowerwallError): - gateway_din = power_wall.get_gateway_din().upper() + + ( + gateway_din, + site_info, + status, + device_type, + serial_numbers, + ) = await asyncio.gather( + power_wall.get_gateway_din(), + power_wall.get_site_info(), + power_wall.get_status(), + power_wall.get_device_type(), + power_wall.get_serial_numbers(), + ) + + # Serial numbers MUST be sorted to ensure the unique_id is always the same + # for backwards compatibility. return PowerwallBaseInfo( - gateway_din=gateway_din, - site_info=power_wall.get_site_info(), - status=power_wall.get_status(), - device_type=power_wall.get_device_type(), - serial_numbers=sorted(power_wall.get_serial_numbers()), + gateway_din=gateway_din.upper(), + site_info=site_info, + status=status, + device_type=device_type, + serial_numbers=sorted(serial_numbers), url=f"https://{host}", ) -def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: - """Process and update powerwall data.""" +async def get_backup_reserve_percentage(power_wall: Powerwall) -> Optional[float]: + """Return the backup reserve percentage.""" try: - backup_reserve = power_wall.get_backup_reserve_percentage() + return await power_wall.get_backup_reserve_percentage() except MissingAttributeError: - backup_reserve = None + return None + + +async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: + """Process and update powerwall data.""" + ( + backup_reserve, + charge, + site_master, + meters, + grid_services_active, + grid_status, + ) = await asyncio.gather( + get_backup_reserve_percentage(power_wall), + power_wall.get_charge(), + power_wall.get_sitemaster(), + power_wall.get_meters(), + power_wall.is_grid_services_active(), + power_wall.get_grid_status(), + ) return PowerwallData( - charge=power_wall.get_charge(), - site_master=power_wall.get_sitemaster(), - meters=power_wall.get_meters(), - grid_services_active=power_wall.is_grid_services_active(), - grid_status=power_wall.get_grid_status(), + charge=charge, + site_master=site_master, + meters=meters, + grid_services_active=grid_services_active, + grid_status=grid_status, backup_reserve=backup_reserve, ) @@ -240,8 +272,6 @@ 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) - hass.data[DOMAIN][entry.entry_id][POWERWALL_HTTP_SESSION].close() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 084ec0ea8a6..b73068985d5 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -1,5 +1,7 @@ """Support for powerwall binary sensors.""" +from typing import TYPE_CHECKING + from tesla_powerwall import GridStatus, MeterType from homeassistant.components.binary_sensor import ( @@ -131,5 +133,9 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Powerwall is charging.""" + meter = self.data.meters.get_meter(MeterType.BATTERY) + # Meter cannot be None because of the available property + if TYPE_CHECKING: + assert meter is not None # is_sending_to returns true for values greater than 100 watts - return self.data.meters.get_meter(MeterType.BATTERY).is_sending_to() + return meter.is_sending_to() diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index f4ebc0f33b1..0946a71a01d 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,16 +1,18 @@ """Config flow for Tesla Powerwall integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any +from aiohttp import CookieJar from tesla_powerwall import ( AccessDeniedError, MissingAttributeError, Powerwall, PowerwallUnreachableError, - SiteInfo, + SiteInfoResponse, ) import voluptuous as vol @@ -18,6 +20,7 @@ from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util.network import is_ip_address from . import async_last_update_was_successful @@ -32,19 +35,23 @@ ENTRY_FAILURE_STATES = { } -def _login_and_fetch_site_info( +async def _login_and_fetch_site_info( power_wall: Powerwall, password: str -) -> tuple[SiteInfo, str]: +) -> tuple[SiteInfoResponse, str]: """Login to the powerwall and fetch the base info.""" if password is not None: - power_wall.login(password) - return power_wall.get_site_info(), power_wall.get_gateway_din() + await power_wall.login(password) + + return await asyncio.gather( + power_wall.get_site_info(), power_wall.get_gateway_din() + ) -def _powerwall_is_reachable(ip_address: str, password: str) -> bool: +async def _powerwall_is_reachable(ip_address: str, password: str) -> bool: """Check if the powerwall is reachable.""" try: - Powerwall(ip_address).login(password) + async with Powerwall(ip_address) as power_wall: + await power_wall.login(password) except AccessDeniedError: return True except PowerwallUnreachableError: @@ -59,21 +66,23 @@ async def validate_input( Data has the keys from schema with values provided by the user. """ + session = async_create_clientsession( + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + ) + async with Powerwall(data[CONF_IP_ADDRESS], http_session=session) as power_wall: + password = data[CONF_PASSWORD] - power_wall = Powerwall(data[CONF_IP_ADDRESS]) - password = data[CONF_PASSWORD] + try: + site_info, gateway_din = await _login_and_fetch_site_info( + power_wall, password + ) + except MissingAttributeError as err: + # Only log the exception without the traceback + _LOGGER.error(str(err)) + raise WrongVersion from err - try: - site_info, gateway_din = await hass.async_add_executor_job( - _login_and_fetch_site_info, power_wall, password - ) - except MissingAttributeError as err: - # Only log the exception without the traceback - _LOGGER.error(str(err)) - raise WrongVersion from err - - # Return info that you want to store in the config entry. - return {"title": site_info.site_name, "unique_id": gateway_din.upper()} + # Return info that you want to store in the config entry. + return {"title": site_info.site_name, "unique_id": gateway_din.upper()} class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -102,9 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return bool( entry.state in ENTRY_FAILURE_STATES or not async_last_update_was_successful(self.hass, entry) - ) and not await self.hass.async_add_executor_job( - _powerwall_is_reachable, ip_address, password - ) + ) and not await _powerwall_is_reachable(ip_address, password) async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" @@ -137,7 +144,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "name": gateway_din, "ip_address": self.ip_address, } - errors, info = await self._async_try_connect( + errors, info, _ = await self._async_try_connect( {CONF_IP_ADDRESS: self.ip_address, CONF_PASSWORD: gateway_din[-5:]} ) if errors: @@ -152,23 +159,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_try_connect( self, user_input: dict[str, Any] - ) -> tuple[dict[str, Any] | None, dict[str, str] | None]: + ) -> tuple[dict[str, Any] | None, dict[str, str] | None, dict[str, str]]: """Try to connect to the powerwall.""" info = None errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) - except PowerwallUnreachableError: + except PowerwallUnreachableError as ex: errors[CONF_IP_ADDRESS] = "cannot_connect" - except WrongVersion: + description_placeholders = {"error": str(ex)} + except WrongVersion as ex: errors["base"] = "wrong_version" - except AccessDeniedError: + description_placeholders = {"error": str(ex)} + except AccessDeniedError as ex: errors[CONF_PASSWORD] = "invalid_auth" - except Exception: # pylint: disable=broad-except + description_placeholders = {"error": str(ex)} + except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + description_placeholders = {"error": str(ex)} - return errors, info + return errors, info, description_placeholders async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -204,8 +216,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] = {} if user_input is not None: - errors, info = await self._async_try_connect(user_input) + errors, info, description_placeholders = await self._async_try_connect( + user_input + ) if not errors: assert info is not None if info["unique_id"]: @@ -227,6 +242,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_reauth_confirm( @@ -235,9 +251,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle reauth confirmation.""" assert self.reauth_entry is not None errors: dict[str, str] | None = {} + description_placeholders: dict[str, str] = {} if user_input is not None: entry_data = self.reauth_entry.data - errors, _ = await self._async_try_connect( + errors, _, description_placeholders = await self._async_try_connect( {CONF_IP_ADDRESS: entry_data[CONF_IP_ADDRESS], **user_input} ) if not errors: @@ -251,6 +268,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index b22e6466cf6..c20ab760f23 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -7,7 +7,6 @@ POWERWALL_BASE_INFO: Final = "base_info" POWERWALL_COORDINATOR: Final = "coordinator" POWERWALL_API: Final = "api_instance" POWERWALL_API_CHANGED: Final = "api_changed" -POWERWALL_HTTP_SESSION: Final = "http_session" UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 989940e9f1d..4de9cf8b982 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/powerwall", "iot_class": "local_polling", "loggers": ["tesla_powerwall"], - "requirements": ["tesla-powerwall==0.3.19"] + "requirements": ["tesla-powerwall==0.5.0"] } diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/models.py index 3ee95b815f5..d67a21a0d53 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/models.py @@ -4,15 +4,14 @@ from __future__ import annotations from dataclasses import dataclass from typing import TypedDict -from requests import Session from tesla_powerwall import ( DeviceType, GridStatus, - MetersAggregates, + MetersAggregatesResponse, Powerwall, - PowerwallStatus, - SiteInfo, - SiteMaster, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -23,8 +22,8 @@ class PowerwallBaseInfo: """Base information for the powerwall integration.""" gateway_din: None | str - site_info: SiteInfo - status: PowerwallStatus + site_info: SiteInfoResponse + status: PowerwallStatusResponse device_type: DeviceType serial_numbers: list[str] url: str @@ -35,8 +34,8 @@ class PowerwallData: """Point in time data for the powerwall integration.""" charge: float - site_master: SiteMaster - meters: MetersAggregates + site_master: SiteMasterResponse + meters: MetersAggregatesResponse grid_services_active: bool grid_status: GridStatus backup_reserve: float | None @@ -49,4 +48,3 @@ class PowerwallRuntimeData(TypedDict): api_instance: Powerwall base_info: PowerwallBaseInfo api_changed: bool - http_session: Session diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index bfa75392efb..d797f56df02 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -3,8 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING -from tesla_powerwall import Meter, MeterType +from tesla_powerwall import MeterResponse, MeterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -36,7 +37,7 @@ _METER_DIRECTION_IMPORT = "import" class PowerwallRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[Meter], float] + value_fn: Callable[[MeterResponse], float] @dataclass(frozen=True) @@ -46,24 +47,24 @@ class PowerwallSensorEntityDescription( """Describes Powerwall entity.""" -def _get_meter_power(meter: Meter) -> float: +def _get_meter_power(meter: MeterResponse) -> float: """Get the current value in kW.""" return meter.get_power(precision=3) -def _get_meter_frequency(meter: Meter) -> float: +def _get_meter_frequency(meter: MeterResponse) -> float: """Get the current value in Hz.""" return round(meter.frequency, 1) -def _get_meter_total_current(meter: Meter) -> float: +def _get_meter_total_current(meter: MeterResponse) -> float: """Get the current value in A.""" return meter.get_instant_total_current() -def _get_meter_average_voltage(meter: Meter) -> float: +def _get_meter_average_voltage(meter: MeterResponse) -> float: """Get the current value in V.""" - return round(meter.average_voltage, 1) + return round(meter.instant_average_voltage, 1) POWERWALL_INSTANT_SENSORS = ( @@ -171,9 +172,13 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{description.key}" @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value.""" - return self.entity_description.value_fn(self.data.meters.get_meter(self._meter)) + meter = self.data.meters.get_meter(self._meter) + if meter is not None: + return self.entity_description.value_fn(meter) + + return None class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): @@ -224,10 +229,10 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): we do not want to include in statistics and its a transient data error. """ - return super().available and self.native_value != 0 + return super().available and self.meter is not None @property - def meter(self) -> Meter: + def meter(self) -> MeterResponse | None: """Get the meter for the sensor.""" return self.data.meters.get_meter(self._meter) @@ -244,9 +249,12 @@ class PowerWallExportSensor(PowerWallEnergyDirectionSensor): super().__init__(powerwall_data, meter, _METER_DIRECTION_EXPORT) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value in kWh.""" - return self.meter.get_energy_exported() + meter = self.meter + if TYPE_CHECKING: + assert meter is not None + return meter.get_energy_exported() class PowerWallImportSensor(PowerWallEnergyDirectionSensor): @@ -261,6 +269,9 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor): super().__init__(powerwall_data, meter, _METER_DIRECTION_IMPORT) @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Get the current value in kWh.""" - return self.meter.get_energy_imported() + meter = self.meter + if TYPE_CHECKING: + assert meter is not None + return meter.get_energy_imported() diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 8be76dc8716..3a44aa8053e 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -23,10 +23,10 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", - "unknown": "[%key:common::config_flow::error::unknown%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "cannot_connect": "A connection error occurred while connecting to the Powerwall: {error}", + "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved: {error}", + "unknown": "An unknown error occurred: {error}", + "invalid_auth": "Authentication failed with error: {error}" }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 8516890d633..673672915fa 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -59,9 +59,7 @@ class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity): async def _async_set_island_mode(self, island_mode: IslandMode) -> None: """Toggles off-grid mode using the island_mode argument.""" try: - await self.hass.async_add_executor_job( - self.power_wall.set_island_mode, island_mode - ) + await self.power_wall.set_island_mode(island_mode) except PowerwallError as ex: raise HomeAssistantError( f"Setting off-grid operation to {island_mode} failed: {ex}" diff --git a/requirements_all.txt b/requirements_all.txt index 45a18853fa2..38cd1f2f727 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2635,7 +2635,7 @@ temperusb==1.6.1 # tensorflow==2.5.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.19 +tesla-powerwall==0.5.0 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7369d916aea..d2df8cf9d97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ temescal==0.5 temperusb==1.6.1 # homeassistant.components.powerwall -tesla-powerwall==0.3.19 +tesla-powerwall==0.5.0 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 diff --git a/tests/components/powerwall/fixtures/meters_empty.json b/tests/components/powerwall/fixtures/meters_empty.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/components/powerwall/fixtures/meters_empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/powerwall/fixtures/sitemaster.json b/tests/components/powerwall/fixtures/sitemaster.json index edac62d0f7d..90478daa66a 100644 --- a/tests/components/powerwall/fixtures/sitemaster.json +++ b/tests/components/powerwall/fixtures/sitemaster.json @@ -1 +1,6 @@ -{ "connected_to_tesla": true, "running": true, "status": "StatusUp" } +{ + "connected_to_tesla": true, + "power_supply_mode": false, + "running": true, + "status": "StatusUp" +} diff --git a/tests/components/powerwall/fixtures/status.json b/tests/components/powerwall/fixtures/status.json index 058c0fcec49..08a2d0a0ec6 100644 --- a/tests/components/powerwall/fixtures/status.json +++ b/tests/components/powerwall/fixtures/status.json @@ -1,7 +1,10 @@ { - "start_time": "2020-03-10 11:57:25 +0800", - "up_time_seconds": "217h40m57.470801079s", + "commission_count": 0, + "device_type": "hec", + "git_hash": "d0e69bde519634961cca04a616d2d4dae80b9f61", "is_new": false, - "version": "1.45.1", - "git_hash": "13bf684a633175f884079ec79f42997080d90310" + "start_time": "2020-10-28 20:14:11 +0800", + "sync_type": "v1", + "up_time_seconds": "17h11m31.214751424s", + "version": "1.50.1 c58c2df3" } diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py index ae6601b0215..c1fb2630261 100644 --- a/tests/components/powerwall/mocks.py +++ b/tests/components/powerwall/mocks.py @@ -1,17 +1,18 @@ """Mocks for powerwall.""" +import asyncio import json import os -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock from tesla_powerwall import ( DeviceType, GridStatus, - MetersAggregates, + MetersAggregatesResponse, Powerwall, - PowerwallStatus, - SiteInfo, - SiteMaster, + PowerwallStatusResponse, + SiteInfoResponse, + SiteMasterResponse, ) from tests.common import load_fixture @@ -19,29 +20,31 @@ from tests.common import load_fixture MOCK_GATEWAY_DIN = "111-0----2-000000000FFA" -async def _mock_powerwall_with_fixtures(hass): +async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> MagicMock: """Mock data used to build powerwall state.""" - meters = await _async_load_json_fixture(hass, "meters.json") - sitemaster = await _async_load_json_fixture(hass, "sitemaster.json") - site_info = await _async_load_json_fixture(hass, "site_info.json") - status = await _async_load_json_fixture(hass, "status.json") - device_type = await _async_load_json_fixture(hass, "device_type.json") + async with asyncio.TaskGroup() as tg: + meters_file = "meters_empty.json" if empty_meters else "meters.json" + meters = tg.create_task(_async_load_json_fixture(hass, meters_file)) + sitemaster = tg.create_task(_async_load_json_fixture(hass, "sitemaster.json")) + site_info = tg.create_task(_async_load_json_fixture(hass, "site_info.json")) + status = tg.create_task(_async_load_json_fixture(hass, "status.json")) + device_type = tg.create_task(_async_load_json_fixture(hass, "device_type.json")) - return _mock_powerwall_return_value( - site_info=SiteInfo(site_info), + return await _mock_powerwall_return_value( + site_info=SiteInfoResponse.from_dict(site_info.result()), charge=47.34587394586, - sitemaster=SiteMaster(sitemaster), - meters=MetersAggregates(meters), + sitemaster=SiteMasterResponse.from_dict(sitemaster.result()), + meters=MetersAggregatesResponse.from_dict(meters.result()), grid_services_active=True, grid_status=GridStatus.CONNECTED, - status=PowerwallStatus(status), - device_type=DeviceType(device_type["device_type"]), + status=PowerwallStatusResponse.from_dict(status.result()), + device_type=DeviceType(device_type.result()["device_type"]), serial_numbers=["TG0123456789AB", "TG9876543210BA"], backup_reserve_percentage=15.0, ) -def _mock_powerwall_return_value( +async def _mock_powerwall_return_value( site_info=None, charge=None, sitemaster=None, @@ -53,38 +56,46 @@ def _mock_powerwall_return_value( serial_numbers=None, backup_reserve_percentage=None, ): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) - powerwall_mock.get_site_info = Mock(return_value=site_info) - powerwall_mock.get_charge = Mock(return_value=charge) - powerwall_mock.get_sitemaster = Mock(return_value=sitemaster) - powerwall_mock.get_meters = Mock(return_value=meters) - powerwall_mock.get_grid_status = Mock(return_value=grid_status) - powerwall_mock.get_status = Mock(return_value=status) - powerwall_mock.get_device_type = Mock(return_value=device_type) - powerwall_mock.get_serial_numbers = Mock(return_value=serial_numbers) - powerwall_mock.get_backup_reserve_percentage = Mock( - return_value=backup_reserve_percentage + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock + + powerwall_mock.get_site_info.return_value = site_info + powerwall_mock.get_charge.return_value = charge + powerwall_mock.get_sitemaster.return_value = sitemaster + powerwall_mock.get_meters.return_value = meters + powerwall_mock.get_grid_status.return_value = grid_status + powerwall_mock.get_status.return_value = status + powerwall_mock.get_device_type.return_value = device_type + powerwall_mock.get_serial_numbers.return_value = serial_numbers + powerwall_mock.get_backup_reserve_percentage.return_value = ( + backup_reserve_percentage ) - powerwall_mock.is_grid_services_active = Mock(return_value=grid_services_active) + powerwall_mock.is_grid_services_active.return_value = grid_services_active + powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN return powerwall_mock async def _mock_powerwall_site_name(hass, site_name): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock - site_info_resp = SiteInfo(await _async_load_json_fixture(hass, "site_info.json")) - # Sets site_info_resp.site_name to return site_name - site_info_resp.response["site_name"] = site_name - powerwall_mock.get_site_info = Mock(return_value=site_info_resp) - powerwall_mock.get_gateway_din = Mock(return_value=MOCK_GATEWAY_DIN) + site_info_resp = SiteInfoResponse.from_dict( + await _async_load_json_fixture(hass, "site_info.json") + ) + site_info_resp._raw["site_name"] = site_name + site_info_resp.site_name = site_name + powerwall_mock.get_site_info.return_value = site_info_resp + powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN return powerwall_mock -def _mock_powerwall_side_effect(site_info=None): - powerwall_mock = MagicMock(Powerwall("1.2.3.4")) - powerwall_mock.get_site_info = Mock(side_effect=site_info) +async def _mock_powerwall_side_effect(site_info=None): + powerwall_mock = MagicMock(Powerwall) + powerwall_mock.__aenter__.return_value = powerwall_mock + + powerwall_mock.get_site_info.side_effect = site_info return powerwall_mock diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py index b0a62f42368..f24c0e910a2 100644 --- a/tests/components/powerwall/test_binary_sensor.py +++ b/tests/components/powerwall/test_binary_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import patch from homeassistant.components.powerwall.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, STATE_ON +from homeassistant.const import CONF_IP_ADDRESS, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from .mocks import _mock_powerwall_with_fixtures @@ -75,3 +75,23 @@ async def test_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: + """Test creation of the binary sensors with empty meters.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.mysite_charging") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 29807fd597b..d79bf6c50f0 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Powerwall config flow.""" +import asyncio +from datetime import timedelta from unittest.mock import MagicMock, patch from tesla_powerwall import ( @@ -14,6 +16,7 @@ from homeassistant.components.powerwall.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +import homeassistant.util.dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, @@ -22,7 +25,7 @@ from .mocks import ( _mock_powerwall_with_fixtures, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed VALID_CONFIG = {CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "00GGX"} @@ -36,7 +39,7 @@ async def test_form_source_user(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - mock_powerwall = await _mock_powerwall_site_name(hass, "My site") + mock_powerwall = await _mock_powerwall_site_name(hass, "MySite") with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -52,7 +55,7 @@ async def test_form_source_user(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "My site" + assert result2["title"] == "MySite" assert result2["data"] == VALID_CONFIG assert len(mock_setup_entry.mock_calls) == 1 @@ -63,7 +66,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError) + mock_powerwall = await _mock_powerwall_side_effect( + site_info=PowerwallUnreachableError + ) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -84,7 +89,9 @@ async def test_invalid_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=AccessDeniedError("any")) + mock_powerwall = await _mock_powerwall_side_effect( + site_info=AccessDeniedError("any") + ) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -105,7 +112,7 @@ async def test_form_unknown_exeption(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect(site_info=ValueError) + mock_powerwall = await _mock_powerwall_side_effect(site_info=ValueError) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -125,7 +132,7 @@ async def test_form_wrong_version(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = _mock_powerwall_side_effect( + mock_powerwall = await _mock_powerwall_side_effect( site_info=MissingAttributeError({}, "") ) @@ -286,7 +293,9 @@ async def test_dhcp_discovery_auto_configure(hass: HomeAssistant) -> None: async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: """Test we can process the discovery from dhcp and we cannot connect.""" - mock_powerwall = _mock_powerwall_side_effect(site_info=PowerwallUnreachableError) + mock_powerwall = await _mock_powerwall_side_effect( + site_info=PowerwallUnreachableError + ) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -354,6 +363,7 @@ async def test_dhcp_discovery_update_ip_address(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) mock_powerwall = MagicMock(login=MagicMock(side_effect=PowerwallUnreachableError)) + mock_powerwall.__aenter__.return_value = mock_powerwall with patch( "homeassistant.components.powerwall.config_flow.Powerwall", @@ -547,3 +557,49 @@ async def test_discovered_wifi_does_not_update_ip_if_is_still_online( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4" + + +async def test_discovered_wifi_does_not_update_ip_online_but_access_denied( + hass: HomeAssistant, +) -> None: + """Test a discovery does not update the ip unless the powerwall at the old ip is offline.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + unique_id=MOCK_GATEWAY_DIN, + ) + entry.add_to_hass(hass) + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + mock_powerwall_no_access = await _mock_powerwall_with_fixtures(hass) + mock_powerwall_no_access.login.side_effect = AccessDeniedError("any") + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall_no_access, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Now mock the powerwall to be offline to force + # the discovery flow to probe to see if its online + # which will result in an access denied error, which + # means its still online and we should not update the ip + mock_powerwall.get_meters.side_effect = asyncio.TimeoutError + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.5", + macaddress="AA:BB:CC:DD:EE:FF", + hostname=MOCK_GATEWAY_DIN.lower(), + ), + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_IP_ADDRESS] == "1.2.3.4" diff --git a/tests/components/powerwall/test_init.py b/tests/components/powerwall/test_init.py index f9c5ccbbbeb..ed0dc0ebde8 100644 --- a/tests/components/powerwall/test_init.py +++ b/tests/components/powerwall/test_init.py @@ -1,7 +1,7 @@ """Tests for the PowerwallDataManager.""" import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch from tesla_powerwall import AccessDeniedError, LoginResponse @@ -24,12 +24,17 @@ async def test_update_data_reauthenticate_on_access_denied(hass: HomeAssistant) # 1. login success on entry setup # 2. login success after reauthentication # 3. login failure after reauthentication - mock_powerwall.login = MagicMock(name="login", return_value=LoginResponse({})) - mock_powerwall.get_charge = MagicMock(name="get_charge", return_value=90.0) - mock_powerwall.is_authenticated = MagicMock( - name="is_authenticated", return_value=True + mock_powerwall.login.return_value = LoginResponse.from_dict( + { + "firstname": "firstname", + "lastname": "lastname", + "token": "token", + "roles": [], + "loginTime": "loginTime", + } ) - mock_powerwall.logout = MagicMock(name="logout") + mock_powerwall.get_charge.return_value = 90.0 + mock_powerwall.is_authenticated.return_value = True config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "password"} diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index e7772571c86..a58c30f332e 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -1,6 +1,8 @@ """The sensor tests for the powerwall platform.""" +from datetime import timedelta from unittest.mock import Mock, patch +from tesla_powerwall import MetersAggregatesResponse from tesla_powerwall.error import MissingAttributeError from homeassistant.components.powerwall.const import DOMAIN @@ -11,13 +13,15 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, PERCENTAGE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util from .mocks import _mock_powerwall_with_fixtures -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors( @@ -43,7 +47,7 @@ async def test_sensors( identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")}, ) assert reg_device.model == "PowerWall 2 (GW1)" - assert reg_device.sw_version == "1.45.1" + assert reg_device.sw_version == "1.50.1 c58c2df3" assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" @@ -118,13 +122,23 @@ async def test_sensors( for key, value in expected_attributes.items(): assert state.attributes[key] == value + mock_powerwall.get_meters.return_value = MetersAggregatesResponse.from_dict({}) + mock_powerwall.get_backup_reserve_percentage.return_value = None + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mysite_load_power").state == STATE_UNKNOWN + assert hass.states.get("sensor.mysite_load_frequency").state == STATE_UNKNOWN + assert hass.states.get("sensor.mysite_backup_reserve").state == STATE_UNKNOWN + async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None: """Confirm that backup reserve sensor is not added if data is unavailable from the device.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) - mock_powerwall.get_backup_reserve_percentage = Mock( - side_effect=MissingAttributeError(Mock(), "backup_reserve_percent", "operation") + mock_powerwall.get_backup_reserve_percentage.side_effect = MissingAttributeError( + Mock(), "backup_reserve_percent", "operation" ) config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) @@ -140,3 +154,22 @@ async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None: state = hass.states.get("sensor.powerwall_backup_reserve") assert state is None + + +async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: + """Test creation of the sensors with empty meters.""" + + mock_powerwall = await _mock_powerwall_with_fixtures(hass, empty_meters=True) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mysite_solar_power") is None diff --git a/tests/components/powerwall/test_switch.py b/tests/components/powerwall/test_switch.py index 393f89e62fd..e63d6031155 100644 --- a/tests/components/powerwall/test_switch.py +++ b/tests/components/powerwall/test_switch.py @@ -1,5 +1,5 @@ """Test for Powerwall off-grid switch.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from tesla_powerwall import GridStatus, PowerwallError @@ -43,7 +43,7 @@ async def test_entity_registry( ) -> None: """Test powerwall off-grid switch device.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED assert ENTITY_ID in entity_registry.entities @@ -51,7 +51,7 @@ async def test_entity_registry( async def test_initial(hass: HomeAssistant, mock_powerwall) -> None: """Test initial grid status without off grid switch selected.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -60,7 +60,7 @@ async def test_initial(hass: HomeAssistant, mock_powerwall) -> None: async def test_on(hass: HomeAssistant, mock_powerwall) -> None: """Test state once offgrid switch has been turned on.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.ISLANDED) + mock_powerwall.get_grid_status.return_value = GridStatus.ISLANDED await hass.services.async_call( SWITCH_DOMAIN, @@ -76,7 +76,7 @@ async def test_on(hass: HomeAssistant, mock_powerwall) -> None: async def test_off(hass: HomeAssistant, mock_powerwall) -> None: """Test state once offgrid switch has been turned off.""" - mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) + mock_powerwall.get_grid_status.return_value = GridStatus.CONNECTED await hass.services.async_call( SWITCH_DOMAIN, @@ -95,9 +95,7 @@ async def test_exception_on_powerwall_error( """Ensure that an exception in the tesla_powerwall library causes a HomeAssistantError.""" with pytest.raises(HomeAssistantError, match="Setting off-grid operation to"): - mock_powerwall.set_island_mode = Mock( - side_effect=PowerwallError("Mock exception") - ) + mock_powerwall.set_island_mode.side_effect = PowerwallError("Mock exception") await hass.services.async_call( SWITCH_DOMAIN, From 350806c0361dec9fe6ba2a1889f37c838ec98196 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 10 Jan 2024 13:49:25 -0800 Subject: [PATCH 0469/1544] Make to-do list item exception wording consistent (#107743) --- homeassistant/components/todo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 5ef7a5fe35b..717aa310ecd 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -93,7 +93,7 @@ }, "exceptions": { "item_not_found": { - "message": "Unable to find To-do item: {item}" + "message": "Unable to find to-do list item: {item}" }, "update_field_not_supported": { "message": "Entity does not support setting field: {service_field}" From 1bb76e2351c7c8b690e8134369df3775a3dfd692 Mon Sep 17 00:00:00 2001 From: Eugene Tiutiunnyk <4804824+eugenet8k@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:23:40 -0800 Subject: [PATCH 0470/1544] Fix Mac address check in kef integration (#107746) Fix the check for Mac address in kef integration (#106072) It might be due to an update of `getmac` dependency in some case the mac was resolved to "00:00:00:00:00:00" instead of the anticipated `None`. With that the original bug #47678 where a duplicated entity would be created in case of HA is restarted while the KEF speaker is offline came back. The PR #52902 was applied back in time to fix that issue. Now, this change is a continuation of the previous efforts. The solution was tested for about two months and it does address the bug with creating duplicated entities in case of KEF speakers being offline. --- homeassistant/components/kef/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index 96f52ef7e03..b8407fd8bde 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -118,7 +118,7 @@ async def async_setup_platform( mode = get_ip_mode(host) mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - if mac is None: + if mac is None or mac == "00:00:00:00:00:00": raise PlatformNotReady("Cannot get the ip address of kef speaker.") unique_id = f"kef-{mac}" From b2f7fd12a2af7f348d5d9fd788a42aac6724a365 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Jan 2024 13:03:09 -1000 Subject: [PATCH 0471/1544] Add comment to ConfigEntry.async_setup about race safety (#107756) --- homeassistant/config_entries.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 336261c3632..d8738e67a04 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -478,6 +478,12 @@ class ConfigEntry: if self.domain != integration.domain: return + # + # It is important that this function does not yield to the + # event loop by using `await` or `async with` or similar until + # after the state has been set. Otherwise we risk that any `call_soon`s + # created by an integration will be executed before the state is set. + # if result: self._async_set_state(hass, ConfigEntryState.LOADED, None) else: From bc4c3bf9e79b54115f2f8d547aedd45c8264e7ce Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 10 Jan 2024 16:03:18 -0700 Subject: [PATCH 0472/1544] Add `valve` platform to Guardian (#107423) --- .coveragerc | 1 + homeassistant/components/guardian/__init__.py | 8 +- .../components/guardian/strings.json | 11 +- homeassistant/components/guardian/switch.py | 23 ++- homeassistant/components/guardian/valve.py | 190 ++++++++++++++++++ 5 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/guardian/valve.py diff --git a/.coveragerc b/.coveragerc index b8c7f949c01..58ed78b6dca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -473,6 +473,7 @@ omit = homeassistant/components/guardian/sensor.py homeassistant/components/guardian/switch.py homeassistant/components/guardian/util.py + homeassistant/components/guardian/valve.py homeassistant/components/habitica/__init__.py homeassistant/components/habitica/sensor.py homeassistant/components/harman_kardon_avr/media_player.py diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 117510a8c1a..90504f3213e 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -76,7 +76,13 @@ SERVICE_UPGRADE_FIRMWARE_SCHEMA = vol.Schema( }, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SENSOR, + Platform.SWITCH, + Platform.VALVE, +] @dataclass diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json index 59630e87932..c426f4f8081 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -44,6 +44,11 @@ "valve_controller": { "name": "Valve controller" } + }, + "valve": { + "valve_controller": { + "name": "Valve controller" + } } }, "services": { @@ -52,7 +57,7 @@ "description": "Adds a new paired sensor to the valve controller.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller to add the sensor to." }, "uid": { @@ -66,7 +71,7 @@ "description": "Removes a paired sensor from the valve controller.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller to remove the sensor from." }, "uid": { @@ -80,7 +85,7 @@ "description": "Upgrades the device firmware.", "fields": { "device_id": { - "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "name": "[%key:component::guardian::entity::valve::valve_controller::name%]", "description": "The valve controller whose firmware should be upgraded." }, "url": { diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 1ed5239641d..7db0fde8905 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -10,12 +10,13 @@ from aioguardian import Client 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.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN from .util import convert_exceptions_to_homeassistant_error +from .valve import GuardianValveState ATTR_AVG_CURRENT = "average_current" ATTR_CONNECTED_CLIENTS = "connected_clients" @@ -27,13 +28,6 @@ ATTR_TRAVEL_COUNT = "travel_count" SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" -ON_STATES = { - "start_opening", - "opening", - "finish_opening", - "opened", -} - @dataclass(frozen=True, kw_only=True) class ValveControllerSwitchDescription( @@ -71,6 +65,17 @@ async def _async_open_valve(client: Client) -> None: await client.valve.open() +@callback +def is_open(data: dict[str, Any]) -> bool: + """Return if the valve is opening.""" + return data["state"] in ( + GuardianValveState.FINISH_OPENING, + GuardianValveState.OPEN, + GuardianValveState.OPENING, + GuardianValveState.START_OPENING, + ) + + VALVE_CONTROLLER_DESCRIPTIONS = ( ValveControllerSwitchDescription( key=SWITCH_KIND_ONBOARD_AP, @@ -97,7 +102,7 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( ATTR_INST_CURRENT_DDT: data["instantaneous_current_ddt"], ATTR_TRAVEL_COUNT: data["travel_count"], }, - is_on_fn=lambda data: data["state"] in ON_STATES, + is_on_fn=is_open, off_fn=_async_close_valve, on_fn=_async_open_valve, ), diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py new file mode 100644 index 00000000000..94f5ddbee6a --- /dev/null +++ b/homeassistant/components/guardian/valve.py @@ -0,0 +1,190 @@ +"""Valves for the Elexa Guardian integration.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine, Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from aioguardian import Client + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription +from .const import API_VALVE_STATUS, DOMAIN +from .util import convert_exceptions_to_homeassistant_error + +ATTR_AVG_CURRENT = "average_current" +ATTR_CONNECTED_CLIENTS = "connected_clients" +ATTR_INST_CURRENT = "instantaneous_current" +ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" +ATTR_STATION_CONNECTED = "station_connected" +ATTR_TRAVEL_COUNT = "travel_count" + +VALVE_KIND_VALVE = "valve" + + +class GuardianValveState(StrEnum): + """States of a valve.""" + + CLOSED = "closed" + CLOSING = "closing" + FINISH_CLOSING = "finish_closing" + FINISH_OPENING = "finish_opening" + OPEN = "open" + OPENING = "opening" + START_CLOSING = "start_closing" + START_OPENING = "start_opening" + + +@dataclass(frozen=True, kw_only=True) +class ValveControllerValveDescription( + ValveEntityDescription, ValveControllerEntityDescription +): + """Describe a Guardian valve controller valve.""" + + extra_state_attributes_fn: Callable[[dict[str, Any]], Mapping[str, Any]] + is_closed_fn: Callable[[dict[str, Any]], bool] + is_closing_fn: Callable[[dict[str, Any]], bool] + is_opening_fn: Callable[[dict[str, Any]], bool] + close_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + halt_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + open_coro_fn: Callable[[Client], Coroutine[Any, Any, None]] + + +async def async_close_valve(client: Client) -> None: + """Close the valve.""" + async with client: + await client.valve.close() + + +async def async_halt_valve(client: Client) -> None: + """Halt the valve.""" + async with client: + await client.valve.halt() + + +async def async_open_valve(client: Client) -> None: + """Open the valve.""" + async with client: + await client.valve.open() + + +@callback +def is_closing(data: dict[str, Any]) -> bool: + """Return if the valve is closing.""" + return data["state"] in ( + GuardianValveState.CLOSING, + GuardianValveState.FINISH_CLOSING, + GuardianValveState.START_CLOSING, + ) + + +@callback +def is_opening(data: dict[str, Any]) -> bool: + """Return if the valve is opening.""" + return data["state"] in ( + GuardianValveState.OPENING, + GuardianValveState.FINISH_OPENING, + GuardianValveState.START_OPENING, + ) + + +VALVE_CONTROLLER_DESCRIPTIONS = ( + ValveControllerValveDescription( + key=VALVE_KIND_VALVE, + translation_key="valve_controller", + device_class=ValveDeviceClass.WATER, + api_category=API_VALVE_STATUS, + extra_state_attributes_fn=lambda data: { + ATTR_AVG_CURRENT: data["average_current"], + ATTR_INST_CURRENT: data["instantaneous_current"], + ATTR_INST_CURRENT_DDT: data["instantaneous_current_ddt"], + ATTR_TRAVEL_COUNT: data["travel_count"], + }, + is_closed_fn=lambda data: data["state"] == GuardianValveState.CLOSED, + is_closing_fn=is_closing, + is_opening_fn=is_opening, + close_coro_fn=async_close_valve, + halt_coro_fn=async_halt_valve, + open_coro_fn=async_open_valve, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Guardian switches based on a config entry.""" + data: GuardianData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ValveControllerValve(entry, data, description) + for description in VALVE_CONTROLLER_DESCRIPTIONS + ) + + +class ValveControllerValve(ValveControllerEntity, ValveEntity): + """Define a switch related to a Guardian valve controller.""" + + _attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE | ValveEntityFeature.STOP + ) + entity_description: ValveControllerValveDescription + + def __init__( + self, + entry: ConfigEntry, + data: GuardianData, + description: ValveControllerValveDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, data.valve_controller_coordinators, description) + + self._client = data.client + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) + + @property + def is_closing(self) -> bool: + """Return if the valve is closing or not.""" + return self.entity_description.is_closing_fn(self.coordinator.data) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self.entity_description.is_closed_fn(self.coordinator.data) + + @property + def is_opening(self) -> bool: + """Return if the valve is opening or not.""" + return self.entity_description.is_opening_fn(self.coordinator.data) + + @convert_exceptions_to_homeassistant_error + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.entity_description.close_coro_fn(self._client) + await self.coordinator.async_request_refresh() + + @convert_exceptions_to_homeassistant_error + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.entity_description.open_coro_fn(self._client) + await self.coordinator.async_request_refresh() + + @convert_exceptions_to_homeassistant_error + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.entity_description.halt_coro_fn(self._client) + await self.coordinator.async_request_refresh() From b5bd91096359c9cd7f070bac8015b2ae084d6488 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 10 Jan 2024 19:38:56 -0500 Subject: [PATCH 0473/1544] Bump pyunifiprotect to 4.23.1 (#107758) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2fbf8f31071..d7c501a0bec 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.23.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 38cd1f2f727..5ebb60ce0ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2292,7 +2292,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.5 +pyunifiprotect==4.23.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2df8cf9d97..66cd22f8fbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1741,7 +1741,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.5 +pyunifiprotect==4.23.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 0ae86095d2f44ee7da686e54d7d6dc3436f01ed3 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Thu, 11 Jan 2024 01:48:37 +0100 Subject: [PATCH 0474/1544] Bump bthome-ble to 3.4.1 (#107757) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 10 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 17 +++++++++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index be64f01966f..53d25ce4c96 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.3.1"] + "requirements": ["bthome-ble==3.4.1"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 10ba292d20c..eb3a177804c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -337,6 +337,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), + # Volume Storage (L) + ( + BTHomeExtendedSensorDeviceClass.VOLUME_STORAGE, + Units.VOLUME_LITERS, + ): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.VOLUME_STORAGE}_{Units.VOLUME_LITERS}", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), # Water (L) ( BTHomeSensorDeviceClass.WATER, diff --git a/requirements_all.txt b/requirements_all.txt index 5ebb60ce0ff..4cac7997a24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.3.1 +bthome-ble==3.4.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66cd22f8fbf..029d7d10a06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -508,7 +508,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.3.1 +bthome-ble==3.4.1 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 0b6e7a42cfb..0220bf59d2c 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -972,6 +972,23 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x55\x87\x56\x2a\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_volume_storage", + "friendly_name": "Test Device 18B2 Volume Storage", + "unit_of_measurement": "L", + "state_class": "measurement", + "expected_state": "19551.879", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From 28b5104cda4169cac1087f09c6cb8272ada78914 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 11 Jan 2024 02:50:45 +0200 Subject: [PATCH 0475/1544] Bump aioshelly to 7.1.0 (#107593) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b56ce07bc30..82833bf34af 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==7.0.0"], + "requirements": ["aioshelly==7.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 4cac7997a24..75dbd72bb8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.0.0 +aioshelly==7.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029d7d10a06..fbd3528e07f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -332,7 +332,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.0.0 +aioshelly==7.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From e595d24d784afa092e1beebf49809a3993920866 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 10 Jan 2024 20:32:05 -0500 Subject: [PATCH 0476/1544] Add leak sensor for UP Sense for UniFi Protect (#107762) --- .../components/unifiprotect/binary_sensor.py | 7 +++++ .../unifiprotect/test_binary_sensor.py | 28 ++++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 1104ecb98e1..8cb3311a40d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -259,6 +259,13 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_opened", ufp_enabled="is_contact_sensor_enabled", ), + ProtectBinaryEntityDescription( + key="leak", + name="Leak", + device_class=BinarySensorDeviceClass.MOISTURE, + ufp_value="is_leak_detected", + ufp_enabled="is_leak_sensor_enabled", + ), ProtectBinaryEntityDescription( key="battery_low", name="Battery low", diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 4b86d4912c1..f07c86d64c2 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -78,11 +78,11 @@ async def test_binary_sensor_sensor_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 5, 5) await remove_entities(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) await adopt_devices(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 5, 5) async def test_binary_sensor_setup_light( @@ -201,11 +201,18 @@ async def test_binary_sensor_setup_sensor( """Test binary_sensor entity setup for sensor devices.""" await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) entity_registry = er.async_get(hass) - for description in SENSE_SENSORS_WRITE: + expected = [ + STATE_OFF, + STATE_UNAVAILABLE, + STATE_OFF, + STATE_OFF, + STATE_OFF, + ] + for index, description in enumerate(SENSE_SENSORS_WRITE): unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, description ) @@ -216,24 +223,25 @@ async def test_binary_sensor_setup_sensor( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OFF + assert state.state == expected[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_binary_sensor_setup_sensor_none( +async def test_binary_sensor_setup_sensor_leak( hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor ) -> None: - """Test binary_sensor entity setup for sensor with most sensors disabled.""" + """Test binary_sensor entity setup for sensor with most leak mounting type.""" sensor.mount_type = MountType.LEAK await init_entry(hass, ufp, [sensor]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) entity_registry = er.async_get(hass) expected = [ STATE_UNAVAILABLE, STATE_OFF, + STATE_OFF, STATE_UNAVAILABLE, STATE_OFF, ] @@ -348,7 +356,7 @@ async def test_binary_sensor_update_mount_type_window( """Test binary_sensor motion entity.""" await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] @@ -380,7 +388,7 @@ async def test_binary_sensor_update_mount_type_garage( """Test binary_sensor motion entity.""" await init_entry(hass, ufp, [sensor_all]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] From 0bdbb526948e5d7197b556abfeec483630274b57 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 10 Jan 2024 22:00:34 -0600 Subject: [PATCH 0477/1544] Bump sonos-websocket to 0.1.3 (#107765) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 0e1a1d7daa4..8ad6bf322bf 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.0", "sonos-websocket==0.1.2"], + "requirements": ["soco==0.30.0", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 75dbd72bb8d..23c8aa0f3a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2533,7 +2533,7 @@ solax==0.3.2 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.2 +sonos-websocket==0.1.3 # homeassistant.components.marytts speak2mary==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbd3528e07f..0e4515db6a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1913,7 +1913,7 @@ solax==0.3.2 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.2 +sonos-websocket==0.1.3 # homeassistant.components.marytts speak2mary==1.4.0 From 28cdf5f1d2def73469b6c8425a720a6d069ef07b Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Wed, 10 Jan 2024 20:04:15 -0800 Subject: [PATCH 0478/1544] Bump aioambient to 2024.01.0 (#107767) --- homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index ebd03651064..046ab9f73e9 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["aioambient"], - "requirements": ["aioambient==2023.04.0"] + "requirements": ["aioambient==2024.01.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23c8aa0f3a2..7659ed33801 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -194,7 +194,7 @@ aioairzone-cloud==0.3.8 aioairzone==0.7.2 # homeassistant.components.ambient_station -aioambient==2023.04.0 +aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e4515db6a0..278d4eb3e4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.3.8 aioairzone==0.7.2 # homeassistant.components.ambient_station -aioambient==2023.04.0 +aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 From 335a1f6e0942d78e8a7cb619159f29b63ee6773c Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 10 Jan 2024 23:04:37 -0500 Subject: [PATCH 0479/1544] Bump pyunifiprotect to 4.23.2 (#107769) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index d7c501a0bec..edb2e28cc88 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.23.1", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.23.2", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 7659ed33801..69ecc88aa44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2292,7 +2292,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.23.1 +pyunifiprotect==4.23.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 278d4eb3e4f..71dfc925968 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1741,7 +1741,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.23.1 +pyunifiprotect==4.23.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From ec8a33b52d1fe20553d72f126d94cafedf7c1ea3 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 10 Jan 2024 23:06:45 -0500 Subject: [PATCH 0480/1544] Rework state change detection for UniFi Protect entities (#107766) --- .../components/unifiprotect/binary_sensor.py | 58 +++++++++-------- .../components/unifiprotect/button.py | 21 ------- .../components/unifiprotect/camera.py | 16 ++++- .../components/unifiprotect/entity.py | 30 ++++++++- .../components/unifiprotect/light.py | 10 +++ homeassistant/components/unifiprotect/lock.py | 16 +++++ .../components/unifiprotect/media_player.py | 33 ++-------- .../components/unifiprotect/number.py | 30 +++------ .../components/unifiprotect/select.py | 33 ++-------- .../components/unifiprotect/sensor.py | 63 ++++++++----------- .../components/unifiprotect/switch.py | 29 ++------- homeassistant/components/unifiprotect/text.py | 11 ++++ .../unifiprotect/test_binary_sensor.py | 2 +- tests/components/unifiprotect/utils.py | 10 +++ 14 files changed, 173 insertions(+), 189 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 8cb3311a40d..5aff0b732df 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses import logging +from typing import Any from pyunifiprotect.data import ( NVR, @@ -573,6 +574,16 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): else: self._attr_device_class = self.entity_description.device_class + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_is_on, self._attr_device_class) + class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): """A UniFi Protect NVR Disk Binary Sensor.""" @@ -617,6 +628,16 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): self._attr_is_on = not self._disk.is_healthy + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_is_on) + class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): """A UniFi Protect Device Binary Sensor for events.""" @@ -633,32 +654,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): self._attr_extra_state_attributes = {} @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the is_on, _attr_extra_state_attributes, and available are ever - updated for these entities, and since the websocket update for the - device will trigger an update for all entities connected to the device, - we want to avoid writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_is_on = self._attr_is_on - previous_available = self._attr_available - previous_extra_state_attributes = self._attr_extra_state_attributes - self._async_update_device_from_protect(device) - if ( - self._attr_is_on != previous_is_on - or self._attr_extra_state_attributes != previous_extra_state_attributes - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", - device.name, - device.mac, - previous_is_on, - previous_available, - previous_extra_state_attributes, - self._attr_is_on, - self._attr_available, - self._attr_extra_state_attributes, - ) - self.async_write_ha_state() + + return ( + self._attr_available, + self._attr_is_on, + self._attr_extra_state_attributes, + ) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index b69fbb95970..c0872e03f03 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -193,24 +193,3 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() - - @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. - - Only available is updated for these entities, and since the websocket - update for the device will trigger an update for all entities connected - to the device, we want to avoid writing state unless something has - actually changed. - """ - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if self._attr_available != previous_available: - _LOGGER.debug( - "Updating state [%s (%s)] %s -> %s", - device.name, - device.mac, - previous_available, - self._attr_available, - ) - self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index dc579ad6b7c..6d82e2fc989 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator import logging -from typing import cast +from typing import Any, cast from pyunifiprotect.data import ( Camera as UFPCamera, @@ -181,6 +181,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): else: self._attr_supported_features = CameraEntityFeature(0) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_is_recording, + self._attr_motion_detection_enabled, + ) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 00160005fe0..59c716d4aa4 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -255,11 +255,37 @@ class ProtectDeviceEntity(Entity): and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) ) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available,) + @callback def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data.""" + """When device is updated from Protect.""" + + previous_attrs = self._async_get_state_attrs() self._async_update_device_from_protect(device) - self.async_write_ha_state() + current_attrs = self._async_get_state_attrs() + if previous_attrs != current_attrs: + if _LOGGER.isEnabledFor(logging.DEBUG): + device_name = device.name + if hasattr(self, "entity_description") and self.entity_description.name: + device_name += f" {self.entity_description.name}" + + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device_name, + device.mac, + previous_attrs, + current_attrs, + ) + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 6cc56009cea..485e715ea39 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -70,6 +70,16 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_brightness) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 2b7ce4f1147..57ade8ad220 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -70,6 +70,22 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): self._attr_name = f"{self.device.display_name} Lock" + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_is_locked, + self._attr_is_locking, + self._attr_is_unlocking, + self._attr_is_jammed, + ) + @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 5daac033048..e0f247eef72 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -116,35 +116,14 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self._attr_available = is_connected and updated_device.feature_flags.has_speaker @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the state, volume, and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_state = self._attr_state - previous_available = self._attr_available - previous_volume_level = self._attr_volume_level - self._async_update_device_from_protect(device) - if ( - self._attr_state != previous_state - or self._attr_volume_level != previous_volume_level - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", - device.name, - device.mac, - previous_state, - previous_available, - previous_volume_level, - self._attr_state, - self._attr_available, - self._attr_volume_level, - ) - self.async_write_ha_state() + + return (self._attr_available, self._attr_state, self._attr_volume_level) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index c02753a9401..04f779ecbd7 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from pyunifiprotect.data import ( Camera, @@ -273,28 +274,11 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): await self.entity_description.ufp_set(self.device, value) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_native_value != previous_value - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s) -> %s (%s)", - device.name, - device.mac, - previous_value, - previous_available, - self._attr_native_value, - self._attr_available, - ) - self.async_write_ha_state() + + return (self._attr_available, self._attr_native_value) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index ecbc22f5787..a3688916959 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -403,32 +403,11 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): await self.entity_description.ufp_set(self.device, unifi_value) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the options, option, and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_option = self._attr_current_option - previous_options = self._attr_options - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_current_option != previous_option - or self._attr_options != previous_options - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", - device.name, - device.mac, - previous_option, - previous_available, - previous_options, - self._attr_current_option, - self._attr_available, - self._attr_options, - ) - self.async_write_ha_state() + + return (self._attr_available, self._attr_options, self._attr_current_option) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 3e2bd6ee858..3b9dfbd1f83 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -715,31 +715,14 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): self._attr_native_value = self.entity_description.get_ufp_value(self.device) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_native_value != previous_value - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s) -> %s (%s)", - device.name, - device.mac, - previous_value, - previous_available, - self._attr_native_value, - self._attr_available, - ) - self.async_write_ha_state() + + return (self._attr_available, self._attr_native_value) class ProtectNVRSensor(ProtectNVREntity, SensorEntity): @@ -752,22 +735,14 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity): self._attr_native_value = self.entity_description.get_ufp_value(self.device) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the native value and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_value = self._attr_native_value - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_native_value != previous_value - or self._attr_available != previous_available - ): - self.async_write_ha_state() + + return (self._attr_available, self._attr_native_value) class ProtectEventSensor(EventEntityMixin, SensorEntity): @@ -803,3 +778,17 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): self._attr_native_value = event.metadata.license_plate.name # type: ignore[union-attr] else: self._attr_native_value = event.smart_detect_types[0].value + + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return ( + self._attr_available, + self._attr_native_value, + self._attr_extra_state_attributes, + ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index d8a3fc1c5bc..324fe56cea2 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -445,31 +445,14 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): await self.entity_description.ufp_set(self.device, False) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: - """Call back for incoming data that only writes when state has changed. + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. - Only the is_on and available are ever updated for these - entities, and since the websocket update for the device will trigger - an update for all entities connected to the device, we want to avoid - writing state unless something has actually changed. + Called before and after updating entity and state is only written if there + is a change. """ - previous_is_on = self._attr_is_on - previous_available = self._attr_available - self._async_update_device_from_protect(device) - if ( - self._attr_is_on != previous_is_on - or self._attr_available != previous_available - ): - _LOGGER.debug( - "Updating state [%s (%s)] %s (%s) -> %s (%s)", - device.name, - device.mac, - previous_is_on, - previous_available, - self._attr_is_on, - self._attr_available, - ) - self.async_write_ha_state() + + return (self._attr_available, self._attr_is_on) class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index de777121ff5..7fb66d7c8e3 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from pyunifiprotect.data import ( Camera, @@ -101,6 +102,16 @@ class ProtectDeviceText(ProtectDeviceEntity, TextEntity): super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_get_state_attrs(self) -> tuple[Any, ...]: + """Retrieve data that goes into the current state of the entity. + + Called before and after updating entity and state is only written if there + is a change. + """ + + return (self._attr_available, self._attr_native_value) + async def async_set_value(self, value: str) -> None: """Change the value.""" diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index f07c86d64c2..2c6a7c90065 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -387,7 +387,7 @@ async def test_binary_sensor_update_mount_type_garage( ) -> None: """Test binary_sensor motion entity.""" - await init_entry(hass, ufp, [sensor_all]) + await init_entry(hass, ufp, [sensor_all], debug=True) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 2a0a0eb0655..1ade39dafca 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -25,6 +25,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -161,6 +162,7 @@ async def init_entry( ufp: MockUFPFixture, devices: Sequence[ProtectAdoptableDeviceModel], regenerate_ids: bool = True, + debug: bool = False, ) -> None: """Initialize Protect entry with given devices.""" @@ -168,6 +170,14 @@ async def init_entry( for device in devices: add_device(ufp.api.bootstrap, device, regenerate_ids) + if debug: + assert await async_setup_component(hass, "logger", {"logger": {}}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.unifiprotect": "DEBUG"}, + blocking=True, + ) await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() From 99e25d94c0417f3ec96b47c5f8b638257dc370c5 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Thu, 11 Jan 2024 00:02:16 -0500 Subject: [PATCH 0481/1544] Rework events for UniFi Protect (#107771) --- .../components/unifiprotect/binary_sensor.py | 53 ++++++++----------- .../components/unifiprotect/models.py | 12 +---- .../components/unifiprotect/sensor.py | 4 +- .../components/unifiprotect/switch.py | 36 ++++++------- tests/components/unifiprotect/test_switch.py | 14 ++--- 5 files changed, 52 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 5aff0b732df..e156a60c787 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -174,15 +174,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_vehicle_detection_on", ufp_perm=PermRequired.NO_WRITE, ), - ProtectBinaryEntityDescription( - key="smart_face", - name="Detections: Face", - icon="mdi:mdi-face", - entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_face", - ufp_value="is_face_detection_on", - ufp_perm=PermRequired.NO_WRITE, - ), ProtectBinaryEntityDescription( key="smart_package", name="Detections: Package", @@ -203,13 +194,22 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_smoke", - name="Detections: Smoke/CO", + name="Detections: Smoke", icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_smoke", ufp_value="is_smoke_detection_on", ufp_perm=PermRequired.NO_WRITE, ), + ProtectBinaryEntityDescription( + key="smart_cmonx", + name="Detections: CO", + icon="mdi:molecule-co", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_co", + ufp_value="is_co_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -350,7 +350,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_motion_detected", + ufp_value="is_motion_currently_detected", ufp_enabled="is_motion_detection_on", ufp_event_obj="last_motion_event", ), @@ -358,7 +358,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_any", name="Object Detected", icon="mdi:eye", - ufp_value="is_smart_detected", + ufp_value="is_smart_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", ), @@ -366,7 +366,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_person", name="Person Detected", icon="mdi:walk", - ufp_value="is_smart_detected", + ufp_value="is_person_currently_detected", ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", ufp_event_obj="last_person_detect_event", @@ -375,25 +375,16 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_vehicle", name="Vehicle Detected", icon="mdi:car", - ufp_value="is_smart_detected", + ufp_value="is_vehicle_currently_detected", ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", ufp_event_obj="last_vehicle_detect_event", ), - ProtectBinaryEventEntityDescription( - key="smart_obj_face", - name="Face Detected", - icon="mdi:mdi-face", - ufp_value="is_smart_detected", - ufp_required_field="can_detect_face", - ufp_enabled="is_face_detection_on", - ufp_event_obj="last_face_detect_event", - ), ProtectBinaryEventEntityDescription( key="smart_obj_package", name="Package Detected", icon="mdi:package-variant-closed", - ufp_value="is_smart_detected", + ufp_value="is_package_currently_detected", ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", ufp_event_obj="last_package_detect_event", @@ -402,7 +393,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_any", name="Audio Object Detected", icon="mdi:eye", - ufp_value="is_smart_detected", + ufp_value="is_audio_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", ), @@ -410,7 +401,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_smoke", name="Smoke Alarm Detected", icon="mdi:fire", - ufp_value="is_smart_detected", + ufp_value="is_smoke_currently_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", ufp_event_obj="last_smoke_detect_event", @@ -418,10 +409,10 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", name="CO Alarm Detected", - icon="mdi:fire", - ufp_value="is_smart_detected", - ufp_required_field="can_detect_smoke", - ufp_enabled="is_smoke_detection_on", + icon="mdi:molecule-co", + ufp_value="is_cmonx_currently_detected", + ufp_required_field="can_detect_co", + ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", ), ) @@ -647,7 +638,7 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - is_on = self.entity_description.get_is_on(self._event) + is_on = self.entity_description.get_is_on(self.device, self._event) self._attr_is_on: bool | None = is_on if not is_on: self._event = None diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 7f5612a72a8..08f5c2075e6 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription -from homeassistant.util import dt as dt_util from .utils import get_nested_attr @@ -114,17 +113,10 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return cast(Event, getattr(obj, self.ufp_event_obj, None)) return None - def get_is_on(self, event: Event | None) -> bool: + def get_is_on(self, obj: T, event: Event | None) -> bool: """Return value if event is active.""" - if event is None: - return False - now = dt_util.utcnow() - value = now > event.start - if value and event.end is not None and now > event.end: - value = False - - return value + return event is not None and self.get_ufp_value(obj) @dataclass(frozen=True) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 3b9dfbd1f83..212c0d5245b 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -527,7 +527,7 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License Plate Detected", icon="mdi:car", translation_key="license_plate", - ufp_value="is_smart_detected", + ufp_value="is_license_plate_currently_detected", ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", ), @@ -756,7 +756,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): EventEntityMixin._async_update_device_from_protect(self, device) event = self._event entity_description = self.entity_description - is_on = entity_description.get_is_on(event) + is_on = entity_description.get_is_on(self.device, self._event) is_license_plate = ( entity_description.ufp_event_obj == "last_license_plate_detect_event" ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 324fe56cea2..f3224e086a5 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -135,6 +135,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_osd_bitrate", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="color_night_vision", + name="Color Night Vision", + icon="mdi:light-flood-down", + entity_category=EntityCategory.CONFIG, + ufp_required_field="has_color_night_vision", + ufp_value="isp_settings.is_color_night_vision_enabled", + ufp_set_method="set_color_night_vision", + ufp_perm=PermRequired.WRITE, + ), ProtectSwitchEntityDescription( key="motion", name="Detections: Motion", @@ -167,17 +177,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_vehicle_detection", ufp_perm=PermRequired.WRITE, ), - ProtectSwitchEntityDescription( - key="smart_face", - name="Detections: Face", - icon="mdi:human-greeting", - entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_face", - ufp_value="is_face_detection_on", - ufp_enabled="is_recording_enabled", - ufp_set_method="set_face_detection", - ufp_perm=PermRequired.WRITE, - ), ProtectSwitchEntityDescription( key="smart_package", name="Detections: Package", @@ -202,7 +201,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_smoke", - name="Detections: Smoke/CO", + name="Detections: Smoke", icon="mdi:fire", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", @@ -212,13 +211,14 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( - key="color_night_vision", - name="Color Night Vision", - icon="mdi:light-flood-down", + key="smart_cmonx", + name="Detections: CO", + icon="mdi:molecule-co", entity_category=EntityCategory.CONFIG, - ufp_required_field="has_color_night_vision", - ufp_value="isp_settings.is_color_night_vision_enabled", - ufp_set_method="set_color_night_vision", + ufp_required_field="can_detect_co", + ufp_value="is_co_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_cmonx_detection", ufp_perm=PermRequired.WRITE, ), ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 17db53d05ec..70a21a324d0 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -33,12 +33,14 @@ from .utils import ( CAMERA_SWITCHES_BASIC = [ d for d in CAMERA_SWITCHES - if d.name != "Detections: Face" - and d.name != "Detections: Package" - and d.name != "Detections: License Plate" - and d.name != "Detections: Smoke/CO" - and d.name != "SSH Enabled" - and d.name != "Color Night Vision" + if ( + not d.name.startswith("Detections:") + and d.name != "SSH Enabled" + and d.name != "Color Night Vision" + ) + or d.name == "Detections: Motion" + or d.name == "Detections: Person" + or d.name == "Detections: Vehicle" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC if d.name not in ("High FPS", "Privacy Mode") From b83f5b5932e248349763bc025d3808684f7e444d Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Thu, 11 Jan 2024 00:23:59 -0500 Subject: [PATCH 0482/1544] Add new event sensors from UniFi Protect 2.11 (#107773) --- .../components/unifiprotect/binary_sensor.py | 126 ++++++++++++++++++ .../components/unifiprotect/switch.py | 77 +++++++++++ 2 files changed, 203 insertions(+) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index e156a60c787..d5baaa3b5bf 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -210,6 +210,69 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_co_detection_on", ufp_perm=PermRequired.NO_WRITE, ), + ProtectBinaryEntityDescription( + key="smart_siren", + name="Detections: Siren", + icon="mdi:alarm-bell", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_siren", + ufp_value="is_siren_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_baby_cry", + name="Detections: Baby Cry", + icon="mdi:cradle", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_baby_cry", + ufp_value="is_baby_cry_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_speak", + name="Detections: Speaking", + icon="mdi:account-voice", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_speaking", + ufp_value="is_speaking_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_bark", + name="Detections: Barking", + icon="mdi:dog", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_bark", + ufp_value="is_bark_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_car_alarm", + name="Detections: Car Alarm", + icon="mdi:car", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_car_alarm", + ufp_value="is_car_alarm_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_car_horn", + name="Detections: Car Horn", + icon="mdi:bugle", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_car_horn", + ufp_value="is_car_horn_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_glass_break", + name="Detections: Glass Break", + icon="mdi:glass-fragile", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_glass_break", + ufp_value="is_glass_break_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -415,6 +478,69 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", ), + ProtectBinaryEventEntityDescription( + key="smart_audio_siren", + name="Siren Detected", + icon="mdi:alarm-bell", + ufp_value="is_siren_currently_detected", + ufp_required_field="can_detect_siren", + ufp_enabled="is_siren_detection_on", + ufp_event_obj="last_siren_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_baby_cry", + name="Baby Cry Detected", + icon="mdi:cradle", + ufp_value="is_baby_cry_currently_detected", + ufp_required_field="can_detect_baby_cry", + ufp_enabled="is_baby_cry_detection_on", + ufp_event_obj="last_baby_cry_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_speak", + name="Speaking Detected", + icon="mdi:account-voice", + ufp_value="is_speaking_currently_detected", + ufp_required_field="can_detect_speaking", + ufp_enabled="is_speaking_detection_on", + ufp_event_obj="last_speaking_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_bark", + name="Barking Detected", + icon="mdi:dog", + ufp_value="is_bark_currently_detected", + ufp_required_field="can_detect_bark", + ufp_enabled="is_bark_detection_on", + ufp_event_obj="last_bark_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_car_alarm", + name="Car Alarm Detected", + icon="mdi:car", + ufp_value="is_car_alarm_currently_detected", + ufp_required_field="can_detect_car_alarm", + ufp_enabled="is_car_alarm_detection_on", + ufp_event_obj="last_car_alarm_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_car_horn", + name="Car Horn Detected", + icon="mdi:bugle", + ufp_value="is_car_horn_currently_detected", + ufp_required_field="can_detect_car_horn", + ufp_enabled="is_car_horn_detection_on", + ufp_event_obj="last_car_horn_detect_event", + ), + ProtectBinaryEventEntityDescription( + key="smart_audio_glass_break", + name="Glass Break Detected", + icon="mdi:glass-fragile", + ufp_value="last_glass_break_detect", + ufp_required_field="can_detect_glass_break", + ufp_enabled="is_glass_break_detection_on", + ufp_event_obj="last_glass_break_detect_event", + ), ) DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f3224e086a5..57089157169 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -221,6 +221,83 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_cmonx_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="smart_siren", + name="Detections: Siren", + icon="mdi:alarm-bell", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_siren", + ufp_value="is_siren_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_siren_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_baby_cry", + name="Detections: Baby Cry", + icon="mdi:cradle", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_baby_cry", + ufp_value="is_baby_cry_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_baby_cry_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_speak", + name="Detections: Speaking", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_speaking", + ufp_value="is_speaking_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_speaking_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_bark", + name="Detections: Barking", + icon="mdi:dog", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_bark", + ufp_value="is_bark_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_bark_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_car_alarm", + name="Detections: Car Alarm", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_car_alarm", + ufp_value="is_car_alarm_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_car_alarm_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_car_horn", + name="Detections: Car Horn", + icon="mdi:bugle", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_car_horn", + ufp_value="is_car_horn_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_car_horn_detection", + ufp_perm=PermRequired.WRITE, + ), + ProtectSwitchEntityDescription( + key="smart_glass_break", + name="Detections: Glass Break", + icon="mdi:glass-fragile", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_glass_break", + ufp_value="is_glass_break_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_glass_break_detection", + ufp_perm=PermRequired.WRITE, + ), ) PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( From e0457590d167819c5636220b81b447dc07fb5f9a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 11 Jan 2024 07:17:48 +0100 Subject: [PATCH 0483/1544] Fix mqtt text text min max config params can not be equal (#107738) Fix mqtt text text min max kan not be equal --- homeassistant/components/mqtt/text.py | 4 +- tests/components/mqtt/test_text.py | 59 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index da93a6b619e..fb121c25a9c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -70,8 +70,8 @@ MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset( def valid_text_size_configuration(config: ConfigType) -> ConfigType: """Validate that the text length configuration is valid, throws if it isn't.""" - if config[CONF_MIN] >= config[CONF_MAX]: - raise vol.Invalid("text length min must be >= max") + if config[CONF_MIN] > config[CONF_MAX]: + raise vol.Invalid("text length min must be <= max") if config[CONF_MAX] > MAX_LENGTH_STATE_STATE: raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index a602f1e3065..3aa2f96f478 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -115,6 +115,63 @@ async def test_controlling_state_via_topic( assert state.state == "" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + text.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "min": 5, + "max": 5, + } + } + } + ], +) +async def test_forced_text_length( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a text entity that only allows a fixed length.""" + await mqtt_mock_entry() + + state = hass.states.get("text.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "12345") + state = hass.states.get("text.test") + assert state.state == "12345" + + caplog.clear() + # Text too long + async_fire_mqtt_message(hass, "state-topic", "123456") + state = hass.states.get("text.test") + assert state.state == "12345" + assert ( + "ValueError: Entity text.test provides state 123456 " + "which is too long (maximum length 5)" in caplog.text + ) + + caplog.clear() + # Text too short + async_fire_mqtt_message(hass, "state-topic", "1") + state = hass.states.get("text.test") + assert state.state == "12345" + assert ( + "ValueError: Entity text.test provides state 1 " + "which is too short (minimum length 5)" in caplog.text + ) + # Valid update + async_fire_mqtt_message(hass, "state-topic", "54321") + state = hass.states.get("text.test") + assert state.state == "54321" + + @pytest.mark.parametrize( "hass_config", [ @@ -211,7 +268,7 @@ async def test_attribute_validation_max_greater_then_min( ) -> None: """Test the validation of min and max configuration attributes.""" assert await mqtt_mock_entry() - assert "text length min must be >= max" in caplog.text + assert "text length min must be <= max" in caplog.text @pytest.mark.parametrize( From b08832a89aacc83a2d3e1dfce00d9bf709781d54 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 11 Jan 2024 08:27:15 +0100 Subject: [PATCH 0484/1544] Fastdotcom service optimization (#107179) * Startup mechanic * Workable service (again) * Optimized version, for now * Minor refactoring * Test cases * Fixing test case * Adding startup comment * State_unknown added * Update homeassistant/components/fastdotcom/services.py Co-authored-by: Martin Hjelmare * Check if config entries are not found * Update tests/components/fastdotcom/test_service.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/fastdotcom/services.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/fastdotcom/services.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/fastdotcom/__init__.py | 43 +++------ homeassistant/components/fastdotcom/const.py | 2 + .../components/fastdotcom/services.py | 51 +++++++++++ tests/components/fastdotcom/test_init.py | 16 ++-- tests/components/fastdotcom/test_service.py | 87 +++++++++++++++++++ 5 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/fastdotcom/services.py create mode 100644 tests/components/fastdotcom/test_service.py diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 165d81edd0b..ada717a6dac 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -6,14 +6,15 @@ import logging import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall -from homeassistant.helpers import issue_registry as ir +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS from .coordinator import FastdotcomDataUpdateCoordindator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Fast.com component. (deprecated).""" + """Set up the Fastdotcom component.""" if DOMAIN in config: hass.async_create_task( hass.config_entries.flow.async_init( @@ -42,51 +43,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: data=config[DOMAIN], ) ) + async_setup_services(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fast.com from a config entry.""" coordinator = FastdotcomDataUpdateCoordindator(hass) - - async def _request_refresh(event: Event) -> None: - """Request a refresh.""" - await coordinator.async_request_refresh() - - async def _request_refresh_service(call: ServiceCall) -> None: - """Request a refresh via the service.""" - ir.async_create_issue( - hass, - DOMAIN, - "service_deprecation", - breaks_in_ha_version="2024.7.0", - is_fixable=True, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation", - ) - await coordinator.async_request_refresh() - - if hass.state == CoreState.running: - await coordinator.async_config_entry_first_refresh() - else: - # Don't start the speedtest when HA is starting up - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - hass.services.async_register(DOMAIN, "speedtest", _request_refresh_service) await hass.config_entries.async_forward_entry_setups( entry, PLATFORMS, ) + async def _async_finish_startup(hass: HomeAssistant) -> None: + """Run this only when HA has finished its startup.""" + await coordinator.async_config_entry_first_refresh() + + # Don't start a speedtest during startup, this will slow down the overall startup dramatically + async_at_started(hass, _async_finish_startup) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Fast.com config entry.""" - hass.services.async_remove(DOMAIN, "speedtest") if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/fastdotcom/const.py b/homeassistant/components/fastdotcom/const.py index 753825c4361..340be6f50ae 100644 --- a/homeassistant/components/fastdotcom/const.py +++ b/homeassistant/components/fastdotcom/const.py @@ -10,6 +10,8 @@ DATA_UPDATED = f"{DOMAIN}_data_updated" CONF_MANUAL = "manual" +SERVICE_NAME = "speedtest" + DEFAULT_NAME = "Fast.com" DEFAULT_INTERVAL = 1 PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/fastdotcom/services.py b/homeassistant/components/fastdotcom/services.py new file mode 100644 index 00000000000..d1a9ee2125b --- /dev/null +++ b/homeassistant/components/fastdotcom/services.py @@ -0,0 +1,51 @@ +"""Services for the Fastdotcom integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, SERVICE_NAME +from .coordinator import FastdotcomDataUpdateCoordindator + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the service for the Fastdotcom integration.""" + + @callback + def collect_coordinator() -> FastdotcomDataUpdateCoordindator: + """Collect the coordinator Fastdotcom.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + if not config_entries: + raise HomeAssistantError("No Fast.com config entries found") + + for config_entry in config_entries: + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError(f"{config_entry.title} is not loaded") + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][ + config_entry.entry_id + ] + break + return coordinator + + async def async_perform_service(call: ServiceCall) -> None: + """Perform a service call to manually run Fastdotcom.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + coordinator = collect_coordinator() + await coordinator.async_request_refresh() + + hass.services.async_register( + DOMAIN, + SERVICE_NAME, + async_perform_service, + ) diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py index 0acaddf36fc..dc61acb620e 100644 --- a/tests/components/fastdotcom/test_init.py +++ b/tests/components/fastdotcom/test_init.py @@ -65,23 +65,25 @@ async def test_delayed_speedtest_during_startup( config_entry.add_to_hass(hass) with patch( - "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + "homeassistant.components.fastdotcom.coordinator.fast_com" ), patch.object(hass, "state", CoreState.starting): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == config_entries.ConfigEntryState.LOADED state = hass.states.get("sensor.fast_com_download") - assert state is not None - # Assert state is unknown as coordinator is not allowed to start and fetch data yet - assert state.state == STATE_UNKNOWN + # Assert state is Unknown as fast.com isn't starting until HA has started + assert state.state is STATE_UNKNOWN - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() state = hass.states.get("sensor.fast_com_download") assert state is not None - assert state.state == "0" + assert state.state == "5.0" assert config_entry.state == config_entries.ConfigEntryState.LOADED diff --git a/tests/components/fastdotcom/test_service.py b/tests/components/fastdotcom/test_service.py new file mode 100644 index 00000000000..2f919bc8a84 --- /dev/null +++ b/tests/components/fastdotcom/test_service.py @@ -0,0 +1,87 @@ +"""Test Fastdotcom service.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN, SERVICE_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def test_service(hass: HomeAssistant) -> None: + """Test the Fastdotcom service.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + +async def test_service_unloaded_entry(hass: HomeAssistant) -> None: + """Test service called when config entry unloaded.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry + await config_entry.async_unload(hass) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + assert "Fast.com is not loaded" in str(exc) + + +async def test_service_removed_entry(hass: HomeAssistant) -> None: + """Test service called when config entry was removed and HA was not restarted yet.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry + await hass.config_entries.async_remove(config_entry.entry_id) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) + + assert "No Fast.com config entries found" in str(exc) From 1c669c6e848cf72d0fbe0f88cc0088660a8feaf1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 11 Jan 2024 10:37:19 +0100 Subject: [PATCH 0485/1544] Revert "Revert "Add preselect_remember_me to `/auth/providers`"" (#106867) --- homeassistant/components/auth/login_flow.py | 10 +- homeassistant/components/person/__init__.py | 21 ---- tests/components/auth/test_login_flow.py | 122 ++++++++------------ tests/components/person/test_init.py | 30 +---- 4 files changed, 57 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 9b96e57dbd3..cc6cb5fc47a 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -91,6 +91,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local from . import indieauth @@ -185,7 +186,14 @@ class AuthProvidersView(HomeAssistantView): } ) - return self.json(providers) + preselect_remember_me = not cloud_connection and is_local(remote_address) + + return self.json( + { + "providers": providers, + "preselect_remember_me": preselect_remember_me, + } + ) def _prepare_result_json( diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index c796cb8d843..49b719a5490 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,11 +1,9 @@ """Support for tracking people.""" from __future__ import annotations -from http import HTTPStatus import logging from typing import Any -from aiohttp import web import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED @@ -15,7 +13,6 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) -from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import ( ATTR_EDITABLE, ATTR_ENTITY_ID, @@ -388,8 +385,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_RELOAD, async_reload_yaml ) - hass.http.register_view(ListPersonsView) - return True @@ -574,19 +569,3 @@ def _get_latest(prev: State | None, curr: State): if prev is None or curr.last_updated > prev.last_updated: return curr return prev - - -class ListPersonsView(HomeAssistantView): - """List all persons if request is made from a local network.""" - - requires_auth = False - url = "/api/person/list" - name = "api:person:list" - - async def get(self, request: web.Request) -> web.Response: - """Return a list of persons if request comes from a local IP.""" - return self.json_message( - message="Not local", - status_code=HTTPStatus.BAD_REQUEST, - message_code="not_local", - ) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 27652ca2be4..c8b0261b79c 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from . import BASE_CONFIG, async_setup_auth @@ -26,22 +25,30 @@ _TRUSTED_NETWORKS_CONFIG = { @pytest.mark.parametrize( - ("provider_configs", "ip", "expected"), + ("ip", "preselect_remember_me"), + [ + ("192.168.1.10", True), + ("::ffff:192.168.0.10", True), + ("1.2.3.4", False), + ("2001:db8::1", False), + ], +) +@pytest.mark.parametrize( + ("provider_configs", "expected"), [ ( BASE_CONFIG, - None, [{"name": "Example", "type": "insecure_example", "id": None}], ), ( - [_TRUSTED_NETWORKS_CONFIG], - None, - [], - ), - ( - [_TRUSTED_NETWORKS_CONFIG], - "192.168.0.1", - [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + [{"type": "homeassistant"}], + [ + { + "name": "Home Assistant Local", + "type": "homeassistant", + "id": None, + } + ], ), ], ) @@ -49,8 +56,9 @@ async def test_fetch_auth_providers( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, provider_configs: list[dict[str, Any]], - ip: str | None, expected: list[dict[str, Any]], + ip: str, + preselect_remember_me: bool, ) -> None: """Test fetching auth providers.""" client = await async_setup_auth( @@ -58,73 +66,37 @@ async def test_fetch_auth_providers( ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == expected - - -async def _test_fetch_auth_providers_home_assistant( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, -) -> None: - """Test fetching auth providers for homeassistant auth provider.""" - client = await async_setup_auth( - hass, aiohttp_client, [{"type": "homeassistant"}], custom_ip=ip - ) - - expected = { - "name": "Home Assistant Local", - "type": "homeassistant", - "id": None, + assert await resp.json() == { + "providers": expected, + "preselect_remember_me": preselect_remember_me, } + +@pytest.mark.parametrize( + ("ip", "expected"), + [ + ( + "192.168.0.1", + [{"name": "Trusted Networks", "type": "trusted_networks", "id": None}], + ), + ("::ffff:192.168.0.10", []), + ("1.2.3.4", []), + ("2001:db8::1", []), + ], +) +async def test_fetch_auth_providers_trusted_network( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + expected: list[dict[str, Any]], + ip: str, +) -> None: + """Test fetching auth providers.""" + client = await async_setup_auth( + hass, aiohttp_client, [_TRUSTED_NETWORKS_CONFIG], custom_ip=ip + ) resp = await client.get("/auth/providers") assert resp.status == HTTPStatus.OK - assert await resp.json() == [expected] - - -@pytest.mark.parametrize( - "ip", - [ - "192.168.0.10", - "::ffff:192.168.0.10", - "1.2.3.4", - "2001:db8::1", - ], -) -async def test_fetch_auth_providers_home_assistant_person_not_loaded( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, -) -> None: - """Test fetching auth providers for homeassistant auth provider, where person integration is not loaded.""" - await _test_fetch_auth_providers_home_assistant(hass, aiohttp_client, ip) - - -@pytest.mark.parametrize( - ("ip", "is_local"), - [ - ("192.168.0.10", True), - ("::ffff:192.168.0.10", True), - ("1.2.3.4", False), - ("2001:db8::1", False), - ], -) -async def test_fetch_auth_providers_home_assistant_person_loaded( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - ip: str, - is_local: bool, -) -> None: - """Test fetching auth providers for homeassistant auth provider, where person integration is loaded.""" - domain = "person" - config = {domain: {"id": "1234", "name": "test person"}} - assert await async_setup_component(hass, domain, config) - - await _test_fetch_auth_providers_home_assistant( - hass, - aiohttp_client, - ip, - ) + assert (await resp.json())["providers"] == expected async def test_fetch_auth_providers_onboarding( diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1866f682b55..71491ee3caf 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -30,7 +29,7 @@ from homeassistant.setup import async_setup_component from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 from tests.common import MockUser, mock_component, mock_restore_cache -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator async def test_minimal_setup(hass: HomeAssistant) -> None: @@ -848,30 +847,3 @@ async def test_entities_in_person(hass: HomeAssistant) -> None: "device_tracker.paulus_iphone", "device_tracker.paulus_ipad", ] - - -async def test_list_persons( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - hass_admin_user: MockUser, -) -> None: - """Test listing persons from a not local ip address.""" - - user_id = hass_admin_user.id - admin = {"id": "1234", "name": "Admin", "user_id": user_id, "picture": "/bla"} - config = { - DOMAIN: [ - admin, - {"id": "5678", "name": "Only a person"}, - ] - } - assert await async_setup_component(hass, DOMAIN, config) - - await async_setup_component(hass, "api", {}) - client = await hass_client_no_auth() - - resp = await client.get("/api/person/list") - - assert resp.status == HTTPStatus.BAD_REQUEST - result = await resp.json() - assert result == {"code": "not_local", "message": "Not local"} From b12c53e94e34931651a13bf2ccb1a609dac782e6 Mon Sep 17 00:00:00 2001 From: Ido Flatow Date: Thu, 11 Jan 2024 11:39:50 +0200 Subject: [PATCH 0486/1544] Fix switcher kis logging incorrect property for device's name (#107775) * use of incorrect property for device's name * Update switch.py according to Ruff formatter --- homeassistant/components/switcher_kis/switch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index f37e16aa513..88867393834 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -105,7 +105,9 @@ class SwitcherBaseSwitchEntity( async def _async_call_api(self, api: str, *args: Any) -> None: """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + _LOGGER.debug( + "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args + ) response: SwitcherBaseResponse = None error = None From f217d438cdf7963fb039d1b4472410908b0ed31e Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 11 Jan 2024 18:04:14 +0800 Subject: [PATCH 0487/1544] Add SpeakerHub support to YoLink (#104678) * SpeakerHub support * Remove unnecessary code * fix entity description * Fix as suggestion * fixes * fixes as suggestion & remove Speker Hub service --- .coveragerc | 1 + homeassistant/components/yolink/__init__.py | 1 + homeassistant/components/yolink/number.py | 117 +++++++++++++++++++ homeassistant/components/yolink/strings.json | 5 + 4 files changed, 124 insertions(+) create mode 100644 homeassistant/components/yolink/number.py diff --git a/.coveragerc b/.coveragerc index 58ed78b6dca..eb1b8132c79 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1617,6 +1617,7 @@ omit = homeassistant/components/yolink/entity.py homeassistant/components/yolink/light.py homeassistant/components/yolink/lock.py + homeassistant/components/yolink/number.py homeassistant/components/yolink/sensor.py homeassistant/components/yolink/siren.py homeassistant/components/yolink/switch.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 20129b819ce..16094816f17 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -36,6 +36,7 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.LOCK, + Platform.NUMBER, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py new file mode 100644 index 00000000000..1ec20cd4d17 --- /dev/null +++ b/homeassistant/components/yolink/number.py @@ -0,0 +1,117 @@ +"""YoLink device number type config settings.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from yolink.client_request import ClientRequest +from yolink.const import ATTR_DEVICE_SPEAKER_HUB +from yolink.device import YoLinkDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +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 YoLinkCoordinator +from .entity import YoLinkEntity + +OPTIONS_VALUME = "options_volume" + + +@dataclass(frozen=True, kw_only=True) +class YoLinkNumberTypeConfigEntityDescription(NumberEntityDescription): + """YoLink NumberEntity description.""" + + exists_fn: Callable[[YoLinkDevice], bool] + value: Callable + + +NUMBER_TYPE_CONF_SUPPORT_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + +SUPPORT_SET_VOLUME_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + +DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] = ( + YoLinkNumberTypeConfigEntityDescription( + key=OPTIONS_VALUME, + translation_key="config_volume", + native_min_value=1, + native_max_value=16, + mode=NumberMode.SLIDER, + native_step=1.0, + native_unit_of_measurement=None, + icon="mdi:volume-high", + exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES, + value=lambda state: state["options"]["volume"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up device number type config option entity from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators + config_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in NUMBER_TYPE_CONF_SUPPORT_DEVICES + ] + entities = [] + for config_device_coordinator in config_device_coordinators: + for description in DEVICE_CONFIG_DESCRIPTIONS: + if description.exists_fn(config_device_coordinator.device): + entities.append( + YoLinkNumberTypeConfigEntity( + config_entry, + config_device_coordinator, + description, + ) + ) + async_add_entities(entities) + + +class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity): + """YoLink number type config Entity.""" + + entity_description: YoLinkNumberTypeConfigEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + description: YoLinkNumberTypeConfigEntityDescription, + ) -> None: + """Init YoLink device number type config entities.""" + super().__init__(config_entry, coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device.device_id} {description.key}" + + @callback + def update_entity_state(self, state: dict) -> None: + """Update HA Entity State.""" + attr_val = self.entity_description.value(state) + self._attr_native_value = attr_val + self.async_write_ha_state() + + async def update_speaker_hub_volume(self, volume: float) -> None: + """Update SpeakerHub volume.""" + await self.call_device(ClientRequest("setOption", {"volume": volume})) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + if ( + self.coordinator.device.device_type == ATTR_DEVICE_SPEAKER_HUB + and self.entity_description.key == OPTIONS_VALUME + ): + await self.update_speaker_hub_volume(value) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 212d7ced7d7..e1fa09429aa 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -69,6 +69,11 @@ "disabled": "[%key:common::state::disabled%]" } } + }, + "number": { + "config_volume": { + "name": "Volume" + } } } } From 00b40c964a7e5697f3c37666ae602e0bed6c8428 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 00:28:03 -1000 Subject: [PATCH 0488/1544] Bump govee-ble to 0.27.2 (#107778) * Bump govee-ble to 0.27.0 changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.26.0...v0.27.0 note: H5106 is partially supported, full support will be added in another PR + docs * .1 * 0.27.2 --- 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 23bc20570e3..77d67547b78 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.26.0"] + "requirements": ["govee-ble==0.27.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69ecc88aa44..586b2f23c9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,7 +949,7 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.26.0 +govee-ble==0.27.2 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71dfc925968..b015b4c39f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -766,7 +766,7 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.26.0 +govee-ble==0.27.2 # homeassistant.components.gree greeclimate==1.4.1 From 24cd6a8a523bb36ed1ad7fdb97c5cded46f0ccaa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 12:00:12 +0100 Subject: [PATCH 0489/1544] Improve ergonomics of FlowManager.async_show_progress (#107668) * Improve ergonomics of FlowManager.async_show_progress * Don't include progress coroutine in web response * Unconditionally reset progress task when show_progress finished * Fix race * Tweak, add tests * Address review comments * Improve error handling * Allow progress jobs to return anything * Add comment * Remove unneeded check * Change API according to discussion * Adjust typing --- .../components/github/config_flow.py | 31 +--- homeassistant/data_entry_flow.py | 47 +++++ tests/components/github/test_config_flow.py | 5 +- tests/test_data_entry_flow.py | 166 +++++++++++++++++- 4 files changed, 221 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index c90caf0fc89..aa7ec7b6f86 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from contextlib import suppress from typing import TYPE_CHECKING, Any from aiogithubapi import ( @@ -18,7 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult, UnknownFlow +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, @@ -124,22 +123,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._device is not None assert self._login_device is not None - try: - response = await self._device.activation( - device_code=self._login_device.device_code - ) - self._login = response.data - - finally: - - async def _progress(): - # If the user closes the dialog the flow will no longer exist and it will raise UnknownFlow - with suppress(UnknownFlow): - await self.hass.config_entries.flow.async_configure( - flow_id=self.flow_id - ) - - self.hass.async_create_task(_progress()) + response = await self._device.activation( + device_code=self._login_device.device_code + ) + self._login = response.data if not self._device: self._device = GitHubDeviceAPI( @@ -174,6 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "url": OAUTH_USER_LOGIN, "code": self._login_device.user_code, }, + progress_task=self.login_task, ) async def async_step_repositories( @@ -220,13 +208,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) - @callback - def async_remove(self) -> None: - """Handle remove handler callback.""" - if self.login_task and not self.login_task.done(): - # Clean up login task if it's still running - self.login_task.cancel() - class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for GitHub.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index c017744689c..aa9df89de5c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -2,7 +2,9 @@ from __future__ import annotations import abc +import asyncio from collections.abc import Callable, Iterable, Mapping +from contextlib import suppress import copy from dataclasses import dataclass from enum import StrEnum @@ -124,6 +126,7 @@ class FlowResult(TypedDict, total=False): options: Mapping[str, Any] preview: str | None progress_action: str + progress_task: asyncio.Task[Any] | None reason: str required: bool result: Any @@ -402,6 +405,7 @@ class FlowManager(abc.ABC): if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow self._async_remove_flow_from_index(flow) + flow.async_cancel_progress_task() try: flow.async_remove() except Exception as err: # pylint: disable=broad-except @@ -435,6 +439,25 @@ class FlowManager(abc.ABC): error_if_core=False, ) + if ( + result["type"] == FlowResultType.SHOW_PROGRESS + and (progress_task := result.pop("progress_task", None)) + and progress_task != flow.async_get_progress_task() + ): + # The flow's progress task was changed, register a callback on it + async def call_configure() -> None: + with suppress(UnknownFlow): + await self.async_configure(flow.flow_id) + + def schedule_configure(_: asyncio.Task) -> None: + self.hass.async_create_task(call_configure()) + + progress_task.add_done_callback(schedule_configure) + flow.async_set_progress_task(progress_task) + + elif result["type"] != FlowResultType.SHOW_PROGRESS: + flow.async_cancel_progress_task() + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result @@ -494,6 +517,8 @@ class FlowHandler: VERSION = 1 MINOR_VERSION = 1 + __progress_task: asyncio.Task[Any] | None = None + @property def source(self) -> str | None: """Source that initialized the flow.""" @@ -632,6 +657,7 @@ class FlowHandler: step_id: str, progress_action: str, description_placeholders: Mapping[str, str] | None = None, + progress_task: asyncio.Task[Any] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" return FlowResult( @@ -641,6 +667,7 @@ class FlowHandler: step_id=step_id, progress_action=progress_action, description_placeholders=description_placeholders, + progress_task=progress_task, ) @callback @@ -683,6 +710,26 @@ class FlowHandler: async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" + @callback + def async_cancel_progress_task(self) -> None: + """Cancel in progress task.""" + if self.__progress_task and not self.__progress_task.done(): + self.__progress_task.cancel() + self.__progress_task = None + + @callback + def async_get_progress_task(self) -> asyncio.Task[Any] | None: + """Get in progress task.""" + return self.__progress_task + + @callback + def async_set_progress_task( + self, + progress_task: asyncio.Task[Any], + ) -> None: + """Set in progress task.""" + self.__progress_task = progress_task + @callback def _create_abort_data( diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index 8d61eca1ab1..32388fb65d1 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -121,10 +121,11 @@ async def test_flow_with_activation_failure( ) assert result["step_id"] == "device" assert result["type"] == FlowResultType.SHOW_PROGRESS + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "could_not_register" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "could_not_register" async def test_flow_with_remove_while_activating( diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 155d78e2c64..aedf3e40c15 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -1,4 +1,5 @@ """Test the flow classes.""" +import asyncio import dataclasses import logging from unittest.mock import Mock, patch @@ -7,7 +8,7 @@ import pytest import voluptuous as vol from homeassistant import config_entries, data_entry_flow -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.decorator import Registry from .common import ( @@ -342,6 +343,169 @@ async def test_external_step(hass: HomeAssistant, manager) -> None: async def test_show_progress(hass: HomeAssistant, manager) -> None: """Test show progress logic.""" manager.hass = hass + events = [] + task_one_evt = asyncio.Event() + task_two_evt = asyncio.Event() + event_received_evt = asyncio.Event() + + @callback + def capture_events(event: Event) -> None: + events.append(event) + event_received_evt.set() + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + start_task_two = False + progress_task: asyncio.Task[None] | None = None + + async def async_step_init(self, user_input=None): + async def long_running_task_one() -> None: + await task_one_evt.wait() + self.start_task_two = True + + async def long_running_task_two() -> None: + await task_two_evt.wait() + self.data = {"title": "Hello"} + + if not task_one_evt.is_set(): + progress_action = "task_one" + if not self.progress_task: + self.progress_task = hass.async_create_task(long_running_task_one()) + elif not task_two_evt.is_set(): + progress_action = "task_two" + if self.start_task_two: + self.progress_task = hass.async_create_task(long_running_task_two()) + self.start_task_two = False + if not task_one_evt.is_set() or not task_two_evt.is_set(): + return self.async_show_progress( + step_id="init", + progress_action=progress_action, + progress_task=self.progress_task, + ) + + return self.async_show_progress_done(next_step_id="finish") + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=self.data["title"], data=self.data) + + hass.bus.async_listen( + data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED, + capture_events, + run_immediately=True, + ) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_one" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Set task one done and wait for event + task_one_evt.set() + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 1 + assert events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_two" + + # Set task two done and wait for event + task_two_evt.set() + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 2 # 1 for task one and 1 for task two + assert events[1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Hello" + + +async def test_show_progress_error(hass: HomeAssistant, manager) -> None: + """Test show progress logic.""" + manager.hass = hass + events = [] + event_received_evt = asyncio.Event() + + @callback + def capture_events(event: Event) -> None: + events.append(event) + event_received_evt.set() + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + progress_task: asyncio.Task[None] | None = None + + async def async_step_init(self, user_input=None): + async def long_running_task() -> None: + raise TypeError + + if not self.progress_task: + self.progress_task = hass.async_create_task(long_running_task()) + if self.progress_task and self.progress_task.done(): + if self.progress_task.exception(): + return self.async_show_progress_done(next_step_id="error") + return self.async_show_progress_done(next_step_id="no_error") + return self.async_show_progress( + step_id="init", progress_action="task", progress_task=self.progress_task + ) + + async def async_step_error(self, user_input=None): + return self.async_abort(reason="error") + + hass.bus.async_listen( + data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED, + capture_events, + run_immediately=True, + ) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Set task one done and wait for event + await event_received_evt.wait() + event_received_evt.clear() + assert len(events) == 1 + assert events[0].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "error" + + +async def test_show_progress_legacy(hass: HomeAssistant, manager) -> None: + """Test show progress logic. + + This tests the deprecated version where the config flow is responsible for + resuming the flow. + """ + manager.hass = hass @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): From 2a0bd6654b12a7f3d82aebfdc0157ceba542c2ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 13:07:43 +0100 Subject: [PATCH 0490/1544] Improve calls to async_show_progress in zwave_js (#107794) --- .../components/zwave_js/config_flow.py | 82 +++++++++---------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 752e3545114..e252a2ad693 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -185,19 +185,23 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Install Z-Wave JS add-on.""" if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) + + if not self.install_task.done(): return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" + step_id="install_addon", + progress_action="install_addon", + progress_task=self.install_task, ) try: await self.install_task except AddonError as err: - self.install_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="install_failed") + finally: + self.install_task = None self.integration_created_addon = True - self.install_task = None return self.async_show_progress_done(next_step_id="configure_addon") @@ -213,18 +217,22 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Start Z-Wave JS 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(): return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" + step_id="start_addon", + progress_action="start_addon", + progress_task=self.start_task, ) try: await self.start_task except (CannotConnect, AddonError, AbortFlow) as err: - self.start_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -237,38 +245,32 @@ class BaseZwaveJSFlow(FlowHandler, ABC): """Start the Z-Wave JS add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) self.version_info = None - try: - if self.restart_addon: - await addon_manager.async_schedule_restart_addon() - else: - await addon_manager.async_schedule_start_addon() - # Sleep some seconds to let the add-on start properly before connecting. - for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): - await asyncio.sleep(ADDON_SETUP_TIMEOUT) - try: - if not self.ws_address: - discovery_info = await self._async_get_addon_discovery_info() - self.ws_address = ( - f"ws://{discovery_info['host']}:{discovery_info['port']}" - ) - self.version_info = await async_get_version_info( - self.hass, self.ws_address + if self.restart_addon: + await addon_manager.async_schedule_restart_addon() + else: + await addon_manager.async_schedule_start_addon() + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not self.ws_address: + discovery_info = await self._async_get_addon_discovery_info() + self.ws_address = ( + f"ws://{discovery_info['host']}:{discovery_info['port']}" ) - except (AbortFlow, CannotConnect) as err: - _LOGGER.debug( - "Add-on not ready yet, waiting %s seconds: %s", - ADDON_SETUP_TIMEOUT, - err, - ) - else: - break + self.version_info = await async_get_version_info( + self.hass, self.ws_address + ) + except (AbortFlow, CannotConnect) as err: + _LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) else: - raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) + break + else: + raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") @abstractmethod async def async_step_configure_addon( @@ -309,13 +311,7 @@ class BaseZwaveJSFlow(FlowHandler, ABC): async def _async_install_addon(self) -> None: """Install the Z-Wave JS add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_install_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) + await addon_manager.async_schedule_install_addon() async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" From fbb6c1d0f0a025cb42fe086428169e108fd2d195 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 13:08:54 +0100 Subject: [PATCH 0491/1544] Improve calls to async_show_progress in matter (#107791) --- .../components/matter/config_flow.py | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 6e25370d86a..1636790c4cb 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -76,19 +76,23 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Install Matter Server add-on.""" if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) + + if not self.install_task.done(): return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" + step_id="install_addon", + progress_action="install_addon", + progress_task=self.install_task, ) try: await self.install_task except AddonError as err: - self.install_task = None LOGGER.error(err) return self.async_show_progress_done(next_step_id="install_failed") + finally: + self.install_task = None self.integration_created_addon = True - self.install_task = None return self.async_show_progress_done(next_step_id="start_addon") @@ -101,13 +105,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_install_addon(self) -> None: """Install the Matter Server add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_install_addon() - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + await addon_manager.async_schedule_install_addon() async def _async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" @@ -126,18 +124,21 @@ class ConfigFlow(config_entries.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(): return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" + step_id="start_addon", + progress_action="start_addon", + progress_task=self.start_task, ) try: await self.start_task except (FailedConnect, AddonError, AbortFlow) as err: - self.start_task = None LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -150,33 +151,27 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Start the Matter Server add-on.""" addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_start_addon() - # Sleep some seconds to let the add-on start properly before connecting. - for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): - await asyncio.sleep(ADDON_SETUP_TIMEOUT) - try: - if not (ws_address := self.ws_address): - discovery_info = await self._async_get_addon_discovery_info() - ws_address = self.ws_address = build_ws_address( - discovery_info["host"], discovery_info["port"] - ) - await validate_input(self.hass, {CONF_URL: ws_address}) - except (AbortFlow, CannotConnect) as err: - LOGGER.debug( - "Add-on not ready yet, waiting %s seconds: %s", - ADDON_SETUP_TIMEOUT, - err, + await addon_manager.async_schedule_start_addon() + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not (ws_address := self.ws_address): + discovery_info = await self._async_get_addon_discovery_info() + ws_address = self.ws_address = build_ws_address( + discovery_info["host"], discovery_info["port"] ) - else: - break + await validate_input(self.hass, {CONF_URL: ws_address}) + except (AbortFlow, CannotConnect) as err: + LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) else: - raise FailedConnect("Failed to start Matter Server add-on: timeout") - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + break + else: + raise FailedConnect("Failed to start Matter Server add-on: timeout") async def _async_get_addon_info(self) -> AddonInfo: """Return Matter Server add-on info.""" From ddf3a36061be4464f9d411e883313856d7d08305 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 16:47:53 +0100 Subject: [PATCH 0492/1544] Improve calls to async_show_progress in google (#107788) --- .../components/google/config_flow.py | 17 +++++++++----- tests/components/google/test_config_flow.py | 22 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 33d913fe8f1..a9d707fe4bf 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Google integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -68,6 +69,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + _exchange_finished_task: asyncio.Task[bool] | None = None + def __init__(self) -> None: """Set up instance.""" super().__init__() @@ -115,7 +118,7 @@ class OAuth2FlowHandler( if self._web_auth: return await super().async_step_auth(user_input) - if user_input is not None: + if self._exchange_finished_task and self._exchange_finished_task.done(): return self.async_show_progress_done(next_step_id="creation") if not self._device_flow: @@ -150,15 +153,16 @@ class OAuth2FlowHandler( return self.async_abort(reason="oauth_error") self._device_flow = device_flow + exchange_finished_evt = asyncio.Event() + self._exchange_finished_task = self.hass.async_create_task( + exchange_finished_evt.wait() + ) + def _exchange_finished() -> None: self.external_data = { DEVICE_AUTH_CREDS: device_flow.creds } # is None on timeout/expiration - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure( - flow_id=self.flow_id, user_input={} - ) - ) + exchange_finished_evt.set() device_flow.async_set_listener(_exchange_finished) device_flow.async_start_exchange() @@ -170,6 +174,7 @@ class OAuth2FlowHandler( "user_code": self._device_flow.user_code, }, progress_action="exchange", + progress_task=self._exchange_finished_task, ) async def async_step_creation( diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index b2c472757b6..f8eff022d9f 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable import datetime from http import HTTPStatus @@ -10,6 +11,7 @@ from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from oauth2client.client import ( DeviceFlowInfo, FlowExchangeError, @@ -273,6 +275,7 @@ async def test_exchange_error( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, + freezer: FrozenDateTimeFactory, ) -> None: """Test an error while exchanging the code for credentials.""" await async_import_client_credential( @@ -290,14 +293,19 @@ async def test_exchange_error( assert "url" in result["description_placeholders"] # Run one tick to invoke the credential exchange check - now = utcnow() + step2_exchange_called = asyncio.Event() + + def step2_exchange(*args, **kwargs): + hass.loop.call_soon_threadsafe(step2_exchange_called.set) + raise FlowExchangeError + with patch( "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", - side_effect=FlowExchangeError(), + side_effect=step2_exchange, ): - now += CODE_CHECK_ALARM_TIMEDELTA - await fire_alarm(hass, now) - await hass.async_block_till_done() + freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) + async_fire_time_changed(hass, utcnow()) + await step2_exchange_called.wait() # Status has not updated, will retry result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) @@ -308,8 +316,8 @@ async def test_exchange_error( with patch( "homeassistant.components.google.async_setup_entry", return_value=True ) as mock_setup: - now += CODE_CHECK_ALARM_TIMEDELTA - await fire_alarm(hass, now) + freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) + async_fire_time_changed(hass, utcnow()) await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"] From 44a6882c39ca86de803579b07cdbdae6a25e7d01 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 21:05:20 +0100 Subject: [PATCH 0493/1544] Make step_id parameter to FlowHandler.async_show_progress optional (#107802) Drop step_id parameter from FlowHandler.async_show_progress --- homeassistant/data_entry_flow.py | 16 +++++++++++++--- tests/test_data_entry_flow.py | 3 +-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index aa9df89de5c..65cf7eb3d36 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -74,6 +74,10 @@ FLOW_NOT_COMPLETE_STEPS = { FlowResultType.MENU, } +STEP_ID_OPTIONAL_STEPS = { + FlowResultType.SHOW_PROGRESS, +} + @dataclass(slots=True) class BaseServiceInfo: @@ -458,6 +462,10 @@ class FlowManager(abc.ABC): elif result["type"] != FlowResultType.SHOW_PROGRESS: flow.async_cancel_progress_task() + if result["type"] in STEP_ID_OPTIONAL_STEPS: + if "step_id" not in result: + result["step_id"] = step_id + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result @@ -654,21 +662,23 @@ class FlowHandler: def async_show_progress( self, *, - step_id: str, + step_id: str | None = None, progress_action: str, description_placeholders: Mapping[str, str] | None = None, progress_task: asyncio.Task[Any] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" - return FlowResult( + result = FlowResult( type=FlowResultType.SHOW_PROGRESS, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, progress_action=progress_action, description_placeholders=description_placeholders, progress_task=progress_task, ) + if step_id is not None: + result["step_id"] = step_id + return result @callback def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index aedf3e40c15..cafeaaf3ba0 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -380,7 +380,6 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: self.start_task_two = False if not task_one_evt.is_set() or not task_two_evt.is_set(): return self.async_show_progress( - step_id="init", progress_action=progress_action, progress_task=self.progress_task, ) @@ -464,7 +463,7 @@ async def test_show_progress_error(hass: HomeAssistant, manager) -> None: return self.async_show_progress_done(next_step_id="error") return self.async_show_progress_done(next_step_id="no_error") return self.async_show_progress( - step_id="init", progress_action="task", progress_task=self.progress_task + progress_action="task", progress_task=self.progress_task ) async def async_step_error(self, user_input=None): From 8a9f9b94ef8437869895e0227eb0ecf55b38bd92 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Jan 2024 21:09:32 +0100 Subject: [PATCH 0494/1544] Fix call to async_setup_component in translation test (#107807) --- tests/helpers/test_translation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 350e706ca1d..527e1a07c23 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -43,7 +43,7 @@ async def test_component_translation_path( "switch", {"switch": [{"platform": "test"}, {"platform": "test_embedded"}]}, ) - assert await async_setup_component(hass, "test_package", {"test_package"}) + assert await async_setup_component(hass, "test_package", {"test_package": None}) ( int_test, From ff811a33f5b52b7fd3808b00e893f6d8cd80ab6a Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Fri, 12 Jan 2024 09:36:39 +1300 Subject: [PATCH 0495/1544] Fix Netatmo camera name does not show under Media -> Media sources -> Camera (#107696) * Fixes issue where Netatmo camera name does not show under Media -> Media sources ->Camera Fixes #105268 * Remove entity name and change has_entity_name to False has_entity_name has to be retained (per https://developers.home-assistant.io/docs/core/entity/#has_entity_name-true-mandatory-for-new-integrations) --- homeassistant/components/netatmo/camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 7fab99a6f39..5c217837ce7 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -83,7 +83,7 @@ class NetatmoCamera(NetatmoBase, Camera): """Representation of a Netatmo camera.""" _attr_brand = MANUFACTURER - _attr_has_entity_name = True + _attr_has_entity_name = False _attr_supported_features = CameraEntityFeature.STREAM def __init__( @@ -97,7 +97,7 @@ class NetatmoCamera(NetatmoBase, Camera): self._camera = cast(NaModules.Camera, netatmo_device.device) self._id = self._camera.entity_id self._home_id = self._camera.home.entity_id - self._device_name = self._camera.name + self._device_name = self._attr_name = self._camera.name self._model = self._camera.device_type self._config_url = CONF_URL_SECURITY self._attr_unique_id = f"{self._id}-{self._model}" From 24ddc939c0e894b1a30d0fec33518c84357a4d08 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 11 Jan 2024 14:49:39 -0600 Subject: [PATCH 0496/1544] Remove Life360 integration (#107805) --- .coveragerc | 4 - CODEOWNERS | 2 - homeassistant/components/life360/__init__.py | 68 ++-- homeassistant/components/life360/button.py | 54 --- .../components/life360/config_flow.py | 197 +---------- homeassistant/components/life360/const.py | 37 -- .../components/life360/coordinator.py | 246 ------------- .../components/life360/device_tracker.py | 326 ----------------- .../components/life360/manifest.json | 7 +- homeassistant/components/life360/strings.json | 51 +-- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/life360/test_config_flow.py | 329 ------------------ tests/components/life360/test_init.py | 50 +++ 16 files changed, 81 insertions(+), 1303 deletions(-) delete mode 100644 homeassistant/components/life360/button.py delete mode 100644 homeassistant/components/life360/const.py delete mode 100644 homeassistant/components/life360/coordinator.py delete mode 100644 homeassistant/components/life360/device_tracker.py delete mode 100644 tests/components/life360/test_config_flow.py create mode 100644 tests/components/life360/test_init.py diff --git a/.coveragerc b/.coveragerc index eb1b8132c79..a3c6737a722 100644 --- a/.coveragerc +++ b/.coveragerc @@ -664,10 +664,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/life360/__init__.py - homeassistant/components/life360/button.py - homeassistant/components/life360/coordinator.py - homeassistant/components/life360/device_tracker.py homeassistant/components/lightwave/* homeassistant/components/limitlessled/light.py homeassistant/components/linksys_smart/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index 8dba5e38df3..fdbc63324ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -706,8 +706,6 @@ build.json @home-assistant/supervisor /homeassistant/components/lg_netcast/ @Drafteed /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob -/homeassistant/components/life360/ @pnbruckner -/tests/components/life360/ @pnbruckner /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linear_garage_door/ @IceBotYT diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 8bd0895821b..5c2d62545d6 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -2,59 +2,35 @@ from __future__ import annotations -from dataclasses import dataclass, field - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN -from .coordinator import Life360DataUpdateCoordinator, MissingLocReason - -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] +DOMAIN = "life360" -@dataclass -class IntegData: - """Integration data.""" - - # ConfigEntry.entry_id: Life360DataUpdateCoordinator - coordinators: dict[str, Life360DataUpdateCoordinator] = field( - init=False, default_factory=dict - ) - # member_id: missing location reason - missing_loc_reason: dict[str, MissingLocReason] = field( - init=False, default_factory=dict - ) - # member_id: ConfigEntry.entry_id - tracked_members: dict[str, str] = field(init=False, default_factory=dict) - logged_circles: list[str] = field(init=False, default_factory=list) - logged_places: list[str] = field(init=False, default_factory=list) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up config entry.""" - hass.data.setdefault(DOMAIN, IntegData()) - - coordinator = Life360DataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN].coordinators[entry.entry_id] = coordinator - - # Set up components for our platforms. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/life360" + }, + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" - - # Unload components for our platforms. - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN].coordinators[entry.entry_id] - # Remove any members that were tracked by this entry. - for member_id, entry_id in hass.data[DOMAIN].tracked_members.copy().items(): - if entry_id == entry.entry_id: - del hass.data[DOMAIN].tracked_members[member_id] - - return unload_ok + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + return True diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py deleted file mode 100644 index 07ef4d06ed9..00000000000 --- a/homeassistant/components/life360/button.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Support for Life360 buttons.""" -from homeassistant.components.button import ButtonEntity -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 . import Life360DataUpdateCoordinator -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Life360 buttons.""" - coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ - config_entry.entry_id - ] - async_add_entities( - Life360UpdateLocationButton(coordinator, member.circle_id, member_id) - for member_id, member in coordinator.data.members.items() - ) - - -class Life360UpdateLocationButton( - CoordinatorEntity[Life360DataUpdateCoordinator], ButtonEntity -): - """Represent an Life360 Update Location button.""" - - _attr_has_entity_name = True - _attr_translation_key = "update_location" - - def __init__( - self, - coordinator: Life360DataUpdateCoordinator, - circle_id: str, - member_id: str, - ) -> None: - """Initialize a new Life360 Update Location button.""" - super().__init__(coordinator) - self._circle_id = circle_id - self._member_id = member_id - self._attr_unique_id = f"{member_id}-update-location" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, member_id)}, - name=coordinator.data.members[member_id].name, - ) - - async def async_press(self) -> None: - """Handle the button press.""" - await self.coordinator.update_location(self._circle_id, self._member_id) diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 4b59bcadf88..ea9f33d9f45 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -2,205 +2,12 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any, cast +from homeassistant.config_entries import ConfigFlow -from life360 import Life360, Life360Error, LoginError -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv - -from .const import ( - COMM_TIMEOUT, - CONF_AUTHORIZATION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DEFAULT_OPTIONS, - DOMAIN, - LOGGER, - OPTIONS, - SHOW_DRIVING, -) - -LIMIT_GPS_ACC = "limit_gps_acc" -SET_DRIVE_SPEED = "set_drive_speed" - - -def account_schema( - def_username: str | vol.UNDEFINED = vol.UNDEFINED, - def_password: str | vol.UNDEFINED = vol.UNDEFINED, -) -> dict[vol.Marker, Any]: - """Return schema for an account with optional default values.""" - return { - vol.Required(CONF_USERNAME, default=def_username): cv.string, - vol.Required(CONF_PASSWORD, default=def_password): cv.string, - } - - -def password_schema( - def_password: str | vol.UNDEFINED = vol.UNDEFINED, -) -> dict[vol.Marker, Any]: - """Return schema for a password with optional default value.""" - return {vol.Required(CONF_PASSWORD, default=def_password): cv.string} +from . import DOMAIN class Life360ConfigFlow(ConfigFlow, domain=DOMAIN): """Life360 integration config flow.""" VERSION = 1 - _api: Life360 | None = None - _username: str | vol.UNDEFINED = vol.UNDEFINED - _password: str | vol.UNDEFINED = vol.UNDEFINED - _reauth_entry: ConfigEntry | None = None - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> Life360OptionsFlow: - """Get the options flow for this handler.""" - return Life360OptionsFlow(config_entry) - - async def _async_verify(self, step_id: str) -> FlowResult: - """Attempt to authorize the provided credentials.""" - if not self._api: - self._api = Life360( - session=async_get_clientsession(self.hass), timeout=COMM_TIMEOUT - ) - errors: dict[str, str] = {} - try: - authorization = await self._api.get_authorization( - self._username, self._password - ) - except LoginError as exc: - LOGGER.debug("Login error: %s", exc) - errors["base"] = "invalid_auth" - except Life360Error as exc: - LOGGER.debug("Unexpected error communicating with Life360 server: %s", exc) - errors["base"] = "cannot_connect" - if errors: - if step_id == "user": - schema = account_schema(self._username, self._password) - else: - schema = password_schema(self._password) - return self.async_show_form( - step_id=step_id, data_schema=vol.Schema(schema), errors=errors - ) - - data = { - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_AUTHORIZATION: authorization, - } - - if self._reauth_entry: - LOGGER.debug("Reauthorization successful") - self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") - - return self.async_create_entry( - title=cast(str, self.unique_id), data=data, options=DEFAULT_OPTIONS - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a config flow initiated by the user.""" - if not user_input: - return self.async_show_form( - step_id="user", data_schema=vol.Schema(account_schema()) - ) - - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - - await self.async_set_unique_id(self._username.lower()) - self._abort_if_unique_id_configured() - - return await self._async_verify("user") - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Handle reauthorization.""" - self._username = data[CONF_USERNAME] - self._reauth_entry = self.hass.config_entries.async_get_entry( - self.context["entry_id"] - ) - # Always start with current credentials since they may still be valid and a - # simple reauthorization will be successful. - return await self.async_step_reauth_confirm(dict(data)) - - async def async_step_reauth_confirm(self, user_input: dict[str, Any]) -> FlowResult: - """Handle reauthorization completion.""" - if not user_input: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema(password_schema(self._password)), - errors={"base": "invalid_auth"}, - ) - self._password = user_input[CONF_PASSWORD] - return await self._async_verify("reauth_confirm") - - -class Life360OptionsFlow(OptionsFlow): - """Life360 integration options flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle account options.""" - options = self.config_entry.options - - if user_input is not None: - new_options = _extract_account_options(user_input) - return self.async_create_entry(title="", data=new_options) - - return self.async_show_form( - step_id="init", data_schema=vol.Schema(_account_options_schema(options)) - ) - - -def _account_options_schema(options: Mapping[str, Any]) -> dict[vol.Marker, Any]: - """Create schema for account options form.""" - def_limit_gps_acc = options[CONF_MAX_GPS_ACCURACY] is not None - def_max_gps = options[CONF_MAX_GPS_ACCURACY] or vol.UNDEFINED - def_set_drive_speed = options[CONF_DRIVING_SPEED] is not None - def_speed = options[CONF_DRIVING_SPEED] or vol.UNDEFINED - def_show_driving = options[SHOW_DRIVING] - - return { - vol.Required(LIMIT_GPS_ACC, default=def_limit_gps_acc): bool, - vol.Optional(CONF_MAX_GPS_ACCURACY, default=def_max_gps): vol.Coerce(float), - vol.Required(SET_DRIVE_SPEED, default=def_set_drive_speed): bool, - vol.Optional(CONF_DRIVING_SPEED, default=def_speed): vol.Coerce(float), - vol.Optional(SHOW_DRIVING, default=def_show_driving): bool, - } - - -def _extract_account_options(user_input: dict) -> dict[str, Any]: - """Remove options from user input and return as a separate dict.""" - result = {} - - for key in OPTIONS: - value = user_input.pop(key, None) - # Was "include" checkbox (if there was one) corresponding to option key True - # (meaning option should be included)? - incl = user_input.pop( - { - CONF_MAX_GPS_ACCURACY: LIMIT_GPS_ACC, - CONF_DRIVING_SPEED: SET_DRIVE_SPEED, - }.get(key), - True, - ) - result[key] = value if incl else None - - return result diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py deleted file mode 100644 index d310a5177b1..00000000000 --- a/homeassistant/components/life360/const.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Constants for Life360 integration.""" - -from datetime import timedelta -import logging - -from aiohttp import ClientTimeout - -DOMAIN = "life360" -LOGGER = logging.getLogger(__package__) - -ATTRIBUTION = "Data provided by life360.com" -COMM_MAX_RETRIES = 3 -COMM_TIMEOUT = ClientTimeout(sock_connect=15, total=60) -SPEED_FACTOR_MPH = 2.25 -SPEED_DIGITS = 1 -UPDATE_INTERVAL = timedelta(seconds=10) - -ATTR_ADDRESS = "address" -ATTR_AT_LOC_SINCE = "at_loc_since" -ATTR_DRIVING = "driving" -ATTR_LAST_SEEN = "last_seen" -ATTR_PLACE = "place" -ATTR_SPEED = "speed" -ATTR_WIFI_ON = "wifi_on" - -CONF_AUTHORIZATION = "authorization" -CONF_DRIVING_SPEED = "driving_speed" -CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" - -SHOW_DRIVING = "driving" - -DEFAULT_OPTIONS = { - CONF_DRIVING_SPEED: None, - CONF_MAX_GPS_ACCURACY: None, - SHOW_DRIVING: False, -} -OPTIONS = list(DEFAULT_OPTIONS.keys()) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py deleted file mode 100644 index 4ef6e20d703..00000000000 --- a/homeassistant/components/life360/coordinator.py +++ /dev/null @@ -1,246 +0,0 @@ -"""DataUpdateCoordinator for the Life360 integration.""" - -from __future__ import annotations - -import asyncio -from contextlib import suppress -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from typing import Any - -from life360 import Life360, Life360Error, LoginError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -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 homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM - -from .const import ( - COMM_MAX_RETRIES, - COMM_TIMEOUT, - CONF_AUTHORIZATION, - DOMAIN, - LOGGER, - SPEED_DIGITS, - SPEED_FACTOR_MPH, - UPDATE_INTERVAL, -) - - -class MissingLocReason(Enum): - """Reason member location information is missing.""" - - VAGUE_ERROR_REASON = "vague error reason" - EXPLICIT_ERROR_REASON = "explicit error reason" - - -@dataclass -class Life360Place: - """Life360 Place data.""" - - name: str - latitude: float - longitude: float - radius: float - - -@dataclass -class Life360Circle: - """Life360 Circle data.""" - - name: str - places: dict[str, Life360Place] - - -@dataclass -class Life360Member: - """Life360 Member data.""" - - address: str | None - at_loc_since: datetime - battery_charging: bool - battery_level: int - circle_id: str - driving: bool - entity_picture: str - gps_accuracy: int - last_seen: datetime - latitude: float - longitude: float - name: str - place: str | None - speed: float - wifi_on: bool - - -@dataclass -class Life360Data: - """Life360 data.""" - - circles: dict[str, Life360Circle] = field(init=False, default_factory=dict) - members: dict[str, Life360Member] = field(init=False, default_factory=dict) - - -class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): - """Life360 data update coordinator.""" - - config_entry: ConfigEntry - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize data update coordinator.""" - super().__init__( - hass, - LOGGER, - name=f"{DOMAIN} ({entry.unique_id})", - update_interval=UPDATE_INTERVAL, - ) - self._hass = hass - self._api = Life360( - session=async_get_clientsession(hass), - timeout=COMM_TIMEOUT, - max_retries=COMM_MAX_RETRIES, - authorization=entry.data[CONF_AUTHORIZATION], - ) - self._missing_loc_reason = hass.data[DOMAIN].missing_loc_reason - - async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: - """Get data from Life360.""" - try: - return await getattr(self._api, func)(*args) - except LoginError as exc: - LOGGER.debug("Login error: %s", exc) - raise ConfigEntryAuthFailed(exc) from exc - except Life360Error as exc: - LOGGER.debug("%s: %s", exc.__class__.__name__, exc) - raise UpdateFailed(exc) from exc - - async def update_location(self, circle_id: str, member_id: str) -> None: - """Update location for given Circle and Member.""" - await self._retrieve_data("update_location", circle_id, member_id) - - async def _async_update_data(self) -> Life360Data: - """Get & process data from Life360.""" - - data = Life360Data() - - for circle in await self._retrieve_data("get_circles"): - circle_id = circle["id"] - circle_members, circle_places = await asyncio.gather( - self._retrieve_data("get_circle_members", circle_id), - self._retrieve_data("get_circle_places", circle_id), - ) - - data.circles[circle_id] = Life360Circle( - circle["name"], - { - place["id"]: Life360Place( - place["name"], - float(place["latitude"]), - float(place["longitude"]), - float(place["radius"]), - ) - for place in circle_places - }, - ) - - for member in circle_members: - # Member isn't sharing location. - if not int(member["features"]["shareLocation"]): - continue - - member_id = member["id"] - - first = member["firstName"] - last = member["lastName"] - if first and last: - name = " ".join([first, last]) - else: - name = first or last - - cur_missing_reason = self._missing_loc_reason.get(member_id) - - # Check if location information is missing. This can happen if server - # has not heard from member's device in a long time (e.g., has been off - # for a long time, or has lost service, etc.) - if loc := member["location"]: - with suppress(KeyError): - del self._missing_loc_reason[member_id] - else: - if explicit_reason := member["issues"]["title"]: - if extended_reason := member["issues"]["dialog"]: - explicit_reason += f": {extended_reason}" - # Note that different Circles can report missing location in - # different ways. E.g., one might report an explicit reason and - # another does not. If a vague reason has already been logged but a - # more explicit reason is now available, log that, too. - if ( - cur_missing_reason is None - or cur_missing_reason == MissingLocReason.VAGUE_ERROR_REASON - and explicit_reason - ): - if explicit_reason: - self._missing_loc_reason[ - member_id - ] = MissingLocReason.EXPLICIT_ERROR_REASON - err_msg = explicit_reason - else: - self._missing_loc_reason[ - member_id - ] = MissingLocReason.VAGUE_ERROR_REASON - err_msg = "Location information missing" - LOGGER.error("%s: %s", name, err_msg) - continue - - # Note that member may be in more than one circle. If that's the case - # just go ahead and process the newly retrieved data (overwriting the - # older data), since it might be slightly newer than what was retrieved - # while processing another circle. - - place = loc["name"] or None - - address1: str | None = loc["address1"] or None - address2: str | None = loc["address2"] or None - if address1 and address2: - address: str | None = ", ".join([address1, address2]) - else: - address = address1 or address2 - - speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH) - if self._hass.config.units is METRIC_SYSTEM: - speed = DistanceConverter.convert( - speed, UnitOfLength.MILES, UnitOfLength.KILOMETERS - ) - - data.members[member_id] = Life360Member( - address, - dt_util.utc_from_timestamp(int(loc["since"])), - bool(int(loc["charge"])), - int(float(loc["battery"])), - circle_id, - bool(int(loc["isDriving"])), - member["avatar"], - # Life360 reports accuracy in feet, but Device Tracker expects - # gps_accuracy in meters. - round( - DistanceConverter.convert( - float(loc["accuracy"]), - UnitOfLength.FEET, - UnitOfLength.METERS, - ) - ), - dt_util.utc_from_timestamp(int(loc["timestamp"])), - float(loc["latitude"]), - float(loc["longitude"]), - name, - place, - round(speed, SPEED_DIGITS), - bool(int(loc["wifiState"])), - ) - - return data diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py deleted file mode 100644 index ee097b9e989..00000000000 --- a/homeassistant/components/life360/device_tracker.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Support for Life360 device tracking.""" - -from __future__ import annotations - -from collections.abc import Mapping -from contextlib import suppress -from typing import Any, cast - -from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_BATTERY_CHARGING -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import ( - ATTR_ADDRESS, - ATTR_AT_LOC_SINCE, - ATTR_DRIVING, - ATTR_LAST_SEEN, - ATTR_PLACE, - ATTR_SPEED, - ATTR_WIFI_ON, - ATTRIBUTION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DOMAIN, - LOGGER, - SHOW_DRIVING, -) -from .coordinator import Life360DataUpdateCoordinator, Life360Member - -_LOC_ATTRS = ( - "address", - "at_loc_since", - "driving", - "gps_accuracy", - "last_seen", - "latitude", - "longitude", - "place", - "speed", -) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the device tracker platform.""" - coordinator = hass.data[DOMAIN].coordinators[entry.entry_id] - tracked_members = hass.data[DOMAIN].tracked_members - logged_circles = hass.data[DOMAIN].logged_circles - logged_places = hass.data[DOMAIN].logged_places - - @callback - def process_data(new_members_only: bool = True) -> None: - """Process new Life360 data.""" - for circle_id, circle in coordinator.data.circles.items(): - if circle_id not in logged_circles: - logged_circles.append(circle_id) - LOGGER.debug("Circle: %s", circle.name) - - new_places = [] - for place_id, place in circle.places.items(): - if place_id not in logged_places: - logged_places.append(place_id) - new_places.append(place) - if new_places: - msg = f"Places from {circle.name}:" - for place in new_places: - msg += f"\n- name: {place.name}" - msg += f"\n latitude: {place.latitude}" - msg += f"\n longitude: {place.longitude}" - msg += f"\n radius: {place.radius}" - LOGGER.debug(msg) - - new_entities = [] - for member_id, member in coordinator.data.members.items(): - tracked_by_entry = tracked_members.get(member_id) - if new_member := not tracked_by_entry: - tracked_members[member_id] = entry.entry_id - LOGGER.debug("Member: %s (%s)", member.name, entry.unique_id) - if ( - new_member - or tracked_by_entry == entry.entry_id - and not new_members_only - ): - new_entities.append(Life360DeviceTracker(coordinator, member_id)) - async_add_entities(new_entities) - - process_data(new_members_only=False) - entry.async_on_unload(coordinator.async_add_listener(process_data)) - - -class Life360DeviceTracker( - CoordinatorEntity[Life360DataUpdateCoordinator], TrackerEntity -): - """Life360 Device Tracker.""" - - _attr_attribution = ATTRIBUTION - _attr_unique_id: str - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, coordinator: Life360DataUpdateCoordinator, member_id: str - ) -> None: - """Initialize Life360 Entity.""" - super().__init__(coordinator) - self._attr_unique_id = member_id - - self._data: Life360Member | None = coordinator.data.members[member_id] - self._prev_data = self._data - - self._name = self._data.name - self._attr_entity_picture = self._data.entity_picture - - # Server sends a pair of address values on alternate updates. Keep the pair of - # values so they can be combined into the one address attribute. - # The pair will either be two different address values, or one address and a - # copy of the Place value (if the Member is in a Place.) In the latter case we - # won't duplicate the Place name, but rather just use one the address value. Use - # the value of None to hold one of the "slots" in the list so we'll know not to - # expect another address value. - if (address := self._data.address) == self._data.place: - address = None - self._addresses = [address] - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo(identifiers={(DOMAIN, self._attr_unique_id)}, name=self._name) - - @property - def _options(self) -> Mapping[str, Any]: - """Shortcut to config entry options.""" - return cast(Mapping[str, Any], self.coordinator.config_entry.options) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - # Get a shortcut to this Member's data. This needs to be updated each time since - # coordinator provides a new Life360Member object each time, and it's possible - # that there is no data for this Member on some updates. - if self.available: - self._data = self.coordinator.data.members.get(self._attr_unique_id) - else: - self._data = None - - if self._data: - # Check if we should effectively throw out new location data. - last_seen = self._data.last_seen - prev_seen = self._prev_data.last_seen - max_gps_acc = self._options.get(CONF_MAX_GPS_ACCURACY) - bad_last_seen = last_seen < prev_seen - bad_accuracy = ( - max_gps_acc is not None and self.location_accuracy > max_gps_acc - ) - if bad_last_seen or bad_accuracy: - if bad_last_seen: - LOGGER.warning( - ( - "%s: Ignoring location update because " - "last_seen (%s) < previous last_seen (%s)" - ), - self.entity_id, - last_seen, - prev_seen, - ) - if bad_accuracy: - LOGGER.warning( - ( - "%s: Ignoring location update because " - "expected GPS accuracy (%0.1f) is not met: %i" - ), - self.entity_id, - max_gps_acc, - self.location_accuracy, - ) - # Overwrite new location related data with previous values. - for attr in _LOC_ATTRS: - setattr(self._data, attr, getattr(self._prev_data, attr)) - - else: - # Process address field. - # Check if we got the name of a Place, which we won't use. - if (address := self._data.address) == self._data.place: - address = None - if last_seen != prev_seen: - # We have new location data, so we might have a new pair of address - # values. - if address not in self._addresses: - # We do. - # Replace the old values with the first value of the new pair. - self._addresses = [address] - elif self._data.address != self._prev_data.address: - # Location data didn't change in general, but the address field did. - # There are three possibilities: - # 1. The new value is one of the pair we've already seen before. - # 2. The new value is the second of the pair we haven't seen yet. - # 3. The new value is the first of a new pair of values. - if address not in self._addresses: - if len(self._addresses) < 2: - self._addresses.append(address) - else: - self._addresses = [address] - - self._prev_data = self._data - - super()._handle_coordinator_update() - - @property - def force_update(self) -> bool: - """Return True if state updates should be forced. - - Overridden because CoordinatorEntity sets `should_poll` to False, - which causes TrackerEntity to set `force_update` to True. - """ - return False - - @property - def entity_picture(self) -> str | None: - """Return the entity picture to use in the frontend, if any.""" - if self._data: - self._attr_entity_picture = self._data.entity_picture - return super().entity_picture - - @property - def battery_level(self) -> int | None: - """Return the battery level of the device. - - Percentage from 0-100. - """ - if not self._data: - return None - return self._data.battery_level - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - - @property - def location_accuracy(self) -> int: - """Return the location accuracy of the device. - - Value in meters. - """ - if not self._data: - return 0 - return self._data.gps_accuracy - - @property - def driving(self) -> bool: - """Return if driving.""" - if not self._data: - return False - if (driving_speed := self._options.get(CONF_DRIVING_SPEED)) is not None: - if self._data.speed >= driving_speed: - return True - return self._data.driving - - @property - def location_name(self) -> str | None: - """Return a location name for the current location of the device.""" - if self._options.get(SHOW_DRIVING) and self.driving: - return "Driving" - return None - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - if not self._data: - return None - return self._data.latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - if not self._data: - return None - return self._data.longitude - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - if not self._data: - return { - ATTR_ADDRESS: None, - ATTR_AT_LOC_SINCE: None, - ATTR_BATTERY_CHARGING: None, - ATTR_DRIVING: None, - ATTR_LAST_SEEN: None, - ATTR_PLACE: None, - ATTR_SPEED: None, - ATTR_WIFI_ON: None, - } - - # Generate address attribute from pair of address values. - # There may be two, one or no values. If there are two, sort the strings since - # one value is typically a numbered street address and the other is a street, - # town or state name, and it's helpful to start with the more detailed address - # value. Also, sorting helps to generate the same result if we get a location - # update, and the same pair is sent afterwards, but where the value that comes - # first is swapped vs the order they came in before the update. - address1: str | None = None - address2: str | None = None - with suppress(IndexError): - address1 = self._addresses[0] - address2 = self._addresses[1] - if address1 and address2: - address: str | None = " / ".join(sorted([address1, address2])) - else: - address = address1 or address2 - - return { - ATTR_ADDRESS: address, - ATTR_AT_LOC_SINCE: self._data.at_loc_since, - ATTR_BATTERY_CHARGING: self._data.battery_charging, - ATTR_DRIVING: self.driving, - ATTR_LAST_SEEN: self._data.last_seen, - ATTR_PLACE: self._data.place, - ATTR_SPEED: self._data.speed, - ATTR_WIFI_ON: self._data.wifi_on, - } diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json index 481d006809d..da304cf4485 100644 --- a/homeassistant/components/life360/manifest.json +++ b/homeassistant/components/life360/manifest.json @@ -1,10 +1,9 @@ { "domain": "life360", "name": "Life360", - "codeowners": ["@pnbruckner"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/life360", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["life360"], - "requirements": ["life360==6.0.1"] + "requirements": [] } diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index 343d9e95bb8..885b3203f52 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -1,51 +1,8 @@ { - "config": { - "step": { - "user": { - "title": "Configure Life360 Account", - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - }, - "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "button": { - "update_location": { - "name": "Update Location" - } - } - }, - "options": { - "step": { - "init": { - "title": "Account Options", - "data": { - "limit_gps_acc": "Limit GPS accuracy", - "max_gps_accuracy": "Max GPS accuracy (meters)", - "set_drive_speed": "Set driving speed threshold", - "driving_speed": "Driving speed", - "driving": "Show driving as state" - } - } + "issues": { + "integration_removed": { + "title": "The Life360 integration has been removed", + "description": "The Life360 integration has been removed from Home Assistant.\n\nLife360 has blocked all third-party integrations.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Life360 integration entries]({entries})." } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f04ec579f91..699bdebc61f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -268,7 +268,6 @@ FLOWS = { "led_ble", "lg_soundbar", "lidarr", - "life360", "lifx", "linear_garage_door", "litejet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5a66e7e1f44..b70aad119df 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3093,12 +3093,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "life360": { - "name": "Life360", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "lifx": { "name": "LIFX", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 586b2f23c9e..929adbd137d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1191,9 +1191,6 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.life360 -life360==6.0.1 - # homeassistant.components.osramlightify lightify==1.0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b015b4c39f3..2f8c733c38d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -948,9 +948,6 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.life360 -life360==6.0.1 - # homeassistant.components.linear_garage_door linear-garage-door==0.2.7 diff --git a/tests/components/life360/test_config_flow.py b/tests/components/life360/test_config_flow.py deleted file mode 100644 index 7eec67fc0cc..00000000000 --- a/tests/components/life360/test_config_flow.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Test the Life360 config flow.""" -from unittest.mock import patch - -from life360 import Life360Error, LoginError -import pytest -import voluptuous as vol - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.life360.const import ( - CONF_AUTHORIZATION, - CONF_DRIVING_SPEED, - CONF_MAX_GPS_ACCURACY, - DEFAULT_OPTIONS, - DOMAIN, - SHOW_DRIVING, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - -TEST_USER = "Test@Test.com" -TEST_PW = "password" -TEST_PW_3 = "password_3" -TEST_AUTHORIZATION = "authorization_string" -TEST_AUTHORIZATION_2 = "authorization_string_2" -TEST_AUTHORIZATION_3 = "authorization_string_3" -TEST_MAX_GPS_ACCURACY = "300" -TEST_DRIVING_SPEED = "18" -TEST_SHOW_DRIVING = True - -USER_INPUT = {CONF_USERNAME: TEST_USER, CONF_PASSWORD: TEST_PW} - -TEST_CONFIG_DATA = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW, - CONF_AUTHORIZATION: TEST_AUTHORIZATION, -} -TEST_CONFIG_DATA_2 = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW, - CONF_AUTHORIZATION: TEST_AUTHORIZATION_2, -} -TEST_CONFIG_DATA_3 = { - CONF_USERNAME: TEST_USER, - CONF_PASSWORD: TEST_PW_3, - CONF_AUTHORIZATION: TEST_AUTHORIZATION_3, -} - -USER_OPTIONS = { - "limit_gps_acc": True, - CONF_MAX_GPS_ACCURACY: TEST_MAX_GPS_ACCURACY, - "set_drive_speed": True, - CONF_DRIVING_SPEED: TEST_DRIVING_SPEED, - SHOW_DRIVING: TEST_SHOW_DRIVING, -} -TEST_OPTIONS = { - CONF_MAX_GPS_ACCURACY: float(TEST_MAX_GPS_ACCURACY), - CONF_DRIVING_SPEED: float(TEST_DRIVING_SPEED), - SHOW_DRIVING: TEST_SHOW_DRIVING, -} - - -# ========== Common Fixtures & Functions =============================================== - - -@pytest.fixture(name="life360", autouse=True) -def life360_fixture(): - """Mock life360 config entry setup & unload.""" - with patch( - "homeassistant.components.life360.async_setup_entry", return_value=True - ), patch("homeassistant.components.life360.async_unload_entry", return_value=True): - yield - - -@pytest.fixture -def life360_api(): - """Mock Life360 api.""" - with patch( - "homeassistant.components.life360.config_flow.Life360", autospec=True - ) as mock: - yield mock.return_value - - -def create_config_entry(hass, state=None): - """Create mock config entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONFIG_DATA, - version=1, - state=state, - options=DEFAULT_OPTIONS, - unique_id=TEST_USER.lower(), - ) - config_entry.add_to_hass(hass) - return config_entry - - -# ========== User Flow Tests =========================================================== - - -async def test_user_show_form(hass: HomeAssistant, life360_api) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_not_called() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert not result["errors"] - - schema = result["data_schema"].schema - assert set(schema) == set(USER_INPUT) - # username and password fields should be empty. - keys = list(schema) - for key in USER_INPUT: - assert keys[keys.index(key)].default == vol.UNDEFINED - - -async def test_user_config_flow_success(hass: HomeAssistant, life360_api) -> None: - """Test a successful user config flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.return_value = TEST_AUTHORIZATION - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_USER.lower() - assert result["data"] == TEST_CONFIG_DATA - assert result["options"] == DEFAULT_OPTIONS - - -@pytest.mark.parametrize( - ("exception", "error"), - [(LoginError, "invalid_auth"), (Life360Error, "cannot_connect")], -) -async def test_user_config_flow_error( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture, exception, error -) -> None: - """Test a user config flow with an error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.side_effect = exception("test reason") - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] - assert result["errors"]["base"] == error - - assert "test reason" in caplog.text - - schema = result["data_schema"].schema - assert set(schema) == set(USER_INPUT) - # username and password fields should be prefilled with current values. - keys = list(schema) - for key, val in USER_INPUT.items(): - default = keys[keys.index(key)].default - assert default != vol.UNDEFINED - assert default() == val - - -async def test_user_config_flow_already_configured( - hass: HomeAssistant, life360_api -) -> None: - """Test a user config flow with an account already configured.""" - create_config_entry(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_not_called() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -# ========== Reauth Flow Tests ========================================================= - - -@pytest.mark.parametrize("state", [None, config_entries.ConfigEntryState.LOADED]) -async def test_reauth_config_flow_success( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture, state -) -> None: - """Test a successful reauthorization config flow.""" - config_entry = create_config_entry(hass, state=state) - - # Simulate current username & password are still valid, but authorization string has - # expired, such that getting a new authorization string from server is successful. - life360_api.get_authorization.return_value = TEST_AUTHORIZATION_2 - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data=config_entry.data, - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert "Reauthorization successful" in caplog.text - - assert config_entry.data == TEST_CONFIG_DATA_2 - - -async def test_reauth_config_flow_login_error( - hass: HomeAssistant, life360_api, caplog: pytest.LogCaptureFixture -) -> None: - """Test a reauthorization config flow with a login error.""" - config_entry = create_config_entry(hass) - - # Simulate current username & password are invalid, which results in a form - # requesting new password, with old password as default value. - life360_api.get_authorization.side_effect = LoginError("test reason") - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data=config_entry.data, - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] - assert result["errors"]["base"] == "invalid_auth" - - assert "test reason" in caplog.text - - schema = result["data_schema"].schema - assert len(schema) == 1 - assert "password" in schema - key = list(schema)[0] - assert key.default() == TEST_PW - - # Simulate hitting RECONFIGURE button. - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] - assert result["errors"]["base"] == "invalid_auth" - - # Simulate getting a new, valid password. - life360_api.get_authorization.reset_mock(side_effect=True) - life360_api.get_authorization.return_value = TEST_AUTHORIZATION_3 - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: TEST_PW_3} - ) - await hass.async_block_till_done() - - life360_api.get_authorization.assert_called_once() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert "Reauthorization successful" in caplog.text - - assert config_entry.data == TEST_CONFIG_DATA_3 - - -# ========== Option flow Tests ========================================================= - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test an options flow.""" - config_entry = create_config_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert not result["errors"] - - schema = result["data_schema"].schema - assert set(schema) == set(USER_OPTIONS) - - flow_id = result["flow_id"] - - result = await hass.config_entries.options.async_configure(flow_id, USER_OPTIONS) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == TEST_OPTIONS - - assert config_entry.options == TEST_OPTIONS diff --git a/tests/components/life360/test_init.py b/tests/components/life360/test_init.py new file mode 100644 index 00000000000..0a781f6f2b2 --- /dev/null +++ b/tests/components/life360/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the MyQ Connected Services integration.""" + +from homeassistant.components.life360 import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_life360_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Life360 configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None From e71304580df6e945fc0a3e594b6294a4976bd9da Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 11 Jan 2024 23:25:33 +0100 Subject: [PATCH 0497/1544] Fix Tailwind cover stuck in closing state (#107827) --- homeassistant/components/tailwind/cover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 935fa01eee0..335c3404cdd 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -121,5 +121,6 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): translation_domain=DOMAIN, translation_key="communication_error", ) from exc - self._attr_is_closing = False + finally: + self._attr_is_closing = False await self.coordinator.async_request_refresh() From 0d8073fddfbc929b5c427cdea8c4334971341919 Mon Sep 17 00:00:00 2001 From: dcmeglio <21957250+dcmeglio@users.noreply.github.com> Date: Thu, 11 Jan 2024 20:18:57 -0500 Subject: [PATCH 0498/1544] Bump PySwitchbot to 0.44.0 (#107833) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index d3d84d2cd48..2f92726a6da 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.43.0"] + "requirements": ["PySwitchbot==0.44.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 929adbd137d..cf343422a09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.43.0 +PySwitchbot==0.44.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f8c733c38d..b9e865d1598 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.43.0 +PySwitchbot==0.44.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 69a8f476e81c99f91e23836e33ebee07dffc3a3a Mon Sep 17 00:00:00 2001 From: dcmeglio <21957250+dcmeglio@users.noreply.github.com> Date: Thu, 11 Jan 2024 21:09:50 -0500 Subject: [PATCH 0499/1544] Improved tracking of switchbot opening/closing states (#106741) Co-authored-by: J. Nick Koston --- homeassistant/components/switchbot/cover.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 35083c4b089..4883bf456c0 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -78,6 +78,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to open curtain %s", self._address) self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: @@ -85,6 +87,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to close the curtain %s", self._address) self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -92,6 +96,8 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to stop %s", self._address) self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -100,14 +106,18 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): _LOGGER.debug("Switchbot to move at %d %s", position, self._address) self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() self._attr_current_cover_position = self.parsed_data["position"] self._attr_is_closed = self.parsed_data["position"] <= 20 - self._attr_is_opening = self.parsed_data["inMotion"] + self.async_write_ha_state() From dc10f3c204e0e94e958f3b71c0fcb1caab12e1ce Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 11 Jan 2024 19:16:54 -0700 Subject: [PATCH 0500/1544] Move Guardian valve attributes to diagnostics sensors (#107834) --- homeassistant/components/guardian/sensor.py | 42 +++++++++++++++++++ .../components/guardian/strings.json | 12 ++++++ homeassistant/components/guardian/valve.py | 21 +--------- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 85adaddb7f2..64c70b07b83 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, + UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfTemperature, UnitOfTime, @@ -32,13 +33,18 @@ from . import ( from .const import ( API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, + API_VALVE_STATUS, CONF_UID, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +SENSOR_KIND_AVG_CURRENT = "average_current" SENSOR_KIND_BATTERY = "battery" +SENSOR_KIND_INST_CURRENT = "instantaneous_current" +SENSOR_KIND_INST_CURRENT_DDT = "instantaneous_current_ddt" SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_TRAVEL_COUNT = "travel_count" SENSOR_KIND_UPTIME = "uptime" @@ -75,6 +81,33 @@ PAIRED_SENSOR_DESCRIPTIONS = ( ), ) VALVE_CONTROLLER_DESCRIPTIONS = ( + ValveControllerSensorDescription( + key=SENSOR_KIND_AVG_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["average_current"], + ), + ValveControllerSensorDescription( + key=SENSOR_KIND_INST_CURRENT, + translation_key="instantaneous_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["instantaneous_current"], + ), + ValveControllerSensorDescription( + key=SENSOR_KIND_INST_CURRENT_DDT, + translation_key="instantaneous_current_ddt", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["instantaneous_current_ddt"], + ), ValveControllerSensorDescription( key=SENSOR_KIND_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, @@ -92,6 +125,15 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( api_category=API_SYSTEM_DIAGNOSTICS, value_fn=lambda data: data["uptime"], ), + ValveControllerSensorDescription( + key=SENSOR_KIND_TRAVEL_COUNT, + translation_key="travel_count", + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="revolutions", + api_category=API_VALVE_STATUS, + value_fn=lambda data: data["travel_count"], + ), ) diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json index c426f4f8081..e8622fe9d03 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -33,6 +33,18 @@ } }, "sensor": { + "current": { + "name": "Current" + }, + "instantaneous_current": { + "name": "Instantaneous current" + }, + "instantaneous_current_ddt": { + "name": "Instantaneous current (DDT)" + }, + "travel_count": { + "name": "Travel count" + }, "uptime": { "name": "Uptime" } diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index 94f5ddbee6a..a2b6b5b6ab7 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -1,7 +1,7 @@ """Valves for the Elexa Guardian integration.""" from __future__ import annotations -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import StrEnum from typing import Any @@ -22,13 +22,6 @@ from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescript from .const import API_VALVE_STATUS, DOMAIN from .util import convert_exceptions_to_homeassistant_error -ATTR_AVG_CURRENT = "average_current" -ATTR_CONNECTED_CLIENTS = "connected_clients" -ATTR_INST_CURRENT = "instantaneous_current" -ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" -ATTR_STATION_CONNECTED = "station_connected" -ATTR_TRAVEL_COUNT = "travel_count" - VALVE_KIND_VALVE = "valve" @@ -51,7 +44,6 @@ class ValveControllerValveDescription( ): """Describe a Guardian valve controller valve.""" - extra_state_attributes_fn: Callable[[dict[str, Any]], Mapping[str, Any]] is_closed_fn: Callable[[dict[str, Any]], bool] is_closing_fn: Callable[[dict[str, Any]], bool] is_opening_fn: Callable[[dict[str, Any]], bool] @@ -104,12 +96,6 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( translation_key="valve_controller", device_class=ValveDeviceClass.WATER, api_category=API_VALVE_STATUS, - extra_state_attributes_fn=lambda data: { - ATTR_AVG_CURRENT: data["average_current"], - ATTR_INST_CURRENT: data["instantaneous_current"], - ATTR_INST_CURRENT_DDT: data["instantaneous_current_ddt"], - ATTR_TRAVEL_COUNT: data["travel_count"], - }, is_closed_fn=lambda data: data["state"] == GuardianValveState.CLOSED, is_closing_fn=is_closing, is_opening_fn=is_opening, @@ -151,11 +137,6 @@ class ValveControllerValve(ValveControllerEntity, ValveEntity): self._client = data.client - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - return self.entity_description.extra_state_attributes_fn(self.coordinator.data) - @property def is_closing(self) -> bool: """Return if the valve is closing or not.""" From 83fbcb11ea5af8ff748cbbd11ea1a64e0b3c46c2 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 12 Jan 2024 15:18:44 +0800 Subject: [PATCH 0501/1544] Add YoLink SpeakerHub Service (#107787) * Add YoLink SpeakerHub Service * fix as suggestion * service's params descriptions --- .coveragerc | 1 + homeassistant/components/yolink/__init__.py | 3 + homeassistant/components/yolink/const.py | 5 ++ homeassistant/components/yolink/services.py | 67 +++++++++++++++++++ homeassistant/components/yolink/services.yaml | 44 ++++++++++++ homeassistant/components/yolink/strings.json | 38 +++++++++++ 6 files changed, 158 insertions(+) create mode 100644 homeassistant/components/yolink/services.py create mode 100644 homeassistant/components/yolink/services.yaml diff --git a/.coveragerc b/.coveragerc index a3c6737a722..cf2210ec1a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1615,6 +1615,7 @@ omit = homeassistant/components/yolink/lock.py homeassistant/components/yolink/number.py homeassistant/components/yolink/sensor.py + homeassistant/components/yolink/services.py homeassistant/components/yolink/siren.py homeassistant/components/yolink/switch.py homeassistant/components/youless/__init__.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 16094816f17..473c85d563a 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -26,6 +26,7 @@ from . import api from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS +from .services import async_register_services SCAN_INTERVAL = timedelta(minutes=5) @@ -146,6 +147,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async_register_services(hass, entry) + async def async_yolink_unload(event) -> None: """Unload yolink.""" await yolink_home.async_unload() diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9fc4dac8ada..3d341c8b4fb 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,5 +7,10 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +ATTR_TARGET_DEVICE = "target_device" +ATTR_VOLUME = "volume" +ATTR_TEXT_MESSAGE = "message" +ATTR_REPEAT = "repeat" +ATTR_TONE = "tone" YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py new file mode 100644 index 00000000000..bb2c660ef56 --- /dev/null +++ b/homeassistant/components/yolink/services.py @@ -0,0 +1,67 @@ +"""YoLink services.""" + +import voluptuous as vol +from yolink.client_request import ClientRequest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import ( + ATTR_REPEAT, + ATTR_TARGET_DEVICE, + ATTR_TEXT_MESSAGE, + ATTR_TONE, + ATTR_VOLUME, + DOMAIN, +) + +SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" + + +def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Register services for YoLink integration.""" + + async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: + """Handle Speaker Hub audio play call.""" + service_data = service_call.data + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(service_data[ATTR_TARGET_DEVICE]) + if device_entry is not None: + home_store = hass.data[DOMAIN][entry.entry_id] + for identifier in device_entry.identifiers: + if ( + device_coordinator := home_store.device_coordinators.get( + identifier[1] + ) + ) is not None: + tone_param = service_data[ATTR_TONE].capitalize() + play_request = ClientRequest( + "playAudio", + { + ATTR_TONE: tone_param, + ATTR_TEXT_MESSAGE: service_data[ATTR_TEXT_MESSAGE], + ATTR_VOLUME: service_data[ATTR_VOLUME], + ATTR_REPEAT: service_data[ATTR_REPEAT], + }, + ) + await device_coordinator.device.call_device(play_request) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_PLAY_ON_SPEAKER_HUB, + schema=vol.Schema( + { + vol.Required(ATTR_TARGET_DEVICE): cv.string, + vol.Required(ATTR_TONE): cv.string, + vol.Required(ATTR_TEXT_MESSAGE): cv.string, + vol.Required(ATTR_VOLUME): vol.All( + vol.Coerce(int), vol.Range(min=0, max=15) + ), + vol.Optional(ATTR_REPEAT, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=10) + ), + }, + ), + service_func=handle_speaker_hub_play_call, + ) diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml new file mode 100644 index 00000000000..939eba3e7f5 --- /dev/null +++ b/homeassistant/components/yolink/services.yaml @@ -0,0 +1,44 @@ +# SpeakerHub service +play_on_speaker_hub: + fields: + target_device: + required: true + selector: + device: + filter: + - integration: yolink + manufacturer: YoLink + model: SpeakerHub + + message: + required: true + example: hello, yolink + selector: + text: + tone: + required: true + default: "tip" + selector: + select: + options: + - "emergency" + - "alert" + - "warn" + - "tip" + translation_key: speaker_tone + volume: + required: true + default: 8 + selector: + number: + min: 0 + max: 15 + step: 1 + repeat: + required: true + default: 0 + selector: + number: + min: 0 + max: 10 + step: 1 diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index e1fa09429aa..9661abe096c 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -75,5 +75,43 @@ "name": "Volume" } } + }, + "services": { + "play_on_speaker_hub": { + "name": "Play on SpeakerHub", + "description": "Convert text to audio play on YoLink SpeakerHub", + "fields": { + "target_device": { + "name": "SpeakerHub Device", + "description": "SpeakerHub Device" + }, + "message": { + "name": "Text message", + "description": "Text message to be played." + }, + "tone": { + "name": "Tone", + "description": "Tone before playing audio." + }, + "volume": { + "name": "Volume", + "description": "Speaker volume during playback." + }, + "repeat": { + "name": "Repeat", + "description": "The amount of times the text will be repeated." + } + } + } + }, + "selector": { + "speaker_tone": { + "options": { + "emergency": "Emergency", + "alert": "Alert", + "warn": "Warn", + "tip": "Tip" + } + } } } From e715d6a7a1ca504d9b34ff2906520a2bc97d41d8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 12 Jan 2024 08:44:38 +0100 Subject: [PATCH 0502/1544] Fix duplicated resource issue in System Monitor (#107671) * Fix duplicated resource issue * Only slug the argument --- homeassistant/components/systemmonitor/sensor.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 28929d07a7c..da6e35238ec 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -405,7 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -425,7 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -449,7 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -478,10 +478,13 @@ async def async_setup_entry( # of mount points automatically discovered for resource in legacy_resources: if resource.startswith("disk_"): + check_resource = slugify(resource) _LOGGER.debug( - "Check resource %s already loaded in %s", resource, loaded_resources + "Check resource %s already loaded in %s", + check_resource, + loaded_resources, ) - if resource not in loaded_resources: + if check_resource not in loaded_resources: split_index = resource.rfind("_") _type = resource[:split_index] argument = resource[split_index + 1 :] From 6612de9a6d1818d457f7b3c0c820f14a6699b2b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:16:30 -1000 Subject: [PATCH 0503/1544] Bump govee-ble to 0.27.3 (#107839) changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.27.2...v0.27.3 --- 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 77d67547b78..1cfa367ebe7 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.27.2"] + "requirements": ["govee-ble==0.27.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf343422a09..95a1a0248ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -949,7 +949,7 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.27.2 +govee-ble==0.27.3 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9e865d1598..bd4b3eb54b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -766,7 +766,7 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.27.2 +govee-ble==0.27.3 # homeassistant.components.gree greeclimate==1.4.1 From 751b459f8052030206ca0377e63d5d7802f1efc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jan 2024 09:26:01 +0100 Subject: [PATCH 0504/1544] Bump actions/cache from 3.3.2 to 3.3.3 (#107840) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d77d2166e1d..008dacd57a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -231,7 +231,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.2 + uses: actions/cache@v3.3.3 with: path: venv key: >- @@ -246,7 +246,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.2 + uses: actions/cache@v3.3.3 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -276,7 +276,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -285,7 +285,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -316,7 +316,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -325,7 +325,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -355,7 +355,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -364,7 +364,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -454,7 +454,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.2 + uses: actions/cache@v3.3.3 with: path: venv lookup-only: true @@ -463,7 +463,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.2 + uses: actions/cache@v3.3.3 with: path: ${{ env.PIP_CACHE }} key: >- @@ -517,7 +517,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -549,7 +549,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -582,7 +582,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -633,7 +633,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -641,7 +641,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v3.3.3 with: path: .mypy_cache key: >- @@ -708,7 +708,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -860,7 +860,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true @@ -984,7 +984,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.2 + uses: actions/cache/restore@v3.3.3 with: path: venv fail-on-cache-miss: true From 72618c1bf27c65e50e71ab3d2019a1326d4cd110 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jan 2024 09:31:13 +0100 Subject: [PATCH 0505/1544] Bump github/codeql-action from 3.22.12 to 3.23.0 (#107628) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1dc36b9fa34..401580becfd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.22.12 + uses: github/codeql-action/init@v3.23.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.22.12 + uses: github/codeql-action/analyze@v3.23.0 with: category: "/language:python" From b6dfa1fa7ccfe163b8dbc23108dc0e7b6e93166e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:32:38 -1000 Subject: [PATCH 0506/1544] Bump nexia to 2.0.8 (#107835) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 5464a241b7a..0013cd63de1 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.7"] + "requirements": ["nexia==2.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 95a1a0248ab..6da25c9869d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1327,7 +1327,7 @@ nettigo-air-monitor==2.2.2 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.7 +nexia==2.0.8 # homeassistant.components.nextcloud nextcloudmonitor==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd4b3eb54b3..b929aa4bba6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1048,7 +1048,7 @@ netmap==0.7.0.2 nettigo-air-monitor==2.2.2 # homeassistant.components.nexia -nexia==2.0.7 +nexia==2.0.8 # homeassistant.components.nextcloud nextcloudmonitor==1.4.0 From bef596d0dd04f2dc5600fd7c02ea68c0a556164b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:33:33 -1000 Subject: [PATCH 0507/1544] Migrate unifiprotect descriptions to be kw_only (#107832) --- .../components/unifiprotect/binary_sensor.py | 4 ++-- homeassistant/components/unifiprotect/button.py | 2 +- homeassistant/components/unifiprotect/models.py | 11 ++++++----- homeassistant/components/unifiprotect/number.py | 15 +++++---------- homeassistant/components/unifiprotect/select.py | 2 +- homeassistant/components/unifiprotect/sensor.py | 9 ++++----- homeassistant/components/unifiprotect/switch.py | 2 +- homeassistant/components/unifiprotect/text.py | 2 +- 8 files changed, 21 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d5baaa3b5bf..66767224de2 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -43,14 +43,14 @@ _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index c0872e03f03..cee4280507d 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -28,7 +28,7 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectButtonEntityDescription( ProtectSetableKeysMixin[T], ButtonEntityDescription ): diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 08f5c2075e6..f7da2f781ff 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import Enum import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel @@ -35,7 +35,7 @@ class PermRequired(int, Enum): DELETE = 3 -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -100,7 +100,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): return bool(get_nested_attr(obj, ufp_required_field)) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" @@ -110,7 +110,8 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Return value from UniFi Protect device.""" if self.ufp_event_obj is not None: - return cast(Event, getattr(obj, self.ufp_event_obj, None)) + event: Event | None = getattr(obj, self.ufp_event_obj, None) + return event return None def get_is_on(self, obj: T, event: Event | None) -> bool: @@ -119,7 +120,7 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return event is not None and self.get_ufp_value(obj) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): """Mixin for settable values.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 04f779ecbd7..90201da98d8 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -30,22 +30,17 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) -class NumberKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class ProtectNumberEntityDescription( + ProtectSetableKeysMixin[T], NumberEntityDescription +): + """Describes UniFi Protect Number entity.""" ufp_max: int | float ufp_min: int | float ufp_step: int | float -@dataclass(frozen=True) -class ProtectNumberEntityDescription( - ProtectSetableKeysMixin[T], NumberEntityDescription, NumberKeysMixin -): - """Describes UniFi Protect Number entity.""" - - def _get_pir_duration(obj: Light) -> int: return int(obj.light_device_settings.pir_duration.total_seconds()) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index a3688916959..eed49ac87e7 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -92,7 +92,7 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSelectEntityDescription( ProtectSetableKeysMixin[T], SelectEntityDescription ): diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 212c0d5245b..5a52b45b62d 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSensorEntityDescription( ProtectRequiredKeysMixin[T], SensorEntityDescription ): @@ -65,13 +65,12 @@ class ProtectSensorEntityDescription( def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" value = super().get_ufp_value(obj) - - if isinstance(value, float) and self.precision: - value = round(value, self.precision) + if self.precision and value is not None: + return round(value, self.precision) return value -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 57089157169..ace769d7c43 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -33,7 +33,7 @@ ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectSwitchEntityDescription( ProtectSetableKeysMixin[T], SwitchEntityDescription ): diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 7fb66d7c8e3..cfc4ad5702f 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -25,7 +25,7 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" From 46a06bc8cd14210f4a78369e69ca8d3b5126e30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikail=20Tun=C3=A7?= Date: Fri, 12 Jan 2024 08:34:18 +0000 Subject: [PATCH 0508/1544] Restrict Version Disclosure to Authenticated Requests in Home Assistant (#107458) --- homeassistant/components/websocket_api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 2c86a26efc9..8ca2112191d 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -42,7 +42,7 @@ def auth_ok_message() -> dict[str, str]: def auth_required_message() -> dict[str, str]: """Return an auth_required message.""" - return {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} + return {"type": TYPE_AUTH_REQUIRED} def auth_invalid_message(message: str) -> dict[str, str]: From fb0dad66dbb259bdc8c5f91140cbf17948d36d53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:34:49 -1000 Subject: [PATCH 0509/1544] Add jinja_pass_arg to reserved template names (#107822) --- homeassistant/helpers/template.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index db4e333fa1a..79ef6137f52 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -97,7 +97,12 @@ _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 _IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") -_RESERVED_NAMES = {"contextfunction", "evalcontextfunction", "environmentfunction"} +_RESERVED_NAMES = { + "contextfunction", + "evalcontextfunction", + "environmentfunction", + "jinja_pass_arg", +} _GROUP_DOMAIN_PREFIX = "group." _ZONE_DOMAIN_PREFIX = "zone." From ce11366b9c6dfb35ea2819f7dff02e3ae702f692 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:45:49 -1000 Subject: [PATCH 0510/1544] Bump bluetooth deps (#107816) --- homeassistant/components/bluetooth/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 6 +++--- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/bluetooth/conftest.py | 8 ++++---- tests/components/bluetooth/test_scanner.py | 1 + 8 files changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index e7145d0385a..1551a83ad6a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,9 +17,9 @@ "bleak==0.21.1", "bleak-retry-connector==3.4.0", "bluetooth-adapters==0.17.0", - "bluetooth-auto-recovery==1.2.3", + "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.0.2" + "habluetooth==2.1.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ffd6b4bfa1a..0a112354f08 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.4.0 bleak==0.21.1 bluetooth-adapters==0.17.0 -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 @@ -24,10 +24,10 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.0.2 +habluetooth==2.1.0 hass-nabucasa==0.75.1 hassil==1.5.2 -home-assistant-bluetooth==1.11.0 +home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240104.0 home-assistant-intents==2024.1.2 httpx==0.26.0 diff --git a/pyproject.toml b/pyproject.toml index ddaca9f5b83..1e5c201e24b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.26.0", - "home-assistant-bluetooth==1.11.0", + "home-assistant-bluetooth==1.12.0", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.3.0", diff --git a/requirements.txt b/requirements.txt index 6246d45be10..a5d3bc50802 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.26.0 -home-assistant-bluetooth==1.11.0 +home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6da25c9869d..77e47cd6a7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -569,7 +569,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -1001,7 +1001,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.2 +habluetooth==2.1.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b929aa4bba6..c30aedef3f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,7 +481,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.17.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -806,7 +806,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.2 +habluetooth==2.1.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 4ec6c4e5388..a7e776f3a26 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -55,7 +55,7 @@ def macos_adapter(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Darwin", - ): + ), patch("habluetooth.scanner.SYSTEM", "Darwin"): yield @@ -65,7 +65,7 @@ def windows_adapter(): with patch( "bluetooth_adapters.systems.platform.system", return_value="Windows", - ): + ), patch("habluetooth.scanner.SYSTEM", "Windows"): yield @@ -81,7 +81,7 @@ def no_adapter_fixture(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux", - ), patch( + ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -102,7 +102,7 @@ def one_adapter_fixture(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux", - ), patch( + ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 7673acb80dc..837c058fa6b 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -571,6 +571,7 @@ async def test_restart_takes_longer_than_watchdog_time( assert "already restarting" in caplog.text +@pytest.mark.skipif("platform.system() != 'Darwin'") async def test_setup_and_stop_macos( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None ) -> None: From f8318bbbc7dc3fecff6c46105675e04d95463832 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 12 Jan 2024 09:47:08 +0100 Subject: [PATCH 0511/1544] Fix cloud tts loading (#107714) --- homeassistant/components/cloud/__init__.py | 20 ++++---- tests/components/cloud/conftest.py | 33 ++++++++---- tests/components/cloud/test_http_api.py | 4 +- tests/components/cloud/test_init.py | 20 +++++++- tests/components/cloud/test_system_health.py | 1 + tests/components/cloud/test_tts.py | 54 +++++++++++++++++++- 6 files changed, 108 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 76369c07e8e..cdaae0d6272 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -294,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } async def _on_start() -> None: - """Discover platforms.""" + """Handle cloud started after login.""" nonlocal loaded # Prevent multiple discovery @@ -302,14 +302,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - tts_info = {"platform_loaded": tts_platform_loaded} - - await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) - await tts_platform_loaded.wait() - - # The config entry should be loaded after the legacy tts platform is loaded - # to make sure that the tts integration is setup before we try to migrate - # old assist pipelines in the cloud stt entity. await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) async def _on_connect() -> None: @@ -338,6 +330,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: account_link.async_setup(hass) + hass.async_create_task( + async_load_platform( + hass, + Platform.TTS, + DOMAIN, + {"platform_loaded": tts_platform_loaded}, + config, + ) + ) + async_call_later( hass=hass, delay=timedelta(hours=STARTUP_REPAIR_DELAY), diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 42852b15206..1e1877ae13c 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -76,16 +76,9 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: # Attributes that we mock with default values. - mock_cloud.id_token = jwt.encode( - { - "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", - "cognito:username": "abcdefghjkl", - }, - "test", - ) - mock_cloud.access_token = "test_access_token" - mock_cloud.refresh_token = "test_refresh_token" + mock_cloud.id_token = None + mock_cloud.access_token = None + mock_cloud.refresh_token = None # Properties that we keep as properties. @@ -122,11 +115,31 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: When called, it should call the on_start callback. """ + mock_cloud.id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + mock_cloud.access_token = "test_access_token" + mock_cloud.refresh_token = "test_refresh_token" on_start_callback = mock_cloud.register_on_start.call_args[0][0] await on_start_callback() mock_cloud.login.side_effect = mock_login + async def mock_logout() -> None: + """Mock logout.""" + mock_cloud.id_token = None + mock_cloud.access_token = None + mock_cloud.refresh_token = None + await mock_cloud.stop() + await mock_cloud.client.logout_cleanups() + + mock_cloud.logout.side_effect = mock_logout + yield mock_cloud diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 29930632691..409d86d6e37 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -113,8 +113,8 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: }, ) await hass.async_block_till_done() - on_start_callback = cloud.register_on_start.call_args[0][0] - await on_start_callback() + await cloud.login("test-user", "test-pass") + cloud.login.reset_mock() async def test_google_actions_sync( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 850f8e12e02..c537169bf01 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -19,7 +19,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import Unauthorized from homeassistant.setup import async_setup_component -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: @@ -230,6 +230,7 @@ async def test_async_get_or_create_cloudhook( """Test async_get_or_create_cloudhook.""" assert await async_setup_component(hass, "cloud", {"cloud": {}}) await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") webhook_id = "mock-webhook-id" cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" @@ -262,7 +263,7 @@ async def test_async_get_or_create_cloudhook( async_create_cloudhook_mock.assert_not_called() # Simulate logged out - cloud.id_token = None + await cloud.logout() # Not logged in with pytest.raises(CloudNotAvailable): @@ -274,3 +275,18 @@ async def test_async_get_or_create_cloudhook( # Not connected with pytest.raises(CloudNotConnected): await async_get_or_create_cloudhook(hass, webhook_id) + + +async def test_cloud_logout( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test cloud setup with existing config entry when user is logged out.""" + assert cloud.is_logged_in is False + + mock_config_entry = MockConfigEntry(domain=DOMAIN) + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + assert cloud.is_logged_in is False diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 9f1af8aaeb4..5480cd557fd 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -42,6 +42,7 @@ async def test_cloud_system_health( }, ) await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") cloud.remote.snitun_server = "us-west-1" cloud.remote.certificate_status = CertificateStatus.READY diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index dc32747182d..4069edcb744 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError import pytest import voluptuous as vol @@ -189,3 +189,55 @@ async def test_get_tts_audio( assert mock_process_tts.call_args.kwargs["language"] == "en-US" assert mock_process_tts.call_args.kwargs["gender"] == "female" assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ], +) +async def test_get_tts_audio_logged_out( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + cloud: MagicMock, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Test cloud get tts audio when user is logged out.""" + mock_process_tts = AsyncMock( + side_effect=VoiceTokenError("No token!"), + ) + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_client() + + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" From e7628d23d2383b745e259233101a2cdb0e4644cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 12 Jan 2024 09:55:28 +0100 Subject: [PATCH 0512/1544] Don't include position in binary valve attributes (#107531) --- homeassistant/components/valve/__init__.py | 5 +- .../components/valve/snapshots/test_init.ambr | 56 +++++++++++++++++++ tests/components/valve/test_init.py | 21 +++++-- 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 tests/components/valve/snapshots/test_init.ambr diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 9521d597303..c04e25355ff 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -186,9 +186,10 @@ class ValveEntity(Entity): @final @property - def state_attributes(self) -> dict[str, Any]: + def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - + if not self.reports_position: + return None return {ATTR_CURRENT_POSITION: self.current_valve_position} @property diff --git a/tests/components/valve/snapshots/test_init.ambr b/tests/components/valve/snapshots/test_init.ambr new file mode 100644 index 00000000000..b46d76b6f0c --- /dev/null +++ b/tests/components/valve/snapshots/test_init.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_valve_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_valve_setup.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 50, + 'friendly_name': 'Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_valve_setup.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'restored': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_valve_setup.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'restored': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index 08b0771da8e..6f5c49830bb 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -2,6 +2,7 @@ from collections.abc import Generator import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.valve import ( DOMAIN, @@ -193,26 +194,34 @@ def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: async def test_valve_setup( - hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] + hass: HomeAssistant, + mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]], + snapshot: SnapshotAssertion, ) -> None: """Test setup and tear down of valve platform and entity.""" config_entry = mock_config_entry[0] assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entity_id = mock_config_entry[1][0].entity_id assert config_entry.state == ConfigEntryState.LOADED - assert hass.states.get(entity_id) + for entity in mock_config_entry[1]: + entity_id = entity.entity_id + state = hass.states.get(entity_id) + assert state + assert state == snapshot assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED - entity_state = hass.states.get(entity_id) - assert entity_state - assert entity_state.state == STATE_UNAVAILABLE + for entity in mock_config_entry[1]: + entity_id = entity.entity_id + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + assert state == snapshot async def test_services( From 79254c68673b9b5e86d82b042dbebc14b4606c71 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 12 Jan 2024 09:56:13 +0100 Subject: [PATCH 0513/1544] Fix "not-logged" edge cases for Comelit VEDO (#107741) --- homeassistant/components/comelit/coordinator.py | 8 ++------ homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 6559e2ffb87..4ff75ba5307 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -81,15 +81,11 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: await self.api.login() return await self._async_update_system_data() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - await self.api.close() - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: + raise UpdateFailed(repr(err)) from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - return {} - @abstractmethod async def _async_update_system_data(self) -> dict[str, Any]: """Class method for updating data.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8b50ccdf767..8c47564b165 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.7.0"] + "requirements": ["aiocomelit==0.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77e47cd6a7c..45b67c3dccb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.7.0 +aiocomelit==0.7.3 # homeassistant.components.dhcp aiodiscover==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c30aedef3f0..fc97d939166 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.7.0 +aiocomelit==0.7.3 # homeassistant.components.dhcp aiodiscover==1.6.0 From b12291633ca04c76c801123f5192d4e5c2e2b9f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 22:56:52 -1000 Subject: [PATCH 0514/1544] Fix ld2410_ble not being able to setup because it has a stale connection (#107754) --- homeassistant/components/ld2410_ble/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index e127a4a9836..57e3dfa4617 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -2,7 +2,11 @@ import logging -from bleak_retry_connector import BleakError, close_stale_connections, get_device +from bleak_retry_connector import ( + BleakError, + close_stale_connections_by_address, + get_device, +) from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth @@ -24,6 +28,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LD2410 BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] + + await close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), True ) or await get_device(address) @@ -32,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find LD2410B device with address {address}" ) - await close_stale_connections(ble_device) - ld2410_ble = LD2410BLE(ble_device) coordinator = LD2410BLECoordinator(hass, ld2410_ble) From 4b7a313ecec339887537c6954e734aa7cf28c1e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Jan 2024 23:21:26 -1000 Subject: [PATCH 0515/1544] Use identity checks for CoreState (#107846) Some of the checks used ==, and some used is. Switch everything to is as its faster --- homeassistant/components/automation/__init__.py | 2 +- homeassistant/components/cloud/google_config.py | 4 ++-- homeassistant/components/dsmr/sensor.py | 6 +++--- homeassistant/components/emulated_roku/binding.py | 2 +- .../components/generic_thermostat/climate.py | 2 +- .../components/google_travel_time/sensor.py | 2 +- homeassistant/components/homekit/__init__.py | 2 +- .../components/homekit_controller/connection.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/mqtt/client.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 2 +- homeassistant/components/oralb/__init__.py | 2 +- homeassistant/components/rflink/__init__.py | 2 +- homeassistant/components/simplisafe/__init__.py | 2 +- homeassistant/components/speedtestdotnet/__init__.py | 2 +- homeassistant/components/switchbot/coordinator.py | 2 +- homeassistant/components/template/coordinator.py | 2 +- homeassistant/components/toon/__init__.py | 2 +- homeassistant/components/waze_travel_time/sensor.py | 2 +- homeassistant/components/xiaomi_ble/__init__.py | 2 +- homeassistant/components/zwave_js/update.py | 2 +- homeassistant/config_entries.py | 2 +- homeassistant/core.py | 10 +++++----- homeassistant/helpers/discovery_flow.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/start.py | 2 +- homeassistant/helpers/storage.py | 6 +++--- tests/components/template/test_sensor.py | 2 +- tests/conftest.py | 2 +- tests/helpers/test_start.py | 12 ++++++------ tests/test_core.py | 2 +- 31 files changed, 45 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index efad44b15ef..05f732565e8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -721,7 +721,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self._is_enabled = True # HomeAssistant is starting up - if self.hass.state != CoreState.not_running: + if self.hass.state is not CoreState.not_running: self._async_detach_triggers = await self._async_attach_triggers(False) self.async_write_ha_state() return diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index c11ec47b2e5..b64ec558389 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -412,7 +412,7 @@ class CloudGoogleConfig(AbstractConfig): if ( not self.enabled or not self._cloud.is_logged_in - or self.hass.state != CoreState.running + or self.hass.state is not CoreState.running ): return @@ -435,7 +435,7 @@ class CloudGoogleConfig(AbstractConfig): if ( not self.enabled or not self._cloud.is_logged_in - or self.hass.state != CoreState.running + or self.hass.state is not CoreState.running ): return diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3e26ee1ea62..79136a27f16 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -614,7 +614,7 @@ async def async_setup_entry( transport = None protocol = None - while hass.state == CoreState.not_running or hass.is_running: + while hass.state is CoreState.not_running or hass.is_running: # Start DSMR asyncio.Protocol reader # Reflect connected state in devices state by setting an @@ -641,7 +641,7 @@ async def async_setup_entry( await protocol.wait_closed() # Unexpected disconnect - if hass.state == CoreState.not_running or hass.is_running: + if hass.state is CoreState.not_running or hass.is_running: stop_listener() transport = None @@ -673,7 +673,7 @@ async def async_setup_entry( update_entities_telegram(None) if stop_listener and ( - hass.state == CoreState.not_running or hass.is_running + hass.state is CoreState.not_running or hass.is_running ): stop_listener() diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index 1d233c9ed81..3559c0da99b 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -155,7 +155,7 @@ class EmulatedRoku: ) # start immediately if already running - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await emulated_roku_start(None) else: self._unsub_start_listener = self.hass.bus.async_listen_once( diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index c9fcde87162..03a98401668 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -270,7 +270,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self.hass.create_task(self._check_switch_initial_state()) - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: _async_startup() else: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 06a50dab854..95eb965a4ff 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -93,7 +93,7 @@ class GoogleTravelTimeSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Handle when entity is added.""" - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.first_update ) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index cd90c4acf60..5812bc122c7 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -353,7 +353,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][entry.entry_id] = entry_data - if hass.state == CoreState.running: + if hass.state is CoreState.running: await homekit.async_start() else: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4a5a4953c4b..c127c6dd95e 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -261,7 +261,7 @@ class HKDevice: # Ideally we would know which entities we are about to add # so we only poll those chars but that is not possible # yet. - attempts = None if self.hass.state == CoreState.running else 1 + attempts = None if self.hass.state is CoreState.running else 1 if ( transport == Transport.BLE and pairing.accessories diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 89f0a992ff1..bca1c7f6f0e 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -404,7 +404,7 @@ class KodiEntity(MediaPlayerEntity): # If Home Assistant is already in a running state, start the watchdog # immediately, else trigger it after Home Assistant has finished starting. - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await start_watchdog() else: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_watchdog) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 7d102e3a32f..14a18354b01 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -413,7 +413,7 @@ class MQTT: ) self._pending_unsubscribes: set[str] = set() # topic - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: self._ha_started.set() else: diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 0dafff996d0..726b3fa3db8 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -179,7 +179,7 @@ class NmapDeviceScanner: seconds=cv.positive_float(config[CONF_CONSIDER_HOME]) ) self._scan_lock = asyncio.Lock() - if self._hass.state == CoreState.running: + if self._hass.state is CoreState.running: await self._async_start_scanner() return diff --git a/homeassistant/components/oralb/__init__.py b/homeassistant/components/oralb/__init__.py index 4a4d06cabbb..23a022effef 100644 --- a/homeassistant/components/oralb/__init__.py +++ b/homeassistant/components/oralb/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - hass.state == CoreState.running + hass.state is CoreState.running and data.poll_needed(service_info, last_poll) and bool( async_ble_device_from_address( diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 60e2b0fef58..42b6d9a3ecf 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -254,7 +254,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) # If HA is not stopping, initiate new connection - if hass.state != CoreState.stopping: + if hass.state is not CoreState.stopping: _LOGGER.warning("Disconnected from Rflink, reconnecting") hass.async_create_task(connect()) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 772b6f9cbf6..1e558356ea3 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -456,7 +456,7 @@ class SimpliSafe: @callback def _async_process_new_notifications(self, system: SystemType) -> None: """Act on any new system notifications.""" - if self._hass.state != CoreState.running: + if self._hass.state is not CoreState.running: # If HASS isn't fully running yet, it may cause the SIMPLISAFE_NOTIFICATION # event to fire before dependent components (like automation) are fully # ready. If that's the case, skip: diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 67abecdf5d0..1fb368b13c7 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Request a refresh.""" await coordinator.async_request_refresh() - if hass.state == CoreState.running: + if hass.state is CoreState.running: await coordinator.async_config_entry_first_refresh() else: # Running a speed test during startup can prevent diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 39f2a4aa6da..1965867887c 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -65,7 +65,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - self.hass.state == CoreState.running + self.hass.state is CoreState.running and self.device.poll_needed(seconds_since_last_poll) and bool( bluetooth.async_ble_device_from_address( diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 7f24fe731cc..5ac2b7efa67 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -42,7 +42,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def async_setup(self, hass_config: ConfigType) -> None: """Set up the trigger and create entities.""" - if self.hass.state == CoreState.running: + if self.hass.state is CoreState.running: await self._attach_triggers() else: self._unsub_start = self.hass.bus.async_listen_once( diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 59174cff260..36f7ca12b84 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -118,7 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If Home Assistant is already in a running state, register the webhook # immediately, else trigger it after Home Assistant has finished starting. - if hass.state == CoreState.running: + if hass.state is CoreState.running: await coordinator.register_webhook() else: hass.bus.async_listen_once( diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b54d723f95d..ef372e5fd33 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -110,7 +110,7 @@ class WazeTravelTime(SensorEntity): async def async_added_to_hass(self) -> None: """Handle when entity is added.""" - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.first_update ) diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index ced8c3cc471..228a72cb8a5 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -119,7 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only poll if hass is running, we need to poll, # and we actually have a way to connect to the device return ( - hass.state == CoreState.running + hass.state is CoreState.running and data.poll_needed(service_info, last_poll) and bool( async_ble_device_from_address( diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index cf743a3e85a..f3e60f925e6 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -191,7 +191,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): # If hass hasn't started yet, push the next update to the next day so that we # can preserve the offsets we've created between each node - if self.hass.state != CoreState.running: + if self.hass.state is not CoreState.running: self._poll_unsub = async_call_later( self.hass, timedelta(days=1), self._async_update ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d8738e67a04..9e4791fdef6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -455,7 +455,7 @@ class ConfigEntry: wait_time, ) - if hass.state == CoreState.running: + if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( hass, wait_time, self._async_get_setup_again_job(hass) ) diff --git a/homeassistant/core.py b/homeassistant/core.py index 3ad358b0b4a..bb84e7597b6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -425,7 +425,7 @@ class HomeAssistant: This method is a coroutine. """ - if self.state != CoreState.not_running: + if self.state is not CoreState.not_running: raise RuntimeError("Home Assistant is already running") # _async_stop will set this instead of stopping the loop @@ -474,7 +474,7 @@ class HomeAssistant: # Allow automations to set up the start triggers before changing state await asyncio.sleep(0) - if self.state != CoreState.starting: + if self.state is not CoreState.starting: _LOGGER.warning( "Home Assistant startup has been interrupted. " "Its state may be inconsistent" @@ -824,7 +824,7 @@ class HomeAssistant: def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" - if self.state == CoreState.not_running: # just ignore + if self.state is CoreState.not_running: # just ignore return # The future is never retrieved, and we only hold a reference # to it to prevent it from being garbage collected. @@ -844,12 +844,12 @@ class HomeAssistant: if not force: # Some tests require async_stop to run, # regardless of the state of the loop. - if self.state == CoreState.not_running: # just ignore + if self.state is CoreState.not_running: # just ignore return if self.state in [CoreState.stopping, CoreState.final_write]: _LOGGER.info("Additional call to async_stop was ignored") return - if self.state == CoreState.starting: + if self.state is CoreState.starting: # This may not work _LOGGER.warning( "Stopping Home Assistant before startup has completed may fail" diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index c2c9a04b7c3..7ad9caa5a93 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -23,7 +23,7 @@ def async_create_flow( dispatcher: FlowDispatcher | None = None if DISCOVERY_FLOW_DISPATCHER in hass.data: dispatcher = hass.data[DISCOVERY_FLOW_DISPATCHER] - elif hass.state != CoreState.running: + elif hass.state is not CoreState.running: dispatcher = hass.data[DISCOVERY_FLOW_DISPATCHER] = FlowDispatcher(hass) dispatcher.async_setup() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 89eb44a0459..e1c05c21828 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -401,7 +401,7 @@ class EntityPlatform: self._async_cancel_retry_setup = None await self._async_setup_platform(async_create_setup_task, tries) - if hass.state == CoreState.running: + if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( hass, wait_time, setup_again ) diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index fe3bd2b0987..30e8466070e 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -81,7 +81,7 @@ def async_at_started( """ def _is_started(hass: HomeAssistant) -> bool: - return hass.state == CoreState.running + return hass.state is CoreState.running return _async_at_core_state( hass, at_start_cb, EVENT_HOMEASSISTANT_STARTED, _is_started diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 0e92cc6ff01..f789aeb37e4 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -260,7 +260,7 @@ class Store(Generic[_T]): "data": data, } - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: self._async_ensure_final_write_listener() return @@ -286,7 +286,7 @@ class Store(Generic[_T]): self._async_cleanup_delay_listener() self._async_ensure_final_write_listener() - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: return self._unsub_delay_listener = async_call_later( @@ -318,7 +318,7 @@ class Store(Generic[_T]): async def _async_callback_delayed_write(self, _now): """Handle a delayed write callback.""" # catch the case where a call is scheduled and then we stop Home Assistant - if self.hass.state == CoreState.stopping: + if self.hass.state is CoreState.stopping: self._async_ensure_final_write_listener() return await self._async_handle_write_data() diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 0ca666d22f1..8aef5947d56 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -818,7 +818,7 @@ async def test_this_variable_early_hass_running( """ # Start hass - assert hass.state == CoreState.running + assert hass.state is CoreState.running await hass.async_start() await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index ea4ddd23d28..856213fa60a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1509,7 +1509,7 @@ async def async_setup_recorder_instance( await hass.async_block_till_done() instance = hass.data[recorder.DATA_INSTANCE] # The recorder's worker is not started until Home Assistant is running - if hass.state == CoreState.running: + if hass.state is CoreState.running: await async_recorder_block_till_done(hass) return instance diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index ec7ffbc9afc..f5204a2ec64 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -8,7 +8,7 @@ from homeassistant.helpers import start async def test_at_start_when_running_awaitable(hass: HomeAssistant) -> None: """Test at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -33,7 +33,7 @@ async def test_at_start_when_running_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -110,7 +110,7 @@ async def test_cancelling_at_start_when_running( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancelling at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] @@ -151,7 +151,7 @@ async def test_cancelling_at_start_when_starting(hass: HomeAssistant) -> None: async def test_at_started_when_running_awaitable(hass: HomeAssistant) -> None: """Test at started when already started.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running calls = [] @@ -175,7 +175,7 @@ async def test_at_started_when_running_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at started when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running calls = [] @@ -257,7 +257,7 @@ async def test_cancelling_at_started_when_running( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancelling at start when already running.""" - assert hass.state == CoreState.running + assert hass.state is CoreState.running assert hass.is_running calls = [] diff --git a/tests/test_core.py b/tests/test_core.py index 1210b110601..918f098eab7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -413,7 +413,7 @@ async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): await hass.async_stop() - assert hass.state == CoreState.stopped + assert hass.state is CoreState.stopped async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: From ee9c6fa0d804e603848a2c9e12f38569ce5f3bbb Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Fri, 12 Jan 2024 01:30:55 -0800 Subject: [PATCH 0516/1544] Fix for exception in screenlogic.set_color_mode (#107850) --- homeassistant/components/screenlogic/services.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 17c52932e09..c9c66183daf 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -61,10 +61,7 @@ def async_load_screenlogic_services(hass: HomeAssistant): color_num, ) try: - if not await coordinator.gateway.async_set_color_lights(color_num): - raise HomeAssistantError( - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'" - ) + await coordinator.gateway.async_set_color_lights(color_num) # Debounced refresh to catch any secondary # changes in the device await coordinator.async_request_refresh() From 8d5cdfaf3627c04cc48a9aa46a2edcb995ea0ff7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 12 Jan 2024 10:32:35 +0100 Subject: [PATCH 0517/1544] Fix reauth flow for Comelit VEDO (#107461) --- homeassistant/components/comelit/config_flow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index cbd79ac1e1a..bbb671a29a7 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -72,6 +72,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): _reauth_entry: ConfigEntry | None _reauth_host: str _reauth_port: int + _reauth_type: str async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -109,6 +110,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): ) self._reauth_host = entry_data[CONF_HOST] self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT) + self._reauth_type = entry_data.get(CONF_TYPE, BRIDGE) self.context["title_placeholders"] = {"host": self._reauth_host} return await self.async_step_reauth_confirm() @@ -127,6 +129,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_HOST: self._reauth_host, CONF_PORT: self._reauth_port, + CONF_TYPE: self._reauth_type, } | user_input, ) @@ -144,6 +147,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._reauth_host, CONF_PORT: self._reauth_port, CONF_PIN: user_input[CONF_PIN], + CONF_TYPE: self._reauth_type, }, ) self.hass.async_create_task( From 668fc442e93bdac4ee0b75b55611cca22ee19f3c Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Fri, 12 Jan 2024 10:52:17 +0100 Subject: [PATCH 0518/1544] Set max and min temp for flexit_bacnet climate entity (#107665) 107655: Set max and min temp for flexit_bacnet climate entity --- homeassistant/components/flexit_bacnet/climate.py | 4 ++++ homeassistant/components/flexit_bacnet/const.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index c15cb59a6f3..79846bee019 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -27,6 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN, + MAX_TEMP, + MIN_TEMP, PRESET_TO_VENTILATION_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP, ) @@ -67,6 +69,8 @@ class FlexitClimateEntity(ClimateEntity): _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP def __init__(self, device: FlexitBACnet) -> None: """Initialize the unit.""" diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py index 269a88c4cec..ed52b45f05e 100644 --- a/homeassistant/components/flexit_bacnet/const.py +++ b/homeassistant/components/flexit_bacnet/const.py @@ -15,6 +15,9 @@ from homeassistant.components.climate import ( DOMAIN = "flexit_bacnet" +MAX_TEMP = 30 +MIN_TEMP = 10 + VENTILATION_TO_PRESET_MODE_MAP = { VENTILATION_MODE_STOP: PRESET_NONE, VENTILATION_MODE_AWAY: PRESET_AWAY, From c9befe8700c55f94ae8f5cf660c1e000a3d085f7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:30:07 +0100 Subject: [PATCH 0519/1544] Add decorator typing [limitlessled] (#107557) --- .../components/limitlessled/light.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index c1dfeda172c..926e0a8a6d6 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -1,8 +1,9 @@ """Support for LimitlessLED bulbs.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar, cast from limitlessled import Color from limitlessled.bridge import Bridge @@ -38,6 +39,9 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin +_LimitlessLEDGroupT = TypeVar("_LimitlessLEDGroupT", bound="LimitlessLEDGroup") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) CONF_BRIDGES = "bridges" @@ -171,16 +175,25 @@ def setup_platform( add_entities(lights) -def state(new_state): +def state( + new_state: bool, +) -> Callable[ + [Callable[Concatenate[_LimitlessLEDGroupT, int, Pipeline, _P], Any]], + Callable[Concatenate[_LimitlessLEDGroupT, _P], None], +]: """State decorator. Specify True (turn on) or False (turn off). """ - def decorator(function): + def decorator( + function: Callable[Concatenate[_LimitlessLEDGroupT, int, Pipeline, _P], Any], + ) -> Callable[Concatenate[_LimitlessLEDGroupT, _P], None]: """Set up the decorator function.""" - def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: + def wrapper( + self: _LimitlessLEDGroupT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: """Wrap a group state change.""" pipeline = Pipeline() transition_time = DEFAULT_TRANSITION @@ -189,9 +202,9 @@ def state(new_state): self._attr_effect = None # pylint: disable=protected-access # Set transition time. if ATTR_TRANSITION in kwargs: - transition_time = int(kwargs[ATTR_TRANSITION]) + transition_time = int(cast(float, kwargs[ATTR_TRANSITION])) # Do group type-specific work. - function(self, transition_time, pipeline, **kwargs) + function(self, transition_time, pipeline, *args, **kwargs) # Update state. self._attr_is_on = new_state # pylint: disable=protected-access self.group.enqueue(pipeline) From 87b694298f9a53c7e4cd699785b22a4eaa5e6516 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Fri, 12 Jan 2024 11:30:23 +0100 Subject: [PATCH 0520/1544] Revert "Fix Netatmo camera name does not show under Media -> Media sources -> Camera" (#107856) --- homeassistant/components/netatmo/camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 5c217837ce7..7fab99a6f39 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -83,7 +83,7 @@ class NetatmoCamera(NetatmoBase, Camera): """Representation of a Netatmo camera.""" _attr_brand = MANUFACTURER - _attr_has_entity_name = False + _attr_has_entity_name = True _attr_supported_features = CameraEntityFeature.STREAM def __init__( @@ -97,7 +97,7 @@ class NetatmoCamera(NetatmoBase, Camera): self._camera = cast(NaModules.Camera, netatmo_device.device) self._id = self._camera.entity_id self._home_id = self._camera.home.entity_id - self._device_name = self._attr_name = self._camera.name + self._device_name = self._camera.name self._model = self._camera.device_type self._config_url = CONF_URL_SECURITY self._attr_unique_id = f"{self._id}-{self._model}" From 68ddc1481ee22d4119fc162aa02ec6e40b5ac280 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 12 Jan 2024 11:30:34 +0100 Subject: [PATCH 0521/1544] Rename netatmo base entity file (#107857) --- homeassistant/components/netatmo/camera.py | 4 ++-- homeassistant/components/netatmo/climate.py | 4 ++-- homeassistant/components/netatmo/cover.py | 4 ++-- .../netatmo/{netatmo_entity_base.py => entity.py} | 2 +- homeassistant/components/netatmo/light.py | 6 +++--- homeassistant/components/netatmo/select.py | 4 ++-- homeassistant/components/netatmo/sensor.py | 12 ++++++------ homeassistant/components/netatmo/switch.py | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) rename homeassistant/components/netatmo/{netatmo_entity_base.py => entity.py} (99%) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 7fab99a6f39..dc566afd233 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -39,7 +39,7 @@ from .const import ( WEBHOOK_PUSH_TYPE, ) from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -79,7 +79,7 @@ async def async_setup_entry( ) -class NetatmoCamera(NetatmoBase, Camera): +class NetatmoCamera(NetatmoBaseEntity, Camera): """Representation of a Netatmo camera.""" _attr_brand = MANUFACTURER diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 5a05818d3f2..9f5476718b7 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -56,7 +56,7 @@ from .const import ( SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -178,7 +178,7 @@ async def async_setup_entry( ) -class NetatmoThermostat(NetatmoBase, ClimateEntity): +class NetatmoThermostat(NetatmoBaseEntity, ClimateEntity): """Representation a Netatmo thermostat.""" _attr_hvac_mode = HVACMode.AUTO diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 2e4bf9e7d3c..b9537fee179 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ async def async_setup_entry( ) -class NetatmoCover(NetatmoBase, CoverEntity): +class NetatmoCover(NetatmoBaseEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/entity.py similarity index 99% rename from homeassistant/components/netatmo/netatmo_entity_base.py rename to homeassistant/components/netatmo/entity.py index 54915facb3a..e6829604d48 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/entity.py @@ -18,7 +18,7 @@ from .const import DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME from .data_handler import PUBLIC, NetatmoDataHandler -class NetatmoBase(Entity): +class NetatmoBaseEntity(Entity): """Netatmo entity base class.""" _attr_attribution = DEFAULT_ATTRIBUTION diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index b796372fc20..e5dd3b7354a 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -23,7 +23,7 @@ from .const import ( WEBHOOK_PUSH_TYPE, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,7 @@ async def async_setup_entry( ) -class NetatmoCameraLight(NetatmoBase, LightEntity): +class NetatmoCameraLight(NetatmoBaseEntity, LightEntity): """Representation of a Netatmo Presence camera light.""" _attr_has_entity_name = True @@ -150,7 +150,7 @@ class NetatmoCameraLight(NetatmoBase, LightEntity): self._is_on = bool(self._camera.floodlight == "on") -class NetatmoLight(NetatmoBase, LightEntity): +class NetatmoLight(NetatmoBaseEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" def __init__( diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index b02c63698f3..2dd88782ac3 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -19,7 +19,7 @@ from .const import ( NETATMO_CREATE_SELECT, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoHome -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ async def async_setup_entry( ) -class NetatmoScheduleSelect(NetatmoBase, SelectEntity): +class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): """Representation a Netatmo thermostat schedule selector.""" def __init__( diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 692a1a806ea..430de8e318c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -51,8 +51,8 @@ from .const import ( SIGNAL_NAME, ) from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .entity import NetatmoBaseEntity from .helper import NetatmoArea -from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -399,7 +399,7 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoWeatherSensor(NetatmoBase, SensorEntity): +class NetatmoWeatherSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo weather/home coach sensor.""" _attr_has_entity_name = True @@ -478,7 +478,7 @@ class NetatmoWeatherSensor(NetatmoBase, SensorEntity): self.async_write_ha_state() -class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): +class NetatmoClimateBatterySensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -525,7 +525,7 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): self._attr_native_value = self._module.battery -class NetatmoSensor(NetatmoBase, SensorEntity): +class NetatmoSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -613,7 +613,7 @@ def process_wifi(strength: int) -> str: return "Full" -class NetatmoRoomSensor(NetatmoBase, SensorEntity): +class NetatmoRoomSensor(NetatmoBaseEntity, SensorEntity): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -662,7 +662,7 @@ class NetatmoRoomSensor(NetatmoBase, SensorEntity): self.async_write_ha_state() -class NetatmoPublicSensor(NetatmoBase, SensorEntity): +class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): """Represent a single sensor in a Netatmo.""" _attr_has_entity_name = True diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index a2e2e67db39..730f41afeeb 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice -from .netatmo_entity_base import NetatmoBase +from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ async def async_setup_entry( ) -class NetatmoSwitch(NetatmoBase, SwitchEntity): +class NetatmoSwitch(NetatmoBaseEntity, SwitchEntity): """Representation of a Netatmo switch device.""" def __init__( From bec88e5e512cc0b77321a10459b1e871f7826981 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:31:08 +0100 Subject: [PATCH 0522/1544] Add decorator typing [izone] (#107556) --- homeassistant/components/izone/climate.py | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 1ff016c3177..75eb19b2978 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,9 +1,9 @@ """Support for the iZone HVAC.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar from pizone import Controller, Zone import voluptuous as vol @@ -47,6 +47,12 @@ from .const import ( IZONE, ) +_DeviceT = TypeVar("_DeviceT", bound="ControllerDevice | ZoneDevice") +_T = TypeVar("_T") +_R = TypeVar("_R") +_P = ParamSpec("_P") +_FuncType = Callable[Concatenate[_T, _P], _R] + _LOGGER = logging.getLogger(__name__) _IZONE_FAN_TO_HA = { @@ -112,13 +118,15 @@ async def async_setup_entry( ) -def _return_on_connection_error(ret=None): - def wrap(func): - def wrapped_f(*args, **kwargs): - if not args[0].available: +def _return_on_connection_error( + ret: _T = None, # type: ignore[assignment] +) -> Callable[[_FuncType[_DeviceT, _P, _R]], _FuncType[_DeviceT, _P, _R | _T]]: + def wrap(func: _FuncType[_DeviceT, _P, _R]) -> _FuncType[_DeviceT, _P, _R | _T]: + def wrapped_f(self: _DeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R | _T: + if not self.available: return ret try: - return func(*args, **kwargs) + return func(self, *args, **kwargs) except ConnectionError: return ret @@ -498,7 +506,7 @@ class ZoneDevice(ClimateEntity): return self._controller.available @property - @_return_on_connection_error(0) + @_return_on_connection_error(ClimateEntityFeature(0)) def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" if self._zone.mode == Zone.Mode.AUTO: From 827a1b1f48fc54c0f19fddbd6f4eb98597f6aa44 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:32:03 +0100 Subject: [PATCH 0523/1544] Add decorator typing [homematicip_cloud] (#107555) --- .../components/homematicip_cloud/helpers.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 1680904bbca..4647e553382 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -1,17 +1,25 @@ """Helper functions for Homematicip Cloud Integration.""" +from __future__ import annotations +from collections.abc import Callable, Coroutine from functools import wraps import json import logging +from typing import Any, Concatenate, ParamSpec, TypeGuard, TypeVar from homeassistant.exceptions import HomeAssistantError from . import HomematicipGenericEntity +_HomematicipGenericEntityT = TypeVar( + "_HomematicipGenericEntityT", bound=HomematicipGenericEntity +) +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) -def is_error_response(response) -> bool: +def is_error_response(response: Any) -> TypeGuard[dict[str, Any]]: """Response from async call contains errors or not.""" if isinstance(response, dict): return response.get("errorCode") not in ("", None) @@ -19,13 +27,19 @@ def is_error_response(response) -> bool: return False -def handle_errors(func): +def handle_errors( + func: Callable[ + Concatenate[_HomematicipGenericEntityT, _P], Coroutine[Any, Any, Any] + ], +) -> Callable[Concatenate[_HomematicipGenericEntityT, _P], Coroutine[Any, Any, Any]]: """Handle async errors.""" @wraps(func) - async def inner(self: HomematicipGenericEntity) -> None: + async def inner( + self: _HomematicipGenericEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: """Handle errors from async call.""" - result = await func(self) + result = await func(self, *args, **kwargs) if is_error_response(result): _LOGGER.error( "Error while execute function %s: %s", From c1faafc6a092634d3c51eeffbe0c72aa48a03d3e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:42:10 +0100 Subject: [PATCH 0524/1544] Add decorator typing [zha] (#107599) --- homeassistant/components/zha/core/helpers.py | 25 +++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index bb87cb2cf58..8e518d805c6 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -8,7 +8,7 @@ from __future__ import annotations import asyncio import binascii import collections -from collections.abc import Callable, Iterator +from collections.abc import Callable, Collection, Coroutine, Iterator import dataclasses from dataclasses import dataclass import enum @@ -17,7 +17,7 @@ import itertools import logging from random import uniform import re -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar import voluptuous as vol import zigpy.exceptions @@ -37,10 +37,14 @@ from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA from .registries import BINDABLE_CLUSTERS if TYPE_CHECKING: + from .cluster_handlers import ClusterHandler from .device import ZHADevice from .gateway import ZHAGateway +_ClusterHandlerT = TypeVar("_ClusterHandlerT", bound="ClusterHandler") _T = TypeVar("_T") +_R = TypeVar("_R") +_P = ParamSpec("_P") _LOGGER = logging.getLogger(__name__) @@ -319,8 +323,12 @@ class LogMixin: def retryable_req( - delays=(1, 5, 10, 15, 30, 60, 120, 180, 360, 600, 900, 1800), raise_=False -): + delays: Collection[float] = (1, 5, 10, 15, 30, 60, 120, 180, 360, 600, 900, 1800), + raise_: bool = False, +) -> Callable[ + [Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R]]], + Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R | None]], +]: """Make a method with ZCL requests retryable. This adds delays keyword argument to function. @@ -328,9 +336,13 @@ def retryable_req( raise_ if the final attempt should raise the exception. """ - def decorator(func): + def decorator( + func: Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R]], + ) -> Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R | None]]: @functools.wraps(func) - async def wrapper(cluster_handler, *args, **kwargs): + async def wrapper( + cluster_handler: _ClusterHandlerT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) try_count, errors = 1, [] for delay in itertools.chain(delays, [None]): @@ -355,6 +367,7 @@ def retryable_req( ) if raise_: raise + return None return wrapper From 2508b55b0f4b42a077b386d5b22b9ddf1a0b08b5 Mon Sep 17 00:00:00 2001 From: Peter Winkler Date: Fri, 12 Jan 2024 12:17:07 +0100 Subject: [PATCH 0525/1544] Add myUplink integration (#86522) * First checkin for myUplink * Refactored coordinator and sensor state classe * Updated .coveragerc * Update test_config_flow * Fix test_config_flow for myuplink * Only set state class for temperature sensor * PR comment updates * Type strong dict * use asyncio.timeouts * PR updates (part 1) * Updated to myuplink 0.0.9 * Add strict typing * Fix typing * Inherit CoordinatorEntity * Clean up coordinator and sensors * Use common base entity * Improve device point sensor * Exclude entity from coverage * Set device point entity name if there's no entity description * Update homeassistant/components/myuplink/sensor.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/myuplink/entity.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/myuplink/entity.py Co-authored-by: Martin Hjelmare * Remvoed firmware + connstate sensors * Always add device point parameter name * Removed MyUplinkDeviceSensor * Removed unused class * key="celsius", --------- Co-authored-by: Martin Hjelmare --- .coveragerc | 7 ++ .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/myuplink/__init__.py | 71 +++++++++++++++ homeassistant/components/myuplink/api.py | 31 +++++++ .../myuplink/application_credentials.py | 14 +++ .../components/myuplink/config_flow.py | 25 ++++++ homeassistant/components/myuplink/const.py | 8 ++ .../components/myuplink/coordinator.py | 65 ++++++++++++++ homeassistant/components/myuplink/entity.py | 28 ++++++ .../components/myuplink/manifest.json | 10 +++ homeassistant/components/myuplink/sensor.py | 89 +++++++++++++++++++ .../components/myuplink/strings.json | 21 +++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ mypy.ini | 10 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/myuplink/__init__.py | 1 + tests/components/myuplink/test_config_flow.py | 83 +++++++++++++++++ 21 files changed, 480 insertions(+) create mode 100644 homeassistant/components/myuplink/__init__.py create mode 100644 homeassistant/components/myuplink/api.py create mode 100644 homeassistant/components/myuplink/application_credentials.py create mode 100644 homeassistant/components/myuplink/config_flow.py create mode 100644 homeassistant/components/myuplink/const.py create mode 100644 homeassistant/components/myuplink/coordinator.py create mode 100644 homeassistant/components/myuplink/entity.py create mode 100644 homeassistant/components/myuplink/manifest.json create mode 100644 homeassistant/components/myuplink/sensor.py create mode 100644 homeassistant/components/myuplink/strings.json create mode 100644 tests/components/myuplink/__init__.py create mode 100644 tests/components/myuplink/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index cf2210ec1a0..88a9f96a608 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1654,6 +1654,13 @@ omit = homeassistant/components/zwave_me/switch.py homeassistant/components/electrasmart/climate.py homeassistant/components/electrasmart/__init__.py + homeassistant/components/myuplink/__init__.py + homeassistant/components/myuplink/api.py + homeassistant/components/myuplink/application_credentials.py + homeassistant/components/myuplink/coordinator.py + homeassistant/components/myuplink/entity.py + homeassistant/components/myuplink/sensor.py + [report] # Regexes for lines to exclude from consideration diff --git a/.strict-typing b/.strict-typing index b79b50fd9cb..93d603204f0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -282,6 +282,7 @@ homeassistant.components.mopeka.* homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* +homeassistant.components.myuplink.* homeassistant.components.nam.* homeassistant.components.nanoleaf.* homeassistant.components.neato.* diff --git a/CODEOWNERS b/CODEOWNERS index fdbc63324ce..1288ea53591 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -829,6 +829,8 @@ build.json @home-assistant/supervisor /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff /tests/components/mystrom/ @fabaff +/homeassistant/components/myuplink/ @pajzo +/tests/components/myuplink/ @pajzo /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py new file mode 100644 index 00000000000..15ae1eb75c2 --- /dev/null +++ b/homeassistant/components/myuplink/__init__.py @@ -0,0 +1,71 @@ +"""The myUplink integration.""" +from __future__ import annotations + +from myuplink.api import MyUplinkAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up myUplink from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation) + auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + + # Setup MyUplinkAPI and coordinator for data fetch + api = MyUplinkAPI(auth) + coordinator = MyUplinkDataCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + # Update device registry + create_devices(hass, config_entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +@callback +def create_devices( + hass: HomeAssistant, config_entry: ConfigEntry, coordinator: MyUplinkDataCoordinator +) -> None: + """Update all devices.""" + device_registry = dr.async_get(hass) + + for device_id, device in coordinator.data.devices.items(): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, device_id)}, + name=device.productName, + manufacturer=device.productName.split(" ")[0], + model=device.productName, + sw_version=device.firmwareCurrent, + ) diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py new file mode 100644 index 00000000000..5d0fcaf521a --- /dev/null +++ b/homeassistant/components/myuplink/api.py @@ -0,0 +1,31 @@ +"""API for myUplink bound to Home Assistant OAuth.""" +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientSession +from myuplink.auth_abstract import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import API_ENDPOINT + + +class AsyncConfigEntryAuth(AbstractAuth): # type: ignore[misc] + """Provide myUplink authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize myUplink auth.""" + super().__init__(websession, API_ENDPOINT) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/myuplink/application_credentials.py b/homeassistant/components/myuplink/application_credentials.py new file mode 100644 index 00000000000..fe3cd22f037 --- /dev/null +++ b/homeassistant/components/myuplink/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the myUplink integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py new file mode 100644 index 00000000000..e8377f2682b --- /dev/null +++ b/homeassistant/components/myuplink/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for myUplink.""" +import logging +from typing import Any + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle myUplink OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH2_SCOPES)} diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py new file mode 100644 index 00000000000..9adb1eb0e30 --- /dev/null +++ b/homeassistant/components/myuplink/const.py @@ -0,0 +1,8 @@ +"""Constants for the myUplink integration.""" + +DOMAIN = "myuplink" + +API_ENDPOINT = "https://api.myuplink.com" +OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" +OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" +OAUTH2_SCOPES = ["READSYSTEM", "offline_access"] diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py new file mode 100644 index 00000000000..4cd66adab2b --- /dev/null +++ b/homeassistant/components/myuplink/coordinator.py @@ -0,0 +1,65 @@ +"""Coordinator for myUplink.""" +import asyncio.timeouts +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from myuplink.api import MyUplinkAPI +from myuplink.models import Device, DevicePoint, System + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class CoordinatorData: + """Represent coordinator data.""" + + systems: list[System] + devices: dict[str, Device] + points: dict[str, dict[str, DevicePoint]] + time: datetime + + +class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Coordinator for myUplink data.""" + + def __init__(self, hass: HomeAssistant, api: MyUplinkAPI) -> None: + """Initialize myUplink coordinator.""" + super().__init__( + hass, + _LOGGER, + name="myuplink", + update_interval=timedelta(seconds=60), + ) + self.api = api + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from the myUplink API.""" + async with asyncio.timeout(10): + # Get systems + systems = await self.api.async_get_systems() + + devices: dict[str, Device] = {} + points: dict[str, dict[str, DevicePoint]] = {} + device_ids = [ + device.deviceId for system in systems for device in system.devices + ] + for device_id in device_ids: + # Get device info + api_device_info = await self.api.async_get_device(device_id) + devices[device_id] = api_device_info + + # Get device points (data) + api_device_points = await self.api.async_get_device_points(device_id) + point_info: dict[str, DevicePoint] = {} + for point in api_device_points: + point_info[point.parameter_id] = point + + points[device_id] = point_info + + return CoordinatorData( + systems=systems, devices=devices, points=points, time=datetime.now() + ) diff --git a/homeassistant/components/myuplink/entity.py b/homeassistant/components/myuplink/entity.py new file mode 100644 index 00000000000..e3d6184c368 --- /dev/null +++ b/homeassistant/components/myuplink/entity.py @@ -0,0 +1,28 @@ +"""Provide a common entity class for myUplink entities.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MyUplinkDataCoordinator + + +class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): + """Representation of a sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + + # Internal properties + self.device_id = device_id + + # Basic values + self._attr_unique_id = f"{device_id}-{unique_id_suffix}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json new file mode 100644 index 00000000000..303af547335 --- /dev/null +++ b/homeassistant/components/myuplink/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "myuplink", + "name": "myUplink", + "codeowners": ["@pajzo"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/myuplink", + "iot_class": "cloud_polling", + "requirements": ["myuplink==0.0.9"] +} diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py new file mode 100644 index 00000000000..31cb6715e0c --- /dev/null +++ b/homeassistant/components/myuplink/sensor.py @@ -0,0 +1,89 @@ +"""Sensor for myUplink.""" + +from myuplink.models import DevicePoint + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity + +DEVICE_POINT_DESCRIPTIONS = { + "°C": SensorEntityDescription( + key="celsius", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink sensor.""" + entities: list[SensorEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point sensors + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + entities.append( + MyUplinkDevicePointSensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=DEVICE_POINT_DESCRIPTIONS.get( + device_point.parameter_unit + ), + unique_id_suffix=point_id, + ) + ) + + async_add_entities(entities) + + +class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): + """Representation of a myUplink device point sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + + if entity_description is not None: + self.entity_description = entity_description + else: + self._attr_native_unit_of_measurement = device_point.parameter_unit + + @property + def native_value(self) -> StateType: + """Sensor state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return device_point.value # type: ignore[no-any-return] diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json new file mode 100644 index 00000000000..569e148a5a3 --- /dev/null +++ b/homeassistant/components/myuplink/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 060080517bf..586aa64ce18 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lametric", "lyric", + "myuplink", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 699bdebc61f..c62203b4d6c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -315,6 +315,7 @@ FLOWS = { "mutesync", "mysensors", "mystrom", + "myuplink", "nam", "nanoleaf", "neato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b70aad119df..d5f8354574f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3725,6 +3725,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "myuplink": { + "name": "myUplink", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nad": { "name": "NAD", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 62ad39da8e2..bdb854183f6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2581,6 +2581,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.myuplink.*] +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.nam.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 45b67c3dccb..f700d860df0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,6 +1305,9 @@ mutesync==0.0.1 # homeassistant.components.permobil mypermobil==0.1.6 +# homeassistant.components.myuplink +myuplink==0.0.9 + # homeassistant.components.nad nad-receiver==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc97d939166..f1b2dcd3252 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,6 +1035,9 @@ mutesync==0.0.1 # homeassistant.components.permobil mypermobil==0.1.6 +# homeassistant.components.myuplink +myuplink==0.0.9 + # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 diff --git a/tests/components/myuplink/__init__.py b/tests/components/myuplink/__init__.py new file mode 100644 index 00000000000..d5ca745ced0 --- /dev/null +++ b/tests/components/myuplink/__init__.py @@ -0,0 +1 @@ +"""Tests for the myUplink integration.""" diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py new file mode 100644 index 00000000000..ec781af2a1f --- /dev/null +++ b/tests/components/myuplink/test_config_flow.py @@ -0,0 +1,83 @@ +"""Test the myUplink config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.myuplink.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "myuplink", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=READSYSTEM+offline_access" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.myuplink.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 From 7023ac73664e9d876c2a0e8a2f5987f5ff9040a7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:32:17 +0100 Subject: [PATCH 0526/1544] Enable strict typing for cert_expiry (#107860) --- .strict-typing | 1 + homeassistant/components/cert_expiry/__init__.py | 6 +++--- homeassistant/components/cert_expiry/config_flow.py | 2 +- homeassistant/components/cert_expiry/coordinator.py | 5 +++-- homeassistant/components/cert_expiry/helper.py | 4 ++-- homeassistant/components/cert_expiry/sensor.py | 9 +++++---- mypy.ini | 10 ++++++++++ 7 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.strict-typing b/.strict-typing index 93d603204f0..f92ede38c19 100644 --- a/.strict-typing +++ b/.strict-typing @@ -114,6 +114,7 @@ homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.camera.* homeassistant.components.canary.* +homeassistant.components.cert_expiry.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* homeassistant.components.climate.* diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 391bb3ef8f3..d46cecc7edb 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -14,8 +14,8 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load the saved entities.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=f"{host}:{port}") - async def _async_finish_startup(_): + async def _async_finish_startup(_: HomeAssistant) -> None: await coordinator.async_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index ed294cab981..b3ceb95d301 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -35,7 +35,7 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _test_connection( self, user_input: Mapping[str, Any], - ): + ) -> bool: """Test connection to the server and try to get the certificate.""" try: await get_cert_expiry_timestamp( diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py index 6a125758f70..abb0b4ca727 100644 --- a/homeassistant/components/cert_expiry/coordinator.py +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_PORT @@ -16,11 +17,11 @@ _LOGGER = logging.getLogger(__name__) class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): """Class to manage fetching Cert Expiry data from single endpoint.""" - def __init__(self, hass, host, port): + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize global Cert Expiry data updater.""" self.host = host self.port = port - self.cert_error = None + self.cert_error: ValidationFailure | None = None self.is_cert_valid = False display_port = f":{port}" if port != DEFAULT_PORT else "" diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 6618dbc8a01..cde9364214e 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -19,7 +19,7 @@ from .errors import ( @cache -def _get_default_ssl_context(): +def _get_default_ssl_context() -> ssl.SSLContext: """Return the default SSL context.""" return ssl.create_default_context() @@ -40,7 +40,7 @@ async def async_get_cert( server_hostname=host, ) try: - return transport.get_extra_info("peercert") + return transport.get_extra_info("peercert") # type: ignore[no-any-return] finally: transport.close() diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 645642067e6..68e18fddc14 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import Any import voluptuous as vol @@ -12,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,12 +43,12 @@ async def async_setup_platform( """Set up certificate expiry sensor.""" @callback - def schedule_import(_): + def schedule_import(_: Event) -> None: """Schedule delayed import after HA is fully started.""" async_call_later(hass, 10, do_import) @callback - def do_import(_): + def do_import(_: datetime) -> None: """Process YAML import.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -80,7 +81,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): _attr_has_entity_name = True @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return additional sensor state attributes.""" return { "is_valid": self.coordinator.is_cert_valid, diff --git a/mypy.ini b/mypy.ini index bdb854183f6..bd975096ea4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -900,6 +900,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cert_expiry.*] +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.clickatell.*] check_untyped_defs = true disallow_incomplete_defs = true From e36141a4bc707af5f2a25c31d2d3391b9f3e397a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:41:37 +0100 Subject: [PATCH 0527/1544] Improve onboarding provider call (#107864) --- homeassistant/components/onboarding/views.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 05467e96860..b1b4ea29222 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,17 +3,20 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from typing import cast +from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth from homeassistant.components.http import KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import area_registry as ar from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations @@ -123,9 +126,9 @@ class UserOnboardingView(_BaseOnboardingView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Handle user creation, area creation.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): @@ -137,13 +140,10 @@ class UserOnboardingView(_BaseOnboardingView): user = await hass.auth.async_create_user( data["name"], group_ids=[GROUP_ID_ADMIN] ) - await hass.async_add_executor_job( - provider.data.add_auth, data["username"], data["password"] - ) + await provider.async_add_auth(data["username"], data["password"]) credentials = await provider.async_get_or_create_credentials( {"username": data["username"]} ) - await provider.data.async_save() await hass.auth.async_link_user(user, credentials) if "person" in hass.config.components: await person.async_create_person(hass, data["name"], user_id=user.id) @@ -292,10 +292,10 @@ class AnalyticsOnboardingView(_BaseOnboardingView): @callback -def _async_get_hass_provider(hass): +def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" for prv in hass.auth.auth_providers: if prv.type == "homeassistant": - return prv + return cast(HassAuthProvider, prv) raise RuntimeError("No Home Assistant provider found") From 96a9ebf137f48844be33325ba0922d32fed64202 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 12 Jan 2024 12:55:09 +0100 Subject: [PATCH 0528/1544] Fix missing unique_id for spt integration (#107087) Co-authored-by: Franck Nijhof Co-authored-by: Joost Lekkerkerker --- .../swiss_public_transport/__init__.py | 49 +++++++++++ .../swiss_public_transport/config_flow.py | 7 ++ .../swiss_public_transport/test_init.py | 85 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 tests/components/swiss_public_transport/test_init.py diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 9e01a07416f..a510b5b7414 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -10,6 +10,7 @@ from opendata_transport.exceptions import ( from homeassistant import config_entries, core from homeassistant.const import Platform from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_DESTINATION, CONF_START, DOMAIN @@ -65,3 +66,51 @@ async def async_unload_entry( hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.minor_version > 3: + # This means the user has downgraded from a future version + return False + + if config_entry.minor_version == 1: + # Remove wrongly registered devices and entries + new_unique_id = ( + f"{config_entry.data[CONF_START]} {config_entry.data[CONF_DESTINATION]}" + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_remove_device(dev.id) + + entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "None_departure" + ) + if entity_id: + entity_registry.async_update_entity( + entity_id=entity_id, + new_unique_id=f"{new_unique_id}_departure", + ) + _LOGGER.debug( + "Faulty entity with unique_id 'None_departure' migrated to new unique_id '%s'", + f"{new_unique_id}_departure", + ) + + # Set a valid unique id for config entries + config_entry.unique_id = new_unique_id + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry) + + _LOGGER.debug( + "Migration to minor version %s successful", config_entry.minor_version + ) + + return True diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 63eca1efe96..ceb6f46806d 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -31,6 +31,7 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Swiss public transport config flow.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -59,6 +60,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: + await self.async_set_unique_id( + f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" + ) return self.async_create_entry( title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", data=user_input, @@ -98,6 +102,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="unknown") + await self.async_set_unique_id( + f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" + ) return self.async_create_entry( title=import_input[CONF_NAME], data=import_input, diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py new file mode 100644 index 00000000000..f2b4e41ed71 --- /dev/null +++ b/tests/components/swiss_public_transport/test_init.py @@ -0,0 +1,85 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + +CONNECTIONS = [ + { + "departure": "2024-01-06T18:03:00+0100", + "number": 0, + "platform": 0, + "transfers": 0, + "duration": "10", + "delay": 0, + }, + { + "departure": "2024-01-06T18:04:00+0100", + "number": 1, + "platform": 1, + "transfers": 0, + "duration": "10", + "delay": 0, + }, + { + "departure": "2024-01-06T18:05:00+0100", + "number": 2, + "platform": 2, + "transfers": 0, + "duration": "10", + "delay": 0, + }, +] + + +async def test_migration_1_to_2( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test successful setup.""" + + with patch( + "homeassistant.components.swiss_public_transport.OpendataTransport", + return_value=AsyncMock(), + ) as mock: + mock().connections = CONNECTIONS + + config_entry_faulty = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + title="MIGRATION_TEST", + minor_version=1, + ) + config_entry_faulty.add_to_hass(hass) + + # Setup the config entry + await hass.config_entries.async_setup(config_entry_faulty.entry_id) + await hass.async_block_till_done() + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + (Platform.SENSOR, DOMAIN, "test_start test_destination_departure") + ) + ) + + # Check change in config entry + assert config_entry_faulty.minor_version == 2 + assert config_entry_faulty.unique_id == "test_start test_destination" + + # Check "None" is gone + assert not entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + (Platform.SENSOR, DOMAIN, "None_departure") + ) + ) From 7e28c788cbb096fccd64f727e609e7883039753f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:56:13 +0100 Subject: [PATCH 0529/1544] Enable strict typing for bthome (#107859) --- .strict-typing | 1 + homeassistant/components/bthome/config_flow.py | 6 +++--- homeassistant/components/bthome/device_trigger.py | 2 +- mypy.ini | 10 ++++++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index f92ede38c19..cd43c375439 100644 --- a/.strict-typing +++ b/.strict-typing @@ -110,6 +110,7 @@ homeassistant.components.bond.* homeassistant.components.braviatv.* homeassistant.components.brother.* homeassistant.components.browser.* +homeassistant.components.bthome.* homeassistant.components.button.* homeassistant.components.calendar.* homeassistant.components.camera.* diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index a728efdf05a..26bf1186a1d 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -75,7 +75,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - bindkey = user_input["bindkey"] + bindkey: str = user_input["bindkey"] if len(bindkey) != 32: errors["bindkey"] = "expected_32_characters" @@ -173,8 +173,8 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): # Otherwise there wasn't actually encryption so abort return self.async_abort(reason="reauth_successful") - def _async_get_or_create_entry(self, bindkey=None): - data = {} + def _async_get_or_create_entry(self, bindkey: str | None = None) -> FlowResult: + data: dict[str, Any] = {} if bindkey: data["bindkey"] = bindkey diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index a81c30eee85..6bcc1635aff 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -66,7 +66,7 @@ async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate trigger config.""" - return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( + return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return] config ) diff --git a/mypy.ini b/mypy.ini index bd975096ea4..361bdd63047 100644 --- a/mypy.ini +++ b/mypy.ini @@ -860,6 +860,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bthome.*] +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.button.*] check_untyped_defs = true disallow_incomplete_defs = true From b1f1ecb40adf8cf87a831d765dc9bc5abce2964b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:56:40 +0100 Subject: [PATCH 0530/1544] Improve meteo_france typing (#107863) --- .../components/meteo_france/__init__.py | 9 +++--- .../components/meteo_france/config_flow.py | 29 +++++++++++++------ .../components/meteo_france/sensor.py | 4 +-- .../components/meteo_france/weather.py | 2 +- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 6ad3868f13d..ff29f9d2f95 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -4,6 +4,7 @@ import logging from meteofrance_api.client import MeteoFranceClient from meteofrance_api.helpers import is_valid_warning_department +from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -79,17 +80,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] - async def _async_update_data_forecast_forecast(): + async def _async_update_data_forecast_forecast() -> Forecast: """Fetch data from API endpoint.""" return await hass.async_add_executor_job( client.get_forecast, latitude, longitude ) - async def _async_update_data_rain(): + async def _async_update_data_rain() -> Rain: """Fetch data from API endpoint.""" return await hass.async_add_executor_job(client.get_rain, latitude, longitude) - async def _async_update_data_alert(): + async def _async_update_data_alert() -> CurrentPhenomenons: """Fetch data from API endpoint.""" return await hass.async_add_executor_job( client.get_warning_current_phenomenoms, department, 0, True @@ -136,7 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, department, ) - if is_valid_warning_department(department): + if department is not None and is_valid_warning_department(department): if not hass.data[DOMAIN].get(department): coordinator_alert = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index ade6bedd362..dd62ffc24be 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -2,14 +2,17 @@ from __future__ import annotations import logging +from typing import Any from meteofrance_api.client import MeteoFranceClient +from meteofrance_api.model import Place import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CONF_CITY, DOMAIN @@ -21,12 +24,16 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Init MeteoFranceFlowHandler.""" - self.places = [] + self.places: list[Place] = [] @callback - def _show_setup_form(self, user_input=None, errors=None): + def _show_setup_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: """Show the setup form to the user.""" if user_input is None: @@ -40,9 +47,11 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is None: return self._show_setup_form(user_input, errors) @@ -72,15 +81,17 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, ) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" return await self.async_step_user(user_input) - async def async_step_cities(self, user_input=None): + async def async_step_cities( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Step where the user choose the city from the API search results.""" if not user_input: if len(self.places) > 1 and self.source != SOURCE_IMPORT: - places_for_form = {} + places_for_form: dict[str, str] = {} for place in self.places: places_for_form[_build_place_key(place)] = f"{place}" @@ -106,5 +117,5 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -def _build_place_key(place) -> str: +def _build_place_key(place: Place) -> str: return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 451d617e65b..c5ff38f2a87 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -303,7 +303,7 @@ class MeteoFranceRainSensor(MeteoFranceSensor[Rain]): return dt_util.utc_from_timestamp(next_rain["dt"]) if next_rain else None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" reference_dt = self.coordinator.data.forecast[0]["dt"] return { @@ -330,7 +330,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): self._attr_unique_id = self._attr_name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state.""" return get_warning_text_status_from_indice_color( self.coordinator.data.get_domain_max_color() diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index d081a6e729b..79e35b6219f 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -110,7 +110,7 @@ class MeteoFranceWeather( ) @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id of the sensor.""" return self._unique_id From 93dc0b90297022e90830b89cd64b5c118ca5de80 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:57:19 +0100 Subject: [PATCH 0531/1544] Enable strict typing for ecowitt (#107861) --- .strict-typing | 1 + homeassistant/components/ecowitt/__init__.py | 2 +- homeassistant/components/ecowitt/entity.py | 8 ++++---- mypy.ini | 10 ++++++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index cd43c375439..ef090d3ab4d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -149,6 +149,7 @@ homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* +homeassistant.components.ecowitt.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* diff --git a/homeassistant/components/ecowitt/__init__.py b/homeassistant/components/ecowitt/__init__.py index 567e21b4d87..eaf2441ffac 100644 --- a/homeassistant/components/ecowitt/__init__.py +++ b/homeassistant/components/ecowitt/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @callback - def _stop_ecowitt(_: Event): + def _stop_ecowitt(_: Event) -> None: """Stop the Ecowitt listener.""" webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index 12fcca449c0..a5d769e6749 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -31,15 +31,15 @@ class EcowittEntity(Entity): sw_version=sensor.station.version, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Install listener for updates later.""" - def _update_state(): + def _update_state() -> None: """Update the state on callback.""" self.async_write_ha_state() - self.ecowitt.update_cb.append(_update_state) - self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) + self.ecowitt.update_cb.append(_update_state) # type: ignore[arg-type] # upstream bug + self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) # type: ignore[arg-type] # upstream bug @property def available(self) -> bool: diff --git a/mypy.ini b/mypy.ini index 361bdd63047..8c0d6823efd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1251,6 +1251,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ecowitt.*] +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.efergy.*] check_untyped_defs = true disallow_incomplete_defs = true From 8e83356ccb1c94cbbc06ca96da11d520387d923b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 12:58:19 +0100 Subject: [PATCH 0532/1544] Add decorator typing [spotify] (#107560) --- homeassistant/components/spotify/media_player.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 0204cc30fbb..03b703220c3 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe +from collections.abc import Callable from datetime import timedelta import logging -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import requests from spotipy import SpotifyException @@ -33,6 +34,10 @@ from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES from .util import fetch_image_url +_SpotifyMediaPlayerT = TypeVar("_SpotifyMediaPlayerT", bound="SpotifyMediaPlayer") +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) @@ -80,14 +85,18 @@ async def async_setup_entry( async_add_entities([spotify], True) -def spotify_exception_handler(func): +def spotify_exception_handler( + func: Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R], +) -> Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R | None]: """Decorate Spotify calls to handle Spotify exception. A decorator that wraps the passed in function, catches Spotify errors, aiohttp exceptions and handles the availability of the media player. """ - def wrapper(self, *args, **kwargs): + def wrapper( + self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: # pylint: disable=protected-access try: result = func(self, *args, **kwargs) @@ -95,6 +104,7 @@ def spotify_exception_handler(func): return result except requests.RequestException: self._attr_available = False + return None except SpotifyException as exc: self._attr_available = False if exc.reason == "NO_ACTIVE_DEVICE": From 0257cd8bbed5ddc33d3f795ac70b580c8d70684e Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 12 Jan 2024 13:29:15 +0100 Subject: [PATCH 0533/1544] Bump xiaomi-ble to 0.21.2 (#107779) --- homeassistant/components/xiaomi_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/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index a03e3f388ed..04398051035 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.21.1"] + "requirements": ["xiaomi-ble==0.21.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f700d860df0..0c10e53813f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2818,7 +2818,7 @@ wyoming==1.4.0 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.1 +xiaomi-ble==0.21.2 # homeassistant.components.knx xknx==2.11.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1b2dcd3252..ccc279a5b5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2135,7 +2135,7 @@ wyoming==1.4.0 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.1 +xiaomi-ble==0.21.2 # homeassistant.components.knx xknx==2.11.2 From a9420bf05a173b9967d353dc64d09f6bccad5810 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 12 Jan 2024 14:43:17 +0100 Subject: [PATCH 0534/1544] Enable strict typing for ios (#107382) --- .strict-typing | 1 + homeassistant/components/ios/__init__.py | 28 +++++++++++++----------- homeassistant/components/ios/notify.py | 13 ++++++----- homeassistant/components/ios/sensor.py | 13 +++++++---- homeassistant/util/json.py | 6 ++--- mypy.ini | 10 +++++++++ 6 files changed, 46 insertions(+), 25 deletions(-) diff --git a/.strict-typing b/.strict-typing index ef090d3ab4d..39e03820582 100644 --- a/.strict-typing +++ b/.strict-typing @@ -233,6 +233,7 @@ homeassistant.components.input_select.* homeassistant.components.input_text.* homeassistant.components.integration.* homeassistant.components.intent.* +homeassistant.components.ios.* homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.islamic_prayer_times.* diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index dd5ea743d57..3ba29bf154b 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,7 +1,9 @@ """Native Home Assistant iOS app component.""" import datetime from http import HTTPStatus +from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant import config_entries @@ -218,7 +220,7 @@ CONFIGURATION_FILE = ".ios.conf" PLATFORMS = [Platform.SENSOR] -def devices_with_push(hass): +def devices_with_push(hass: HomeAssistant) -> dict[str, str]: """Return a dictionary of push enabled targets.""" return { device_name: device.get(ATTR_PUSH_ID) @@ -227,7 +229,7 @@ def devices_with_push(hass): } -def enabled_push_ids(hass): +def enabled_push_ids(hass: HomeAssistant) -> list[str]: """Return a list of push enabled target push IDs.""" return [ device.get(ATTR_PUSH_ID) @@ -236,16 +238,16 @@ def enabled_push_ids(hass): ] -def devices(hass): +def devices(hass: HomeAssistant) -> dict[str, dict[str, Any]]: """Return a dictionary of all identified devices.""" - return hass.data[DOMAIN][ATTR_DEVICES] + return hass.data[DOMAIN][ATTR_DEVICES] # type: ignore[no-any-return] -def device_name_for_push_id(hass, push_id): +def device_name_for_push_id(hass: HomeAssistant, push_id: str) -> str | None: """Return the device name for the push ID.""" for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items(): if device.get(ATTR_PUSH_ID) is push_id: - return device_name + return device_name # type: ignore[no-any-return] return None @@ -299,12 +301,12 @@ class iOSPushConfigView(HomeAssistantView): url = "/api/ios/push" name = "api:ios:push" - def __init__(self, push_config): + def __init__(self, push_config: dict[str, Any]) -> None: """Init the view.""" self.push_config = push_config @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle the GET request for the push configuration.""" return self.json(self.push_config) @@ -315,12 +317,12 @@ class iOSConfigView(HomeAssistantView): url = "/api/ios/config" name = "api:ios:config" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Init the view.""" self.config = config @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle the GET request for the user-defined configuration.""" return self.json(self.config) @@ -331,18 +333,18 @@ class iOSIdentifyDeviceView(HomeAssistantView): url = "/api/ios/identify" name = "api:ios:identify" - def __init__(self, config_path): + def __init__(self, config_path: str) -> None: """Initialize the view.""" self._config_path = config_path - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle the POST request for device identification.""" try: data = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index de6091e3638..a8d1b2514cd 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations from http import HTTPStatus import logging +from typing import Any import requests @@ -25,11 +26,13 @@ _LOGGER = logging.getLogger(__name__) PUSH_URL = "https://ios-push.home-assistant.io/push" -def log_rate_limits(hass, target, resp, level=20): +def log_rate_limits( + hass: HomeAssistant, target: str, resp: dict[str, Any], level: int = 20 +) -> None: """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) - resetsAtTime = resetsAt - dt_util.utcnow() + resetsAtTime = resetsAt - dt_util.utcnow() if resetsAt is not None else "---" rate_limit_msg = ( "iOS push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " @@ -69,13 +72,13 @@ class iOSNotificationService(BaseNotificationService): """Initialize the service.""" @property - def targets(self): + def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return ios.devices_with_push(self.hass) - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the Lambda APNS gateway.""" - data = {ATTR_MESSAGE: message} + data: dict[str, Any] = {ATTR_MESSAGE: message} # Remove default title from notifications. if ( diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 610cea8c814..6c6642a0226 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,6 +1,8 @@ """Support for Home Assistant iOS app sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -66,7 +68,10 @@ class IOSSensor(SensorEntity): _attr_has_entity_name = True def __init__( - self, device_name, device, description: SensorEntityDescription + self, + device_name: str, + device: dict[str, Any], + description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -92,7 +97,7 @@ class IOSSensor(SensorEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" device = self._device[ios.ATTR_DEVICE] device_battery = self._device[ios.ATTR_BATTERY] @@ -105,7 +110,7 @@ class IOSSensor(SensorEntity): } @property - def icon(self): + def icon(self) -> str: """Return the icon to use in the frontend, if any.""" device_battery = self._device[ios.ATTR_BATTERY] battery_state = device_battery[ios.ATTR_BATTERY_STATE] @@ -128,7 +133,7 @@ class IOSSensor(SensorEntity): return icon_for_battery_level(battery_level=battery_level, charging=charging) @callback - def _update(self, device): + def _update(self, device: dict[str, Any]) -> None: """Get the latest state of the sensor.""" self._device = device self._attr_native_value = self._device[ios.ATTR_BATTERY][ diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 83ddd373992..630c39b3ad4 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -66,7 +66,7 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject def load_json( - filename: str | PathLike, + filename: str | PathLike[str], default: JsonValueType = _SENTINEL, # type: ignore[assignment] ) -> JsonValueType: """Load JSON data from a file. @@ -89,7 +89,7 @@ def load_json( def load_json_array( - filename: str | PathLike, + filename: str | PathLike[str], default: JsonArrayType = _SENTINEL, # type: ignore[assignment] ) -> JsonArrayType: """Load JSON data from a file and return as list. @@ -109,7 +109,7 @@ def load_json_array( def load_json_object( - filename: str | PathLike, + filename: str | PathLike[str], default: JsonObjectType = _SENTINEL, # type: ignore[assignment] ) -> JsonObjectType: """Load JSON data from a file and return as dict. diff --git a/mypy.ini b/mypy.ini index 8c0d6823efd..e349f5d83fc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2091,6 +2091,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ios.*] +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.ipp.*] check_untyped_defs = true disallow_incomplete_defs = true From e840824a6e67f68e36148915643a8ccc97e7d294 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Sat, 13 Jan 2024 01:50:42 +1100 Subject: [PATCH 0535/1544] Bump aio_geojson_generic_client to 0.4 (#107866) --- homeassistant/components/geo_json_events/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 9f77f9b112e..8f4b36657dd 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_generic_client"], - "requirements": ["aio-geojson-generic-client==0.3"] + "requirements": ["aio-geojson-generic-client==0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0c10e53813f..af68ac1763f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -167,7 +167,7 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.3 +aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ccc279a5b5e..ce4a9cbe0cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.3 +aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.15 From 28917011cb9da2e40ce27dbd580fcf6c4c663353 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 12 Jan 2024 17:56:43 +0100 Subject: [PATCH 0536/1544] Update frontend to 20240112.0 (#107886) --- 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 ad24f6bb12d..f12c6abce25 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==20240104.0"] + "requirements": ["home-assistant-frontend==20240112.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a112354f08..627b0a7a2ef 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.1.0 hass-nabucasa==0.75.1 hassil==1.5.2 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240104.0 +home-assistant-frontend==20240112.0 home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index af68ac1763f..9659cb5924e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1044,7 +1044,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240104.0 +home-assistant-frontend==20240112.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce4a9cbe0cf..e167600c19a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -837,7 +837,7 @@ hole==0.8.0 holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20240104.0 +home-assistant-frontend==20240112.0 # homeassistant.components.conversation home-assistant-intents==2024.1.2 From d0e9e54f26c6c53a8b5c186e9d4fe8a54f796a2b Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Fri, 12 Jan 2024 18:45:02 +0100 Subject: [PATCH 0537/1544] Extend Nuki integration to expose ringer through Nuki Opener (#107745) * Expose ring_action_state and ring_action_timestamp of Nuki Opener * add translation key * address comments --- .../components/nuki/binary_sensor.py | 40 +++++++++++++++++-- homeassistant/components/nuki/strings.json | 5 +++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 240bb2dc525..e3b2d129017 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -19,16 +19,22 @@ from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nuki lock binary sensor.""" + """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] - entities = [] + lock_entities = [] + opener_entities = [] for lock in entry_data.locks: if lock.is_door_sensor_activated: - entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) + lock_entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) - async_add_entities(entities) + async_add_entities(lock_entities) + + for opener in entry_data.openers: + opener_entities.extend([NukiRingactionEntity(entry_data.coordinator, opener)]) + + async_add_entities(opener_entities) class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): @@ -70,3 +76,29 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): def is_on(self): """Return true if the door is open.""" return self.door_sensor_state == STATE_DOORSENSOR_OPENED + + +class NukiRingactionEntity(NukiEntity[NukiDevice], BinarySensorEntity): + """Representation of a Nuki Opener Ringaction.""" + + _attr_has_entity_name = True + _attr_translation_key = "ring_action" + _attr_icon = "mdi:bell-ring" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._nuki_device.nuki_id}_ringaction" + + @property + def extra_state_attributes(self): + """Return the device specific state attributes.""" + data = { + ATTR_NUKI_ID: self._nuki_device.nuki_id, + } + return data + + @property + def is_on(self) -> bool: + """Return the value of the ring action state.""" + return self._nuki_device.ring_action_state diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index 216b891ac31..beac3cb7f74 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -38,6 +38,11 @@ } }, "entity": { + "binary_sensor": { + "ring_action": { + "name": "Ring Action" + } + }, "lock": { "nuki_lock": { "state_attributes": { From 86e608d04f7251b33b621cdb07918f1afe770b46 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Fri, 12 Jan 2024 21:35:09 +0100 Subject: [PATCH 0538/1544] Handle missing fields from Roomba (#107893) Add default values to mission_stats and run_stats --- homeassistant/components/roomba/irobot_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index b5dd9fedbd3..38de3a7fb2b 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -105,12 +105,12 @@ class IRobotEntity(Entity): @property def run_stats(self): """Return the run stats.""" - return self.vacuum_state.get("bbrun") + return self.vacuum_state.get("bbrun", {}) @property def mission_stats(self): """Return the mission stats.""" - return self.vacuum_state.get("bbmssn") + return self.vacuum_state.get("bbmssn", {}) @property def battery_stats(self): From 3a4c64b0a7e4e1763401fabe06fb066f96d150a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Jan 2024 10:39:27 -1000 Subject: [PATCH 0539/1544] Fix missing timeout exception check in powerwall config flow (#107899) * Fix missing timeout exception check in powerwall config flow powerwall recently switched to asyncio, and every place we check for unreachable we need to check for timeout error. There was one missed ``` 09:08 homeassistant homeassistant[546]: 2024-01-12 10:09:08.899 ERROR (MainThread) [homeassistant.components.powerwall.config_flow] Unexpected exception Jan 12 20:09:08 homeassistant homeassistant[546]: Traceback (most recent call last): Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 168, in _async_try_connect Jan 12 20:09:08 homeassistant homeassistant[546]: info = await validate_input(self.hass, user_input) Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 76, in validate_input Jan 12 20:09:08 homeassistant homeassistant[546]: site_info, gateway_din = await _login_and_fetch_site_info( Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/src/homeassistant/homeassistant/components/powerwall/config_flow.py", line 43, in _login_and_fetch_site_info Jan 12 20:09:08 homeassistant homeassistant[546]: await power_wall.login(password) Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/powerwall.py", line 58, in login Jan 12 20:09:08 homeassistant homeassistant[546]: return await self.login_as(User.CUSTOMER, password, email, force_sm_off) Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/powerwall.py", line 49, in login_as Jan 12 20:09:08 homeassistant homeassistant[546]: response = await self._api.login(user, email, password, force_sm_off) Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/api.py", line 172, in login Jan 12 20:09:08 homeassistant homeassistant[546]: return await self.post( Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/tesla_powerwall/api.py", line 146, in post Jan 12 20:09:08 homeassistant homeassistant[546]: response = await self._http_session.post( Jan 12 20:09:08 homeassistant homeassistant[546]: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/aiohttp/client.py", line 601, in _request Jan 12 20:09:08 homeassistant homeassistant[546]: await resp.start(conn) Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/aiohttp/client_reqrep.py", line 960, in start Jan 12 20:09:08 homeassistant homeassistant[546]: with self._timer: Jan 12 20:09:08 homeassistant homeassistant[546]: File "/usr/local/lib/python3.12/site-packages/aiohttp/helpers.py", line 735, in __exit__ Jan 12 20:09:08 homeassistant homeassistant[546]: raise asyncio.TimeoutError from None Jan 12 20:09:08 homeassistant homeassistant[546]: TimeoutError ``` * cov --- homeassistant/components/powerwall/config_flow.py | 2 +- tests/components/powerwall/test_config_flow.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 0946a71a01d..a00d1eaa041 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -166,7 +166,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) - except PowerwallUnreachableError as ex: + except (PowerwallUnreachableError, asyncio.TimeoutError) as ex: errors[CONF_IP_ADDRESS] = "cannot_connect" description_placeholders = {"error": str(ex)} except WrongVersion as ex: diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index d79bf6c50f0..f9dcc4e1c83 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -4,6 +4,7 @@ import asyncio from datetime import timedelta from unittest.mock import MagicMock, patch +import pytest from tesla_powerwall import ( AccessDeniedError, MissingAttributeError, @@ -60,15 +61,14 @@ async def test_form_source_user(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("exc", (PowerwallUnreachableError, asyncio.TimeoutError)) +async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_powerwall = await _mock_powerwall_side_effect( - site_info=PowerwallUnreachableError - ) + mock_powerwall = await _mock_powerwall_side_effect(site_info=exc) with patch( "homeassistant.components.powerwall.config_flow.Powerwall", From a7d21c709d6bb80ba42a61c02e8ca48c5a084c55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Jan 2024 11:06:44 -1000 Subject: [PATCH 0540/1544] Bump orjson to 3.9.10 (#107898) --- 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 627b0a7a2ef..98d5b9a1967 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.9 +orjson==3.9.10 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.1.0 diff --git a/pyproject.toml b/pyproject.toml index 1e5c201e24b..ac8aa79c91f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "cryptography==41.0.7", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.9", + "orjson==3.9.10", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index a5d3bc50802..e9f61c8b2e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ lru-dict==1.3.0 PyJWT==2.8.0 cryptography==41.0.7 pyOpenSSL==23.2.0 -orjson==3.9.9 +orjson==3.9.10 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From 71aecab38b9c47f2ae85a19e75ea88c68df923d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Jan 2024 11:06:59 -1000 Subject: [PATCH 0541/1544] Revert "Restrict Version Disclosure to Authenticated Requests in Home Assistant" (#107904) --- homeassistant/components/websocket_api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 8ca2112191d..2c86a26efc9 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -42,7 +42,7 @@ def auth_ok_message() -> dict[str, str]: def auth_required_message() -> dict[str, str]: """Return an auth_required message.""" - return {"type": TYPE_AUTH_REQUIRED} + return {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} def auth_invalid_message(message: str) -> dict[str, str]: From 68698cacac0cd7abaae806e516f76bf3ff3a7130 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 12 Jan 2024 22:50:15 +0100 Subject: [PATCH 0542/1544] Remove deprecated YAML support from litejet (#107884) --- homeassistant/components/litejet/__init__.py | 37 +----------- .../components/litejet/config_flow.py | 55 +---------------- homeassistant/components/litejet/strings.json | 6 -- tests/components/litejet/test_config_flow.py | 60 +------------------ tests/components/litejet/test_init.py | 10 ---- 5 files changed, 5 insertions(+), 163 deletions(-) diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 8c6d5ef4487..da24aee9ab8 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -2,49 +2,16 @@ import logging import pylitejet -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import CONF_EXCLUDE_NAMES, CONF_INCLUDE_SWITCHES, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_EXCLUDE_NAMES): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LiteJet component.""" - if DOMAIN in config: - # Configuration.yaml config exists, trigger the import flow. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LiteJet via a config entry.""" diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 1062e948090..a7b5a6f000e 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -9,10 +9,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, callback -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -54,21 +53,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Create a LiteJet config entry based upon user input.""" if self._async_current_entries(): - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LiteJet", - }, - ) return self.async_abort(reason="single_instance_allowed") errors = {} @@ -78,20 +62,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: system = await pylitejet.open(port) except SerialException: - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_yaml_serial_exception", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key="deprecated_yaml_serial_exception", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=litejet" - }, - ) errors[CONF_PORT] = "open_failed" else: await system.close() @@ -106,27 +76,6 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: - """Import litejet config from configuration.yaml.""" - new_data = {CONF_PORT: import_data[CONF_PORT]} - result = await self.async_step_user(new_data) - if result["type"] == FlowResultType.CREATE_ENTRY: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LiteJet", - }, - ) - return result - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json index 288e5f959a8..398f1a1e5aa 100644 --- a/homeassistant/components/litejet/strings.json +++ b/homeassistant/components/litejet/strings.json @@ -25,11 +25,5 @@ } } } - }, - "issues": { - "deprecated_yaml_serial_exception": { - "title": "The LiteJet YAML configuration import failed", - "description": "Configuring LiteJet using YAML is being removed but there was an error opening the serial port when importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or manually continue to [set up the integration]({url})." - } } } diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index e2b2829de9e..b490643f622 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -6,8 +6,7 @@ from serial import SerialException from homeassistant import config_entries, data_entry_flow from homeassistant.components.litejet.const import CONF_DEFAULT_TRANSITION, DOMAIN from homeassistant.const import CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -68,63 +67,6 @@ async def test_flow_open_failed(hass: HomeAssistant) -> None: assert result["errors"][CONF_PORT] == "open_failed" -async def test_import_step(hass: HomeAssistant, mock_litejet) -> None: - """Test initializing via import step.""" - test_data = {CONF_PORT: "/dev/imported"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == "create_entry" - assert result["title"] == test_data[CONF_PORT] - assert result["data"] == test_data - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_step_fails(hass: HomeAssistant) -> None: - """Test initializing via import step fails due to can't open port.""" - test_data = {CONF_PORT: "/dev/test"} - with patch("pylitejet.LiteJet") as mock_pylitejet: - mock_pylitejet.side_effect = SerialException - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"port": "open_failed"} - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_serial_exception") - - -async def test_import_step_already_exist(hass: HomeAssistant) -> None: - """Test initializing via import step when entry already exist.""" - first_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_PORT: "/dev/imported"}, - ) - first_entry.add_to_hass(hass) - - test_data = {CONF_PORT: "/dev/imported"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry(domain=DOMAIN, data={CONF_PORT: "/dev/test"}) diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py index fdaeeefc867..c6f0d5c5b02 100644 --- a/tests/components/litejet/test_init.py +++ b/tests/components/litejet/test_init.py @@ -1,7 +1,6 @@ """The tests for the litejet component.""" from homeassistant.components import litejet from homeassistant.components.litejet.const import DOMAIN -from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,15 +13,6 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert DOMAIN not in hass.data -async def test_setup_with_config_to_import(hass: HomeAssistant, mock_litejet) -> None: - """Test that import happens.""" - assert ( - await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PORT: "/dev/hello"}}) - is True - ) - assert DOMAIN in hass.data - - async def test_unload_entry(hass: HomeAssistant, mock_litejet) -> None: """Test being able to unload an entry.""" entry = await async_init_integration(hass, use_switch=True, use_scene=True) From 7bcfcfef5f1085a3ab3bf00d8fafa326466b8b75 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 12 Jan 2024 23:27:31 +0100 Subject: [PATCH 0543/1544] Improve Netatmo tests (#107902) * Improve Netatmo tests * Improve Netatmo tests --- tests/components/netatmo/common.py | 25 +++---- tests/components/netatmo/conftest.py | 6 +- tests/components/netatmo/test_camera.py | 58 ++++++++-------- tests/components/netatmo/test_climate.py | 69 +++++++++++--------- tests/components/netatmo/test_cover.py | 10 +-- tests/components/netatmo/test_diagnostics.py | 3 +- tests/components/netatmo/test_init.py | 28 +++++--- tests/components/netatmo/test_light.py | 5 +- tests/components/netatmo/test_select.py | 9 ++- tests/components/netatmo/test_sensor.py | 44 ++++++++----- tests/components/netatmo/test_switch.py | 10 +-- 11 files changed, 152 insertions(+), 115 deletions(-) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 61a7bc2354d..5018edf8691 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -1,17 +1,17 @@ """Common methods used across tests for Netatmo.""" from contextlib import contextmanager import json -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, patch from homeassistant.components.webhook import async_handle_webhook +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.util.aiohttp import MockRequest from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMockResponse -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - COMMON_RESPONSE = { "user_id": "91763b24c43d3e344f424e8d", "home_id": "91763b24c43d3e344f424e8b", @@ -19,16 +19,12 @@ COMMON_RESPONSE = { "user": {"id": "91763b24c43d3e344f424e8b", "email": "john@doe.com"}, } -TEST_TIME = 1559347200.0 - FAKE_WEBHOOK_ACTIVATION = { "push_type": "webhook_activation", } -DEFAULT_PLATFORMS = ["camera", "climate", "light", "sensor"] - -async def fake_post_request(*args, **kwargs): +async def fake_post_request(*args: Any, **kwargs: Any): """Return fake data.""" if "endpoint" not in kwargs: return "{}" @@ -62,7 +58,7 @@ async def fake_post_request(*args, **kwargs): ) -async def fake_get_image(*args, **kwargs): +async def fake_get_image(*args: Any, **kwargs: Any) -> bytes | str: """Return fake data.""" if "endpoint" not in kwargs: return "{}" @@ -73,12 +69,7 @@ async def fake_get_image(*args, **kwargs): return b"test stream image bytes" -async def fake_post_request_no_data(*args, **kwargs): - """Fake error during requesting backend data.""" - return "{}" - - -async def simulate_webhook(hass, webhook_id, response): +async def simulate_webhook(hass: HomeAssistant, webhook_id: str, response) -> None: """Simulate a webhook event.""" request = MockRequest( method="POST", @@ -90,7 +81,7 @@ async def simulate_webhook(hass, webhook_id, response): @contextmanager -def selected_platforms(platforms): +def selected_platforms(platforms: list[Platform]) -> AsyncMock: """Restrict loaded platforms to list given.""" with patch( "homeassistant.components.netatmo.data_handler.PLATFORMS", platforms diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index a10030fab08..bfd7fa6a072 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -5,13 +5,15 @@ from unittest.mock import AsyncMock, patch from pyatmo.const import ALL_SCOPES import pytest +from homeassistant.core import HomeAssistant + from .common import fake_get_image, fake_post_request from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def mock_config_entry_fixture(hass): +def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: """Mock a config entry.""" mock_entry = MockConfigEntry( domain="netatmo", @@ -55,7 +57,7 @@ def mock_config_entry_fixture(hass): @pytest.fixture(name="netatmo_auth") -def netatmo_auth(): +def netatmo_auth() -> AsyncMock: """Restrict loaded platforms to list given.""" with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 6dcc11d31ab..12cfd1603d0 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -1,10 +1,10 @@ """The tests for Netatmo camera.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch import pyatmo import pytest -import requests_mock from homeassistant.components import camera from homeassistant.components.camera import STATE_STREAMING @@ -14,21 +14,21 @@ from homeassistant.components.netatmo.const import ( SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, ) -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from .common import fake_post_request, selected_platforms, simulate_webhook -from tests.common import async_capture_events, async_fire_time_changed +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed async def test_setup_component_with_webhook( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup with webhook.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -134,10 +134,10 @@ IMAGE_BYTES_FROM_STREAM = b"test stream image bytes" async def test_camera_image_local( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test retrieval or local camera image.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -162,10 +162,10 @@ async def test_camera_image_local( async def test_camera_image_vpn( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test retrieval of remote camera image.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -188,10 +188,10 @@ async def test_camera_image_vpn( async def test_service_set_person_away( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set person as away.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -227,10 +227,10 @@ async def test_service_set_person_away( async def test_service_set_person_away_invalid_person( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set invalid person as away.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -255,10 +255,10 @@ async def test_service_set_person_away_invalid_person( async def test_service_set_persons_home_invalid_person( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set invalid persons as home.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -283,10 +283,10 @@ async def test_service_set_persons_home_invalid_person( async def test_service_set_persons_home( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set persons as home.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -309,10 +309,10 @@ async def test_service_set_persons_home( async def test_service_set_camera_light( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set the outdoor camera light mode.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -341,10 +341,10 @@ async def test_service_set_camera_light( async def test_service_set_camera_light_invalid_type( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service to set the indoor camera light mode.""" - with selected_platforms(["camera"]): + with selected_platforms([Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -371,11 +371,13 @@ async def test_service_set_camera_light_invalid_type( assert "NACamera does not have a floodlight" in excinfo.value.args[0] -async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> None: +async def test_camera_reconnect_webhook( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test webhook event on camera reconnect.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -427,7 +429,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No async def test_webhook_person_event( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test that person events are handled.""" with selected_platforms(["camera"]): @@ -465,7 +467,9 @@ async def test_webhook_person_event( assert test_netatmo_event -async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_no_devices( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup with no devices.""" fake_post_hits = 0 @@ -495,12 +499,12 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> async def test_camera_image_raises_exception( - hass: HomeAssistant, config_entry, requests_mock: requests_mock.Mocker + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Return fake data.""" nonlocal fake_post_hits fake_post_hits += 1 diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 11e2077f859..53bb0c1f052 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -1,6 +1,6 @@ """The tests for the Netatmo climate platform.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from voluptuous.error import MultipleInvalid @@ -31,19 +31,26 @@ from homeassistant.components.netatmo.const import ( SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_WEBHOOK_ID, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook +from tests.common import MockConfigEntry + async def test_webhook_event_handling_thermostats( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service and webhook event handling with thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -214,10 +221,10 @@ async def test_webhook_event_handling_thermostats( async def test_service_preset_mode_frost_guard_thermostat( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with frost guard preset for thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -287,10 +294,10 @@ async def test_service_preset_mode_frost_guard_thermostat( async def test_service_preset_modes_thermostat( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with preset modes for thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -367,10 +374,10 @@ async def test_service_preset_modes_thermostat( async def test_service_set_temperature_with_end_datetime( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service setting temperature with an end datetime.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -425,10 +432,10 @@ async def test_service_set_temperature_with_end_datetime( async def test_service_set_temperature_with_time_period( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service setting temperature with an end datetime.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -483,10 +490,10 @@ async def test_service_set_temperature_with_time_period( async def test_service_clear_temperature_setting( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service clearing temperature setting.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -564,10 +571,10 @@ async def test_service_clear_temperature_setting( async def test_webhook_event_handling_no_data( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service and webhook event handling with erroneous data.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -618,7 +625,7 @@ async def test_service_schedule_thermostats( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service for selecting Netatmo schedule with thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -671,7 +678,7 @@ async def test_service_preset_mode_with_end_time_thermostats( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service for set preset mode with end datetime for Netatmo thermostats.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -740,10 +747,10 @@ async def test_service_preset_mode_with_end_time_thermostats( async def test_service_preset_mode_already_boost_valves( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with boost preset for valves when already in boost mode.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -820,10 +827,10 @@ async def test_service_preset_mode_already_boost_valves( async def test_service_preset_mode_boost_valves( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service with boost preset for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -875,7 +882,7 @@ async def test_service_preset_mode_invalid( hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth ) -> None: """Test service with invalid preset.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -891,10 +898,10 @@ async def test_service_preset_mode_invalid( async def test_valves_service_turn_off( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn off for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -943,10 +950,10 @@ async def test_valves_service_turn_off( async def test_valves_service_turn_on( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -990,10 +997,10 @@ async def test_valves_service_turn_on( async def test_webhook_home_id_mismatch( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -1030,10 +1037,10 @@ async def test_webhook_home_id_mismatch( async def test_webhook_set_point( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test service turn on for valves.""" - with selected_platforms(["climate"]): + with selected_platforms([Platform.CLIMATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index cf1cca197a4..d543d141b62 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -1,5 +1,5 @@ """The tests for Netatmo cover.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from homeassistant.components.cover import ( ATTR_POSITION, @@ -9,17 +9,19 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from .common import selected_platforms +from tests.common import MockConfigEntry + async def test_cover_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" - with selected_platforms(["cover"]): + with selected_platforms([Platform.COVER]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 19f83830a4e..2d13e36150d 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.setup import async_setup_component from .common import fake_post_request +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -17,7 +18,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - config_entry, + config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" with patch( diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 75b1e9e47e6..a9181ef49f7 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.netatmo import DOMAIN -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -54,7 +54,9 @@ FAKE_WEBHOOK = { } -async def test_setup_component(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup and teardown of the netatmo component.""" with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", @@ -86,7 +88,9 @@ async def test_setup_component(hass: HomeAssistant, config_entry) -> None: assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_with_config(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_with_config( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 @@ -127,7 +131,9 @@ async def test_setup_component_with_webhook( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: """Test setup and teardown of the netatmo component with webhook registration.""" - with selected_platforms(["camera", "climate", "light", "sensor"]): + with selected_platforms( + [Platform.CAMERA, Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR] + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -155,7 +161,7 @@ async def test_setup_component_with_webhook( async def test_setup_without_https( - hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -182,7 +188,9 @@ async def test_setup_without_https( assert "https and port 443 is required to register the webhook" in caplog.text -async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: +async def test_setup_with_cloud( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test if set up with active cloud subscription.""" await mock_cloud(hass) await hass.async_block_till_done() @@ -296,7 +304,9 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_with_delay(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_with_delay( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup of the netatmo component with delayed startup.""" hass.state = CoreState.not_running @@ -404,7 +414,9 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(config_entry.entry_id) -async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) -> None: +async def test_setup_component_invalid_token( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test handling of invalid token.""" async def fake_ensure_valid_token(*args, **kwargs): diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index b6df9191976..b5fbadf066a 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant from .common import FAKE_WEBHOOK_ACTIVATION, selected_platforms, simulate_webhook +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse async def test_camera_light_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test camera ligiht setup and services.""" with selected_platforms(["light"]): @@ -127,7 +128,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> async def test_light_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" with selected_platforms(["light"]): diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index aebfa23cee9..f513cc3a8d9 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -1,5 +1,5 @@ """The tests for the Netatmo climate platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -13,9 +13,14 @@ from homeassistant.core import HomeAssistant from .common import selected_platforms, simulate_webhook +from tests.common import MockConfigEntry + async def test_select_schedule_thermostats( - hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + netatmo_auth: AsyncMock, ) -> None: """Test service for selecting Netatmo schedule with thermostats.""" with selected_platforms(["climate", "select"]): diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index ce35873c3e5..03251277ddf 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -1,18 +1,23 @@ """The tests for the Netatmo sensor platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from homeassistant.components.netatmo import sensor +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import TEST_TIME, selected_platforms +from .common import selected_platforms + +from tests.common import MockConfigEntry -async def test_indoor_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: +async def test_indoor_sensor( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test indoor sensor setup.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -25,9 +30,11 @@ async def test_indoor_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> assert hass.states.get(f"{prefix}pressure").state == "1014.5" -async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) -> None: +async def test_weather_sensor( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test weather sensor unreachable.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -38,10 +45,10 @@ async def test_weather_sensor(hass: HomeAssistant, config_entry, netatmo_auth) - async def test_public_weather_sensor( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test public weather sensor setup.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -93,7 +100,7 @@ async def test_public_weather_sensor( ("strength", "expected"), [(50, "Full"), (60, "High"), (80, "Medium"), (90, "Low")], ) -async def test_process_wifi(strength, expected) -> None: +async def test_process_wifi(strength: int, expected: str) -> None: """Test wifi strength translation.""" assert sensor.process_wifi(strength) == expected @@ -102,7 +109,7 @@ async def test_process_wifi(strength, expected) -> None: ("strength", "expected"), [(50, "Full"), (70, "High"), (80, "Medium"), (90, "Low")], ) -async def test_process_rf(strength, expected) -> None: +async def test_process_rf(strength: int, expected: str) -> None: """Test radio strength translation.""" assert sensor.process_rf(strength) == expected @@ -111,7 +118,7 @@ async def test_process_rf(strength, expected) -> None: ("health", "expected"), [(4, "Unhealthy"), (3, "Poor"), (2, "Fair"), (1, "Fine"), (0, "Healthy")], ) -async def test_process_health(health, expected) -> None: +async def test_process_health(health: int, expected: str) -> None: """Test health index translation.""" assert sensor.process_health(health) == expected @@ -182,10 +189,15 @@ async def test_process_health(health, expected) -> None: ], ) async def test_weather_sensor_enabling( - hass: HomeAssistant, config_entry, uid, name, expected, netatmo_auth + hass: HomeAssistant, + config_entry: MockConfigEntry, + uid: str, + name: str, + expected: str, + netatmo_auth: AsyncMock, ) -> None: """Test enabling of by default disabled sensors.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms(["sensor"]): + with selected_platforms([Platform.SENSOR]): states_before = len(hass.states.async_all()) assert hass.states.get(f"sensor.{name}") is None @@ -206,12 +218,10 @@ async def test_weather_sensor_enabling( async def test_climate_battery_sensor( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test climate device battery sensor.""" - with patch("time.time", return_value=TEST_TIME), selected_platforms( - ["sensor", "climate"] - ): + with selected_platforms([Platform.CLIMATE, Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index 545a2261e41..25abfd1a371 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,22 +1,24 @@ """The tests for Netatmo switch.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from .common import selected_platforms +from tests.common import MockConfigEntry + async def test_switch_setup_and_services( - hass: HomeAssistant, config_entry, netatmo_auth + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: """Test setup and services.""" - with selected_platforms(["switch"]): + with selected_platforms([Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From ca1aaacc90592a3554b9b0cc97a089513a48b334 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Jan 2024 08:21:11 +0100 Subject: [PATCH 0544/1544] Enable strict typing for system_log (#107914) --- .strict-typing | 1 + .../components/system_log/__init__.py | 22 ++++++++++++------- mypy.ini | 10 +++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.strict-typing b/.strict-typing index 39e03820582..f7a02feec67 100644 --- a/.strict-typing +++ b/.strict-typing @@ -388,6 +388,7 @@ homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.system_health.* +homeassistant.components.system_log.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index fab2b7ee291..3ede14a2ad6 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -13,10 +13,12 @@ import voluptuous as vol from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +KeyType = tuple[str, tuple[str, int], str | None] + CONF_MAX_ENTRIES = "max_entries" CONF_FIRE_EVENT = "fire_event" CONF_MESSAGE = "message" @@ -60,7 +62,7 @@ SERVICE_WRITE_SCHEMA = vol.Schema( def _figure_out_source( - record: logging.LogRecord, paths_re: re.Pattern + record: logging.LogRecord, paths_re: re.Pattern[str] ) -> tuple[str, int]: """Figure out where a log message came from.""" # If a stack trace exists, extract file names from the entire call stack. @@ -184,7 +186,7 @@ class LogEntry: self.count = 1 self.key = (self.name, source, self.root_cause) - def to_dict(self): + def to_dict(self) -> dict[str, Any]: """Convert object into dict to maintain backward compatibility.""" return { "name": self.name, @@ -198,10 +200,10 @@ class LogEntry: } -class DedupStore(OrderedDict): +class DedupStore(OrderedDict[KeyType, LogEntry]): """Data store to hold max amount of deduped entries.""" - def __init__(self, maxlen=50): + def __init__(self, maxlen: int = 50) -> None: """Initialize a new DedupStore.""" super().__init__() self.maxlen = maxlen @@ -227,7 +229,7 @@ class DedupStore(OrderedDict): # Removes the first record which should also be the oldest self.popitem(last=False) - def to_list(self): + def to_list(self) -> list[dict[str, Any]]: """Return reversed list of log entries - LIFO.""" return [value.to_dict() for value in reversed(self.values())] @@ -236,7 +238,11 @@ class LogErrorHandler(logging.Handler): """Log handler for error messages.""" def __init__( - self, hass: HomeAssistant, maxlen: int, fire_event: bool, paths_re: re.Pattern + self, + hass: HomeAssistant, + maxlen: int, + fire_event: bool, + paths_re: re.Pattern[str], ) -> None: """Initialize a new LogErrorHandler.""" super().__init__() @@ -276,7 +282,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = handler @callback - def _async_stop_handler(_) -> None: + def _async_stop_handler(_: Event) -> None: """Cleanup handler.""" logging.root.removeHandler(handler) del hass.data[DOMAIN] diff --git a/mypy.ini b/mypy.ini index e349f5d83fc..f3be41c3036 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3642,6 +3642,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.system_log.*] +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.systemmonitor.*] check_untyped_defs = true disallow_incomplete_defs = true From 7c98c1e544beb6178d0d448a49ff523a44916125 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Jan 2024 08:48:42 +0100 Subject: [PATCH 0545/1544] Enable strict typing for rest_command (#107911) --- .strict-typing | 1 + homeassistant/components/rest_command/__init__.py | 7 +++++-- mypy.ini | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index f7a02feec67..1163dea618c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -339,6 +339,7 @@ homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.repairs.* homeassistant.components.rest.* +homeassistant.components.rest_command.* homeassistant.components.rfxtrx.* homeassistant.components.rhasspy.* homeassistant.components.ridwell.* diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 7d566933b5f..0c055fe0000 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,8 +1,11 @@ """Support for exposing regular REST commands as services.""" +from __future__ import annotations + import asyncio from http import HTTPStatus from json.decoder import JSONDecodeError import logging +from typing import Any import aiohttp from aiohttp import hdrs @@ -86,9 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_rest_command(name, command_config) @callback - def async_register_rest_command(name, command_config): + def async_register_rest_command(name: str, command_config: dict[str, Any]) -> None: """Create service for rest command.""" - websession = async_get_clientsession(hass, command_config.get(CONF_VERIFY_SSL)) + websession = async_get_clientsession(hass, command_config[CONF_VERIFY_SSL]) timeout = command_config[CONF_TIMEOUT] method = command_config[CONF_METHOD] diff --git a/mypy.ini b/mypy.ini index f3be41c3036..d049a922e1b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3151,6 +3151,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rest_command.*] +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.rfxtrx.*] check_untyped_defs = true disallow_incomplete_defs = true From 0458bd68d9aad50f5e21700eeb1ce4cb3cfaa511 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Jan 2024 22:11:42 -1000 Subject: [PATCH 0546/1544] Avoid duplicate search for existing config entries in homekit_controller (#107613) --- .../homekit_controller/config_flow.py | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 08444555aca..592a2301294 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -20,7 +20,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr @@ -79,17 +79,6 @@ def formatted_category(category: Categories) -> str: return str(category.name).replace("_", " ").title() -@callback -def find_existing_config_entry( - hass: HomeAssistant, upper_case_hkid: str -) -> config_entries.ConfigEntry | None: - """Return a set of the configured hosts.""" - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("AccessoryPairingID") == upper_case_hkid: - return entry - return None - - def ensure_pin_format(pin: str, allow_insecure_setup_codes: Any = None) -> str: """Ensure a pin code is correctly formatted. @@ -284,9 +273,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably # invalid. Remove it automatically. - if not paired and ( - existing := find_existing_config_entry(self.hass, upper_case_hkid) - ): + if not paired and existing_entry: if self.controller is None: await self._async_setup_controller() @@ -295,7 +282,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert self.controller pairing = self.controller.load_pairing( - existing.data["AccessoryPairingID"], dict(existing.data) + existing_entry.data["AccessoryPairingID"], dict(existing_entry.data) ) try: @@ -310,7 +297,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): model, hkid, ) - await self.hass.config_entries.async_remove(existing.entry_id) + await self.hass.config_entries.async_remove(existing_entry.entry_id) else: _LOGGER.debug( ( From 902619a4db4d914e6cda290f57f737c21de22cd8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 13 Jan 2024 10:18:21 +0100 Subject: [PATCH 0547/1544] Add snapshot tests to Netatmo platforms (#107932) * Add snapshot tests to Netatmo platforms * Add snapshot tests to Netatmo platforms --- tests/components/netatmo/common.py | 29 +- .../netatmo/snapshots/test_camera.ambr | 173 + .../netatmo/snapshots/test_climate.ambr | 385 ++ .../netatmo/snapshots/test_cover.ambr | 48 + .../netatmo/snapshots/test_light.ambr | 158 + .../netatmo/snapshots/test_select.ambr | 54 + .../netatmo/snapshots/test_sensor.ambr | 6160 +++++++++++++++++ .../netatmo/snapshots/test_switch.ambr | 45 + tests/components/netatmo/test_camera.py | 27 +- tests/components/netatmo/test_climate.py | 21 +- tests/components/netatmo/test_cover.py | 22 +- tests/components/netatmo/test_light.py | 29 +- tests/components/netatmo/test_select.py | 28 +- tests/components/netatmo/test_sensor.py | 20 +- tests/components/netatmo/test_switch.py | 22 +- 15 files changed, 7211 insertions(+), 10 deletions(-) create mode 100644 tests/components/netatmo/snapshots/test_camera.ambr create mode 100644 tests/components/netatmo/snapshots/test_climate.ambr create mode 100644 tests/components/netatmo/snapshots/test_cover.ambr create mode 100644 tests/components/netatmo/snapshots/test_light.ambr create mode 100644 tests/components/netatmo/snapshots/test_select.ambr create mode 100644 tests/components/netatmo/snapshots/test_sensor.ambr create mode 100644 tests/components/netatmo/snapshots/test_switch.ambr diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 5018edf8691..1bb2ab00d32 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -4,12 +4,15 @@ import json from typing import Any from unittest.mock import AsyncMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from homeassistant.util.aiohttp import MockRequest -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMockResponse COMMON_RESPONSE = { @@ -24,6 +27,30 @@ FAKE_WEBHOOK_ACTIVATION = { } +async def snapshot_platform_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platform: Platform, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot entities and their states.""" + with selected_platforms([platform]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + + async def fake_post_request(*args: Any, **kwargs: Any): """Return fake data.""" if "endpoint" not in kwargs: diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr new file mode 100644 index 00000000000..bf77abeb151 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_entity[camera.front-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:10:b9:0e-DeviceType.NOC', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', + 'friendly_name': 'Front', + 'frontend_stream_type': , + 'id': '12:34:56:10:b9:0e', + 'is_local': False, + 'light_state': None, + 'local_url': None, + 'monitoring': None, + 'motion_detection': True, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,', + }), + 'context': , + 'entity_id': 'camera.front', + 'last_changed': , + 'last_updated': , + 'state': 'streaming', + }) +# --- +# name: test_entity[camera.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.hall', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:00:f1:62-DeviceType.NACamera', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.hall?token=1caab5c3b3', + 'friendly_name': 'Hall', + 'frontend_stream_type': , + 'id': '12:34:56:00:f1:62', + 'is_local': True, + 'light_state': None, + 'local_url': 'http://192.168.0.123/678460a0d47e5618699fb31169e2b47d', + 'monitoring': None, + 'motion_detection': True, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,', + }), + 'context': , + 'entity_id': 'camera.hall', + 'last_changed': , + 'last_updated': , + 'state': 'streaming', + }) +# --- +# name: test_entity[camera.netatmo_doorbell-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.netatmo_doorbell', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:10:f1:66-DeviceType.NDB', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[camera.netatmo_doorbell-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'alim_status': 2, + 'attribution': 'Data provided by Netatmo', + 'brand': 'Netatmo', + 'entity_picture': '/api/camera_proxy/camera.netatmo_doorbell?token=1caab5c3b3', + 'friendly_name': 'Netatmo-Doorbell', + 'id': '12:34:56:10:f1:66', + 'is_local': None, + 'light_state': None, + 'local_url': None, + 'monitoring': None, + 'sd_status': 4, + 'supported_features': , + 'vpn_url': 'https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,', + }), + 'context': , + 'entity_id': 'camera.netatmo_doorbell', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0e7d81a9edb --- /dev/null +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -0,0 +1,385 @@ +# serializer version: 1 +# name: test_entity[climate.bureau-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bureau', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bureau', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '222452125-DeviceType.OTM', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.bureau-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bureau', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + }), + 'context': , + 'entity_id': 'climate.bureau', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[climate.cocina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.cocina', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cocina', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2940411577-DeviceType.NRV', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.cocina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 27, + 'friendly_name': 'Cocina', + 'heating_power_request': 0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Frost Guard', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 7, + }), + 'context': , + 'entity_id': 'climate.cocina', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.corridor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Corridor', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1002003001-DeviceType.BNS', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.corridor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 22, + 'friendly_name': 'Corridor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Schedule', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22, + }), + 'context': , + 'entity_id': 'climate.corridor', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.entrada-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.entrada', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Entrada', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2833524037-DeviceType.NRV', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.entrada-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 24.5, + 'friendly_name': 'Entrada', + 'heating_power_request': 0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'Frost Guard', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 7, + }), + 'context': , + 'entity_id': 'climate.entrada', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entity[climate.livingroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.livingroom', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Livingroom', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2746182631-DeviceType.NATherm1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.livingroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_temperature': 19.8, + 'friendly_name': 'Livingroom', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 7, + 'preset_mode': 'away', + 'preset_modes': list([ + 'away', + 'boost', + 'Frost Guard', + 'Schedule', + ]), + 'selected_schedule': 'Default', + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 12, + }), + 'context': , + 'entity_id': 'climate.livingroom', + 'last_changed': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr new file mode 100644 index 00000000000..58871b397e2 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_entity[cover.entrance_blinds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.entrance_blinds', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Entrance Blinds', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009999992-DeviceType.NBR', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[cover.entrance_blinds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_position': 0, + 'device_class': 'shutter', + 'friendly_name': 'Entrance Blinds', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.entrance_blinds', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr new file mode 100644 index 00000000000..7fac90b4ec0 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -0,0 +1,158 @@ +# serializer version: 1 +# name: test_entity[light.bathroom_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathroom light', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:01:01:01:a1-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.bathroom_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'color_mode': None, + 'friendly_name': 'Bathroom light', + 'supported_color_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.bathroom_light', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity[light.front-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:10:b9:0e-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Front', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.front', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[light.unknown_00_11_22_33_00_11_45_fe-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.unknown_00_11_22_33_00_11_45_fe', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unknown 00:11:22:33:00:11:45:fe', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:11:22:33:00:11:45:fe-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[light.unknown_00_11_22_33_00_11_45_fe-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Unknown 00:11:22:33:00:11:45:fe', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.unknown_00_11_22_33_00_11_45_fe', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_select.ambr b/tests/components/netatmo/snapshots/test_select.ambr new file mode 100644 index 00000000000..44886451b42 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_select.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entity[select.myhome-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Default', + 'Winter', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.myhome', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MYHOME', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '91763b24c43d3e344f424e8b-schedule-select', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[select.myhome-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'MYHOME', + 'options': list([ + 'Default', + 'Winter', + ]), + }), + 'context': , + 'entity_id': 'select.myhome', + 'last_changed': , + 'last_updated': , + 'state': 'Default', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6447f09fdba --- /dev/null +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -0,0 +1,6160 @@ +# serializer version: 1 +# name: test_entity[sensor.baby_bedroom_co2-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.baby_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.baby_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Baby Bedroom CO2', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1053', + }) +# --- +# name: test_entity[sensor.baby_bedroom_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Baby Bedroom Health', + 'icon': 'mdi:cloud', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'Fine', + }) +# --- +# name: test_entity[sensor.baby_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.baby_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Baby Bedroom Humidity', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_entity[sensor.baby_bedroom_noise-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.baby_bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Baby Bedroom Noise', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Baby Bedroom Pressure', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1021.4', + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.baby_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_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.baby_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Baby Bedroom Temperature', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baby_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '21.6', + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baby_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.baby_bedroom_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.baby_bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:68:92-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.baby_bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.bedroom_co2-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.bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Bedroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Bedroom Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_noise-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.bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Bedroom Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Bedroom Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.bedroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.bedroom_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.bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.bedroom_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:69:0c-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.bureau_modulate_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bureau_modulate_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bureau Modulate Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '222452125-12:34:56:20:f5:8c-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.bureau_modulate_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Bureau Modulate Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bureau_modulate_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.cold_water_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cold_water_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cold water Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#8-12:34:56:00:16:0e#8-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.cold_water_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Cold water Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.cold_water_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.cold_water_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cold_water_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Cold water Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#8-12:34:56:00:16:0e#8-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.cold_water_reachability-state] + None +# --- +# name: test_entity[sensor.consumption_meter_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.consumption_meter_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption meter Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.consumption_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Consumption meter Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consumption_meter_power', + 'last_changed': , + 'last_updated': , + 'state': '476', + }) +# --- +# name: test_entity[sensor.consumption_meter_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.consumption_meter_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Consumption meter Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:00:a1:4c:da-12:34:56:00:00:a1:4c:da-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.consumption_meter_reachability-state] + None +# --- +# name: test_entity[sensor.corridor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Corridor Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1002003001-1002003001-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.corridor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Corridor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.corridor_humidity', + 'last_changed': , + 'last_updated': , + 'state': '67', + }) +# --- +# name: test_entity[sensor.ecocompteur_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ecocompteur_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Écocompteur Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e-12:34:56:00:16:0e-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.ecocompteur_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Écocompteur Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ecocompteur_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.ecocompteur_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ecocompteur_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Écocompteur Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e-12:34:56:00:16:0e-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.ecocompteur_reachability-state] + None +# --- +# name: test_entity[sensor.gas_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#6-12:34:56:00:16:0e#6-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.gas_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Gas Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.gas_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Gas Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#6-12:34:56:00:16:0e#6-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.gas_reachability-state] + None +# --- +# name: test_entity[sensor.home_avg_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_angle-state] + None +# --- +# name: test_entity[sensor.home_avg_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_gust_angle-state] + None +# --- +# name: test_entity[sensor.home_avg_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_gust_strength-state] + None +# --- +# name: test_entity[sensor.home_avg_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.home_avg_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Home avg Humidity', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_avg_humidity', + 'last_changed': , + 'last_updated': , + 'state': '63.2', + }) +# --- +# name: test_entity[sensor.home_avg_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Home avg Pressure', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1010.4', + }) +# --- +# name: test_entity[sensor.home_avg_rain-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.home_avg_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home avg Rain', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_rain', + 'last_changed': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_entity[sensor.home_avg_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.home_avg_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home avg Rain today', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_entity[sensor.home_avg_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.home_avg_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Home avg Temperature', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_temperature', + 'last_changed': , + 'last_updated': , + 'state': '22.7', + }) +# --- +# name: test_entity[sensor.home_avg_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_avg_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Home avg Wind Strength', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_avg_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_entity[sensor.home_max_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_angle-state] + None +# --- +# name: test_entity[sensor.home_max_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_gust_angle-state] + None +# --- +# name: test_entity[sensor.home_max_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_gust_strength-state] + None +# --- +# name: test_entity[sensor.home_max_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.home_max_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Home max Humidity', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_max_humidity', + 'last_changed': , + 'last_updated': , + 'state': '76', + }) +# --- +# name: test_entity[sensor.home_max_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Home max Pressure', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1014.4', + }) +# --- +# name: test_entity[sensor.home_max_rain-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.home_max_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home max Rain', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_rain', + 'last_changed': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_entity[sensor.home_max_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.home_max_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Home max Rain today', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '12.322', + }) +# --- +# name: test_entity[sensor.home_max_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Home max Temperature', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.4', + }) +# --- +# name: test_entity[sensor.home_max_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.home_max_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Home max Wind Strength', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_max_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_entity[sensor.hot_water_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hot_water_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#7-12:34:56:00:16:0e#7-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.hot_water_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Hot water Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hot_water_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.hot_water_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hot_water_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Hot water Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#7-12:34:56:00:16:0e#7-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.hot_water_reachability-state] + None +# --- +# name: test_entity[sensor.kitchen_co2-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.kitchen_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.kitchen_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Kitchen CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.kitchen_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Kitchen Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.kitchen_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.kitchen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Kitchen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_noise-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.kitchen_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Kitchen Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Kitchen Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_pressure_trend-state] + None +# --- +# name: test_entity[sensor.kitchen_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_reachability-state] + None +# --- +# name: test_entity[sensor.kitchen_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.kitchen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.kitchen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.kitchen_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.kitchen_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_temperature_trend-state] + None +# --- +# name: test_entity[sensor.kitchen_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:25:cf:a8-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.kitchen_wifi-state] + None +# --- +# name: test_entity[sensor.line_1_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_1_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 1 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#0-12:34:56:00:16:0e#0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_1_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_1_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.line_1_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 1 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#0-12:34:56:00:16:0e#0-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_1_reachability-state] + None +# --- +# name: test_entity[sensor.line_2_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_2_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 2 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#1-12:34:56:00:16:0e#1-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_2_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_2_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.line_2_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 2 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#1-12:34:56:00:16:0e#1-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_2_reachability-state] + None +# --- +# name: test_entity[sensor.line_3_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_3_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 3 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#2-12:34:56:00:16:0e#2-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 3 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_3_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_3_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.line_3_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 3 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#2-12:34:56:00:16:0e#2-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_3_reachability-state] + None +# --- +# name: test_entity[sensor.line_4_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_4_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 4 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#3-12:34:56:00:16:0e#3-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_4_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 4 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_4_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_4_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.line_4_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 4 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#3-12:34:56:00:16:0e#3-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_4_reachability-state] + None +# --- +# name: test_entity[sensor.line_5_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.line_5_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Line 5 Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#4-12:34:56:00:16:0e#4-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.line_5_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Line 5 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.line_5_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.line_5_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.line_5_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Line 5 Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#4-12:34:56:00:16:0e#4-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.line_5_reachability-state] + None +# --- +# name: test_entity[sensor.livingroom_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Livingroom Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2746182631-12:34:56:00:01:ae-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.livingroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Livingroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_entity[sensor.livingroom_co2-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.livingroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.livingroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Livingroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.livingroom_co2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Livingroom Health', + 'icon': 'mdi:cloud', + }), + 'context': , + 'entity_id': 'sensor.livingroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.livingroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Livingroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_noise-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.livingroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Livingroom Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_noise', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Livingroom Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.livingroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_reachability-state] + None +# --- +# name: test_entity[sensor.livingroom_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.livingroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.livingroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Livingroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.livingroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.livingroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.livingroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.livingroom_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:26:65:14-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.livingroom_wifi-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_co2-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.parents_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.parents_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Parents Bedroom CO2', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '494', + }) +# --- +# name: test_entity[sensor.parents_bedroom_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cloud', + 'original_name': 'Health', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-health_idx', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Parents Bedroom Health', + 'icon': 'mdi:cloud', + 'latitude': 13.377726, + 'longitude': 52.516263, + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_health', + 'last_changed': , + 'last_updated': , + 'state': 'Fine', + }) +# --- +# name: test_entity[sensor.parents_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.parents_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Parents Bedroom Humidity', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '63', + }) +# --- +# name: test_entity[sensor.parents_bedroom_noise-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.parents_bedroom_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Parents Bedroom Noise', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_noise', + 'last_changed': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Parents Bedroom Pressure', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1014.5', + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_pressure_trend-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.parents_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_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.parents_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Parents Bedroom Temperature', + 'latitude': 13.377726, + 'longitude': 52.516263, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parents_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parents_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.parents_bedroom_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.parents_bedroom_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:3e:c5:46-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.parents_bedroom_wifi-state] + None +# --- +# name: test_entity[sensor.prise_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.prise_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Prise Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.prise_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Prise Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.prise_power', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entity[sensor.prise_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.prise_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Prise Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-12:34:56:80:00:12:ac:f2-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.prise_reachability-state] + None +# --- +# name: test_entity[sensor.total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.total_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total Power', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#5-12:34:56:00:16:0e#5-power', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'power', + 'friendly_name': 'Total Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.total_power', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.total_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.total_reachability', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Total Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:00:16:0e#5-12:34:56:00:16:0e#5-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.total_reachability-state] + None +# --- +# name: test_entity[sensor.valve1_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.valve1_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve1 Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2833524037-12:34:56:03:a5:54-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.valve1_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Valve1 Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve1_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.valve2_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.valve2_battery_percent', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve2 Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2940411577-12:34:56:03:a0:ac-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.valve2_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Valve2 Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve2_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_entity[sensor.villa_bathroom_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bathroom_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bathroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Bathroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_entity[sensor.villa_bathroom_co2-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.villa_bathroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_bathroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa Bathroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1930', + }) +# --- +# name: test_entity[sensor.villa_bathroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bathroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bathroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Bathroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_entity[sensor.villa_bathroom_radio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bathroom_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_radio-state] + None +# --- +# name: test_entity[sensor.villa_bathroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bathroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_reachability-state] + None +# --- +# name: test_entity[sensor.villa_bathroom_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.villa_bathroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Bathroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_bathroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bathroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:7e:18-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bathroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bedroom_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bedroom_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Bedroom Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_entity[sensor.villa_bedroom_co2-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.villa_bedroom_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_bedroom_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa Bedroom CO2', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_co2', + 'last_changed': , + 'last_updated': , + 'state': '1076', + }) +# --- +# name: test_entity[sensor.villa_bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_humidity', + 'last_changed': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_entity[sensor.villa_bedroom_radio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bedroom_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_radio-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_bedroom_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_reachability-state] + None +# --- +# name: test_entity[sensor.villa_bedroom_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.villa_bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_bedroom_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_bedroom_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:44:92-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_bedroom_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_co2-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.villa_co2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CO2', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_entity[sensor.villa_co2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Villa CO2', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.villa_co2', + 'last_changed': , + 'last_updated': , + 'state': '1339', + }) +# --- +# name: test_entity[sensor.villa_garden_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.villa_garden_angle-state] + None +# --- +# name: test_entity[sensor.villa_garden_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_garden_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_garden_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Garden Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_entity[sensor.villa_garden_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windangle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Villa Garden Direction', + 'icon': 'mdi:compass-outline', + }), + 'context': , + 'entity_id': 'sensor.villa_garden_direction', + 'last_changed': , + 'last_updated': , + 'state': 'SW', + }) +# --- +# name: test_entity[sensor.villa_garden_gust_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Angle', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-gustangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.villa_garden_gust_angle-state] + None +# --- +# name: test_entity[sensor.villa_garden_gust_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:compass-outline', + 'original_name': 'Gust Direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-gustangle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_gust_direction-state] + None +# --- +# name: test_entity[sensor.villa_garden_gust_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_gust_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gust Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-guststrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_garden_gust_strength-state] + None +# --- +# name: test_entity[sensor.villa_garden_radio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_garden_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_radio-state] + None +# --- +# name: test_entity[sensor.villa_garden_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_garden_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_garden_reachability-state] + None +# --- +# name: test_entity[sensor.villa_garden_wind_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_garden_wind_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind Strength', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:03:1b:e4-windstrength', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_garden_wind_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_speed', + 'friendly_name': 'Villa Garden Wind Strength', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_garden_wind_strength', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_entity[sensor.villa_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Humidity', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_humidity', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_entity[sensor.villa_noise-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.villa_noise', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Noise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-noise', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'sound_pressure', + 'friendly_name': 'Villa Noise', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_noise', + 'last_changed': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_entity[sensor.villa_outdoor_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_outdoor_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_outdoor_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Outdoor Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_outdoor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'humidity', + 'friendly_name': 'Villa Outdoor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_humidity', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_radio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_outdoor_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_radio-state] + None +# --- +# name: test_entity[sensor.villa_outdoor_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_outdoor_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_reachability-state] + None +# --- +# name: test_entity[sensor.villa_outdoor_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.villa_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Outdoor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_outdoor_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_outdoor_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:1c:42-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_outdoor_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Villa Pressure', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1026.8', + }) +# --- +# name: test_entity[sensor.villa_pressure_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_pressure_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Pressure trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-pressure_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_pressure_trend-state] + None +# --- +# name: test_entity[sensor.villa_rain_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_rain_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery Percent', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity[sensor.villa_rain_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'battery', + 'friendly_name': 'Villa Rain Battery Percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.villa_rain_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_entity[sensor.villa_rain_radio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_rain_radio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Radio', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-rf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_radio-state] + None +# --- +# name: test_entity[sensor.villa_rain_rain-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.villa_rain_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-rain', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Villa Rain Rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rain', + 'last_changed': , + 'last_updated': , + 'state': '3.7', + }) +# --- +# name: test_entity[sensor.villa_rain_rain_last_hour-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_rain_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-sum_rain_1', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain_last_hour-state] + None +# --- +# name: test_entity[sensor.villa_rain_rain_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_rain_rain_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain today', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-sum_rain_24', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_rain_rain_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'precipitation', + 'friendly_name': 'Villa Rain Rain today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_rain_rain_today', + 'last_changed': , + 'last_updated': , + 'state': '6.9', + }) +# --- +# name: test_entity[sensor.villa_rain_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_rain_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:c1:ea-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_rain_reachability-state] + None +# --- +# name: test_entity[sensor.villa_reachability-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_reachability', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:signal', + 'original_name': 'Reachability', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-reachable', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_reachability-state] + None +# --- +# name: test_entity[sensor.villa_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.villa_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entity[sensor.villa_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'temperature', + 'friendly_name': 'Villa Temperature', + 'latitude': 46.123456, + 'longitude': 6.1234567, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.villa_temperature', + 'last_changed': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_entity[sensor.villa_temperature_trend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.villa_temperature_trend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:trending-up', + 'original_name': 'Temperature trend', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-temp_trend', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_temperature_trend-state] + None +# --- +# name: test_entity[sensor.villa_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.villa_wifi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wifi', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:bb:26-wifi_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[sensor.villa_wifi-state] + None +# --- diff --git a/tests/components/netatmo/snapshots/test_switch.ambr b/tests/components/netatmo/snapshots/test_switch.ambr new file mode 100644 index 00000000000..6069bf60c1f --- /dev/null +++ b/tests/components/netatmo/snapshots/test_switch.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_entity[switch.prise-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.prise', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Prise', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12:34:56:80:00:12:ac:f2-DeviceType.NLP', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[switch.prise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Prise', + }), + 'context': , + 'entity_id': 'switch.prise', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 12cfd1603d0..e845ca08f06 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pyatmo import pytest +from syrupy import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.camera import STATE_STREAMING @@ -17,13 +18,37 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er from homeassistant.util import dt as dt_util -from .common import fake_post_request, selected_platforms, simulate_webhook +from .common import ( + fake_post_request, + selected_platforms, + simulate_webhook, + snapshot_platform_entities, +) from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + await snapshot_platform_entities( + hass, + config_entry, + Platform.CAMERA, + entity_registry, + snapshot, + ) + + async def test_setup_component_with_webhook( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 53bb0c1f052..e4b8c298c26 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch import pytest +from syrupy import SnapshotAssertion from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( @@ -39,13 +40,31 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er from homeassistant.util import dt as dt_util -from .common import selected_platforms, simulate_webhook +from .common import selected_platforms, simulate_webhook, snapshot_platform_entities from tests.common import MockConfigEntry +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.CLIMATE, + entity_registry, + snapshot, + ) + + async def test_webhook_event_handling_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index d543d141b62..5a7c33fc6ef 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -1,6 +1,8 @@ """The tests for Netatmo cover.""" from unittest.mock import AsyncMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -11,12 +13,30 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms +from .common import selected_platforms, snapshot_platform_entities from tests.common import MockConfigEntry +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.COVER, + entity_registry, + snapshot, + ) + + async def test_cover_setup_and_services( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index b5fbadf066a..1c83f9c6772 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,21 +1,46 @@ """The tests for Netatmo light.""" from unittest.mock import AsyncMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.components.netatmo import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import FAKE_WEBHOOK_ACTIVATION, selected_platforms, simulate_webhook +from .common import ( + FAKE_WEBHOOK_ACTIVATION, + selected_platforms, + simulate_webhook, + snapshot_platform_entities, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.LIGHT, + entity_registry, + snapshot, + ) + + async def test_camera_light_setup_and_services( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index f513cc3a8d9..055ea355b48 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -2,20 +2,44 @@ from unittest.mock import AsyncMock, patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, SERVICE_SELECT_OPTION +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_WEBHOOK_ID, + SERVICE_SELECT_OPTION, + Platform, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms, simulate_webhook +from .common import selected_platforms, simulate_webhook, snapshot_platform_entities from tests.common import MockConfigEntry +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SELECT, + entity_registry, + snapshot, + ) + + async def test_select_schedule_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 03251277ddf..8829e374f29 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -2,17 +2,35 @@ from unittest.mock import AsyncMock import pytest +from syrupy import SnapshotAssertion from homeassistant.components.netatmo import sensor from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import selected_platforms +from .common import selected_platforms, snapshot_platform_entities from tests.common import MockConfigEntry +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SENSOR, + entity_registry, + snapshot, + ) + + async def test_indoor_sensor( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index 25abfd1a371..f5ea08ec1fa 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,6 +1,8 @@ """The tests for Netatmo switch.""" from unittest.mock import AsyncMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -8,12 +10,30 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er -from .common import selected_platforms +from .common import selected_platforms, snapshot_platform_entities from tests.common import MockConfigEntry +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.SWITCH, + entity_registry, + snapshot, + ) + + async def test_switch_setup_and_services( hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock ) -> None: From f601104418c1d6c7853e1fecaa6ecd56cee4fc28 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 13 Jan 2024 10:59:36 +0100 Subject: [PATCH 0548/1544] Snapshot Netatmo devices (#107935) --- .../netatmo/snapshots/test_init.ambr | 1037 +++++++++++++++++ tests/components/netatmo/test_init.py | 35 + 2 files changed, 1072 insertions(+) create mode 100644 tests/components/netatmo/snapshots/test_init.ambr diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr new file mode 100644 index 00000000000..b76989a1689 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -0,0 +1,1037 @@ +# serializer version: 1 +# name: test_devices + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home avg', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home avg', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home max', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home max', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.10 + DeviceRegistryEntrySnapshot({ + 'area_id': 'cocina', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2940411577', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Valve', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Cocina', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.11 + DeviceRegistryEntrySnapshot({ + 'area_id': 'bureau', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '222452125', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'OpenTherm Modulating Thermostat', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Bureau', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.12 + DeviceRegistryEntrySnapshot({ + 'area_id': 'corridor', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '1002003001', + ), + }), + 'is_new': False, + 'manufacturer': 'Smarther', + 'model': 'Smarther with Netatmo', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Corridor', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.13 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:f1:62', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Camera', + 'name': 'Hall', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.14 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:f1:66', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Video Doorbell', + 'name': 'Netatmo-Doorbell', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.15 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:b9:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Camera', + 'name': 'Front', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.16 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '0009999992', + ), + }), + 'is_new': False, + 'manufacturer': 'Bubbendorf', + 'model': 'Roller Shutter', + 'name': 'Entrance Blinds', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.17 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:bb:26', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Home Weather station', + 'name': 'Villa', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.18 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:1c:42', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Module', + 'name': 'Villa Outdoor', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.19 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:c1:ea', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Rain Gauge', + 'name': 'Villa Rain', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:69:0c', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.20 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:44:92', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.21 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:7e:18', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bathroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.22 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:03:1b:e4', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Anemometer', + 'name': 'Villa Garden', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.23 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:00:12:ac:f2', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Plug', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.24 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.25 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#0', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.26 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#1', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.27 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#2', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.28 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#3', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.29 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#4', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.3 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:25:cf:a8', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Kitchen', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.30 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#5', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.31 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#6', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.32 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#7', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.33 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#8', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.34 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:00:a1:4c:da', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Energy Meter', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.35 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '00:11:22:33:00:11:45:fe', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': '2 wire light switch/dimmer', + 'name': 'Unknown 00:11:22:33:00:11:45:fe', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.36 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:01:01:01:a1', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Light switch/dimmer with neutral', + 'name': 'Bathroom light', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.4 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:65:14', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Livingroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.5 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:3e:c5:46', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Parents Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.6 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:68:92', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Baby Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.7 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Thermostat', + 'name': 'MYHOME', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.8 + DeviceRegistryEntrySnapshot({ + 'area_id': 'livingroom', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2746182631', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Thermostat', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Livingroom', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices.9 + DeviceRegistryEntrySnapshot({ + 'area_id': 'entrada', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2833524037', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Valve', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Entrada', + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index a9181ef49f7..ff3a00f31eb 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyatmo.const import ALL_SCOPES import pytest +from syrupy import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.netatmo import DOMAIN from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import CoreState, HomeAssistant +import homeassistant.helpers.device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -461,3 +463,36 @@ async def test_setup_component_invalid_token( for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) + + +async def test_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + netatmo_auth: AsyncMock, +) -> None: + """Test devices are registered.""" + with selected_platforms( + [ + Platform.CAMERA, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ] + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert device_entries + + for device_entry in device_entries: + assert device_entry == snapshot From 9471f81a18b8ae45869c467366d3a13717e678a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 13 Jan 2024 11:09:47 +0100 Subject: [PATCH 0549/1544] Give name to Netatmo device snapshots (#107938) --- .../netatmo/snapshots/test_init.ambr | 1648 ++++++++--------- tests/components/netatmo/test_init.py | 3 +- 2 files changed, 826 insertions(+), 825 deletions(-) diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index b76989a1689..0c9e2d00f55 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -1,229 +1,5 @@ # serializer version: 1 -# name: test_devices - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://weathermap.netatmo.com/', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - 'Home avg', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Public Weather station', - 'name': 'Home avg', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.1 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://weathermap.netatmo.com/', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - 'Home max', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Public Weather station', - 'name': 'Home max', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.10 - DeviceRegistryEntrySnapshot({ - 'area_id': 'cocina', - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '2940411577', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Valve', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': 'Cocina', - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.11 - DeviceRegistryEntrySnapshot({ - 'area_id': 'bureau', - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '222452125', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'OpenTherm Modulating Thermostat', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': 'Bureau', - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.12 - DeviceRegistryEntrySnapshot({ - 'area_id': 'corridor', - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '1002003001', - ), - }), - 'is_new': False, - 'manufacturer': 'Smarther', - 'model': 'Smarther with Netatmo', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': 'Corridor', - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.13 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://home.netatmo.com/security', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:f1:62', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Camera', - 'name': 'Hall', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.14 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://home.netatmo.com/security', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:10:f1:66', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Video Doorbell', - 'name': 'Netatmo-Doorbell', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.15 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://home.netatmo.com/security', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:10:b9:0e', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Outdoor Camera', - 'name': 'Front', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.16 +# name: test_devices[netatmo-0009999992] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -251,567 +27,7 @@ 'via_device_id': None, }) # --- -# name: test_devices.17 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:bb:26', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Home Weather station', - 'name': 'Villa', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.18 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:1c:42', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Outdoor Module', - 'name': 'Villa Outdoor', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.19 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:c1:ea', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Rain Gauge', - 'name': 'Villa Rain', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:26:69:0c', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Air Quality Monitor', - 'name': 'Bedroom', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.20 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:44:92', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Module', - 'name': 'Villa Bedroom', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.21 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:7e:18', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Module', - 'name': 'Villa Bathroom', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.22 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:03:1b:e4', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Anemometer', - 'name': 'Villa Garden', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.23 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:80:00:12:ac:f2', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Plug', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.24 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.25 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#0', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.26 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#1', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.27 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#2', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.28 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#3', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.29 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#4', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.3 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:25:cf:a8', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Air Quality Monitor', - 'name': 'Kitchen', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.30 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#5', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.31 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#6', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.32 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#7', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.33 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:16:0e#8', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Ecometer', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.34 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/energy', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:00:00:a1:4c:da', - ), - }), - 'is_new': False, - 'manufacturer': 'Legrand', - 'model': 'Connected Energy Meter', - 'name': '', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.35 +# name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -839,7 +55,63 @@ 'via_device_id': None, }) # --- -# name: test_devices.36 +# name: test_devices[netatmo-1002003001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'corridor', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '1002003001', + ), + }), + 'is_new': False, + 'manufacturer': 'Smarther', + 'model': 'Smarther with Netatmo', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Corridor', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:00:a1:4c:da] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:00:a1:4c:da', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Energy Meter', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:01:01:01:a1] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -867,7 +139,427 @@ 'via_device_id': None, }) # --- -# name: test_devices.4 +# name: test_devices[netatmo-12:34:56:00:16:0e#0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#0', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#1', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#2] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#2', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#3] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#3', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#4', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#5] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#5', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#6] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#6', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#7] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#7', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e#8] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e#8', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:16:0e] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:16:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Legrand', + 'model': 'Connected Ecometer', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:00:f1:62] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:00:f1:62', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Camera', + 'name': 'Hall', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:03:1b:e4] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:03:1b:e4', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Anemometer', + 'name': 'Villa Garden', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:10:b9:0e] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:b9:0e', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Camera', + 'name': 'Front', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:10:f1:66] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/security', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:10:f1:66', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Video Doorbell', + 'name': 'Netatmo-Doorbell', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:25:cf:a8] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:25:cf:a8', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Kitchen', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:26:65:14] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -895,35 +587,7 @@ 'via_device_id': None, }) # --- -# name: test_devices.5 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'https://my.netatmo.com/app/weather', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'netatmo', - '12:34:56:3e:c5:46', - ), - }), - 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Indoor Air Quality Monitor', - 'name': 'Parents Bedroom', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_devices.6 +# name: test_devices[netatmo-12:34:56:26:68:92] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -951,7 +615,63 @@ 'via_device_id': None, }) # --- -# name: test_devices.7 +# name: test_devices[netatmo-12:34:56:26:69:0c] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:26:69:0c', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:3e:c5:46] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:3e:c5:46', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Air Quality Monitor', + 'name': 'Parents Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:00:12:ac:f2] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -965,13 +685,13 @@ 'identifiers': set({ tuple( 'netatmo', - '', + '12:34:56:80:00:12:ac:f2', ), }), 'is_new': False, - 'manufacturer': 'Netatmo', - 'model': 'Smart Thermostat', - 'name': 'MYHOME', + 'manufacturer': 'Legrand', + 'model': 'Plug', + 'name': '', 'name_by_user': None, 'serial_number': None, 'suggested_area': None, @@ -979,7 +699,175 @@ 'via_device_id': None, }) # --- -# name: test_devices.8 +# name: test_devices[netatmo-12:34:56:80:1c:42] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:1c:42', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Outdoor Module', + 'name': 'Villa Outdoor', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:44:92] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:44:92', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bedroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:7e:18] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:7e:18', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Indoor Module', + 'name': 'Villa Bathroom', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:bb:26] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:bb:26', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Home Weather station', + 'name': 'Villa', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-12:34:56:80:c1:ea] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/weather', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '12:34:56:80:c1:ea', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Rain Gauge', + 'name': 'Villa Rain', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-222452125] + DeviceRegistryEntrySnapshot({ + 'area_id': 'bureau', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '222452125', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'OpenTherm Modulating Thermostat', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Bureau', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-2746182631] DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', 'config_entries': , @@ -1007,7 +895,7 @@ 'via_device_id': None, }) # --- -# name: test_devices.9 +# name: test_devices[netatmo-2833524037] DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', 'config_entries': , @@ -1035,3 +923,115 @@ 'via_device_id': None, }) # --- +# name: test_devices[netatmo-2940411577] + DeviceRegistryEntrySnapshot({ + 'area_id': 'cocina', + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '2940411577', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Valve', + 'name': '', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': 'Cocina', + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-Home avg] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home avg', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home avg', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-Home max] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://weathermap.netatmo.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + 'Home max', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Public Weather station', + 'name': 'Home max', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[netatmo-] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://my.netatmo.com/app/energy', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '', + ), + }), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart Thermostat', + 'name': 'MYHOME', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index ff3a00f31eb..9aee3170027 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -495,4 +495,5 @@ async def test_devices( assert device_entries for device_entry in device_entries: - assert device_entry == snapshot + identifier = list(device_entry.identifiers)[0] + assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") From 24c23d7323064a0e9f29d88b34c8b3f3ab17a9a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 13 Jan 2024 11:56:05 +0100 Subject: [PATCH 0550/1544] Warn if integrations call async_show_progress without passing a task (#107796) --- homeassistant/data_entry_flow.py | 17 +++++++++++++++++ tests/test_data_entry_flow.py | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 65cf7eb3d36..207328992ab 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -24,6 +24,7 @@ from .helpers.deprecation import ( dir_with_deprecated_constants, ) from .helpers.frame import report +from .loader import async_suggest_report_issue from .util import uuid as uuid_util _LOGGER = logging.getLogger(__name__) @@ -526,6 +527,7 @@ class FlowHandler: MINOR_VERSION = 1 __progress_task: asyncio.Task[Any] | None = None + __no_progress_task_reported = False @property def source(self) -> str | None: @@ -668,6 +670,21 @@ class FlowHandler: progress_task: asyncio.Task[Any] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" + if progress_task is None and not self.__no_progress_task_reported: + self.__no_progress_task_reported = True + cls = self.__class__ + report_issue = async_suggest_report_issue(self.hass, module=cls.__module__) + _LOGGER.warning( + ( + "%s::%s calls async_show_progress without passing a progress task, " + "this is not valid and will break in Home Assistant Core 2024.8. " + "Please %s" + ), + cls.__module__, + cls.__name__, + report_issue, + ) + result = FlowResult( type=FlowResultType.SHOW_PROGRESS, flow_id=self.flow_id, diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index cafeaaf3ba0..744ae4dc007 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -498,7 +498,7 @@ async def test_show_progress_error(hass: HomeAssistant, manager) -> None: assert result["reason"] == "error" -async def test_show_progress_legacy(hass: HomeAssistant, manager) -> None: +async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> None: """Test show progress logic. This tests the deprecated version where the config flow is responsible for @@ -586,6 +586,13 @@ async def test_show_progress_legacy(hass: HomeAssistant, manager) -> None: assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Hello" + # Check for deprecation warning + assert ( + "tests.test_data_entry_flow::TestFlow calls async_show_progress without passing" + " a progress task, this is not valid and will break in Home Assistant " + "Core 2024.8." + ) in caplog.text + async def test_show_progress_fires_only_when_changed( hass: HomeAssistant, manager From c49246dd07d6f5a6828661c37f70d2a7ef5fc15f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 13 Jan 2024 13:48:02 +0100 Subject: [PATCH 0551/1544] Fix duplicate unique id in System Monitor (again) (#107947) Fix duplicate unique id in System Monitor --- homeassistant/components/systemmonitor/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index da6e35238ec..95437c7fa4c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -405,7 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{slugify(argument)}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( sensor_registry, @@ -425,7 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{slugify(argument)}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( sensor_registry, @@ -449,7 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) - loaded_resources.add(f"{_type}_{slugify(argument)}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( sensor_registry, From 1d2c23d81c56a38944a77d3537c599136cfa9cc3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 13 Jan 2024 14:34:24 +0100 Subject: [PATCH 0552/1544] Skip disk types in System Monitor (#107943) * Skip disk types in System Monitor * change back --- homeassistant/components/systemmonitor/util.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 742e0d40f3d..aeb7816784b 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -7,6 +7,8 @@ import psutil _LOGGER = logging.getLogger(__name__) +SKIP_DISK_TYPES = {"proc", "tmpfs", "devtmpfs"} + def get_all_disk_mounts() -> set[str]: """Return all disk mount points on system.""" @@ -18,6 +20,9 @@ def get_all_disk_mounts() -> set[str]: # ENOENT, pop-up a Windows GUI error for a non-ready # partition or just hang. continue + if part.fstype in SKIP_DISK_TYPES: + # Ignore disks which are memory + continue try: usage = psutil.disk_usage(part.mountpoint) except PermissionError: From 45fec1d4048c72ef94df027154ecf7bf69c21c84 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:53:48 +0100 Subject: [PATCH 0553/1544] Bump pyenphase to 1.17.0 (#107950) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 67d07f0d502..4b3a4eadb3d 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.16.0"], + "requirements": ["pyenphase==1.17.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 9659cb5924e..fe21f105261 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1750,7 +1750,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.16.0 +pyenphase==1.17.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e167600c19a..6bf1ceda2e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1334,7 +1334,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.16.0 +pyenphase==1.17.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 0cc43d09150fdcda69d7335c793c72a6a476062c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:57:54 +0100 Subject: [PATCH 0554/1544] Enable strict typing for xiaomi_ble (#107948) --- .strict-typing | 1 + homeassistant/components/xiaomi_ble/__init__.py | 2 +- homeassistant/components/xiaomi_ble/config_flow.py | 4 ++-- homeassistant/components/xiaomi_ble/device_trigger.py | 2 +- mypy.ini | 10 ++++++++++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1163dea618c..041da10ab80 100644 --- a/.strict-typing +++ b/.strict-typing @@ -447,6 +447,7 @@ homeassistant.components.withings.* homeassistant.components.wiz.* homeassistant.components.wled.* homeassistant.components.worldclock.* +homeassistant.components.xiaomi_ble.* homeassistant.components.yale_smart_alarm.* homeassistant.components.yalexs_ble.* homeassistant.components.youtube.* diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 228a72cb8a5..456838d1ee1 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -128,7 +128,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - async def _async_poll(service_info: BluetoothServiceInfoBleak): + async def _async_poll(service_info: BluetoothServiceInfoBleak) -> SensorUpdate: # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it # directly to the Xiaomi code # Make sure the device we have is one that we can connect with diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index 9115fc5991b..bca76bfd0a5 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -280,8 +280,8 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): # Otherwise there wasn't actually encryption so abort return self.async_abort(reason="reauth_successful") - def _async_get_or_create_entry(self, bindkey=None): - data = {} + def _async_get_or_create_entry(self, bindkey: str | None = None) -> FlowResult: + data: dict[str, Any] = {} if bindkey: data["bindkey"] = bindkey diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 04239cee56d..91d7132d65f 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -65,7 +65,7 @@ async def async_validate_trigger_config( """Validate trigger config.""" device_id = config[CONF_DEVICE_ID] if model_data := _async_trigger_model_data(hass, device_id): - return model_data.schema(config) + return model_data.schema(config) # type: ignore[no-any-return] return config diff --git a/mypy.ini b/mypy.ini index d049a922e1b..7693912a6c8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4233,6 +4233,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.xiaomi_ble.*] +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.yale_smart_alarm.*] check_untyped_defs = true disallow_incomplete_defs = true From 058759c76a59b72de537581acd83ed4fd625c0c5 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 13 Jan 2024 17:21:49 +0100 Subject: [PATCH 0555/1544] Bump python-holidays to 0.40 (#107888) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index c8ef6c88b13..e20984e3029 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.39", "babel==2.13.1"] + "requirements": ["holidays==0.40", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ae7c42c1868..2eda7c2dfb0 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.39"] + "requirements": ["holidays==0.40"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe21f105261..410d8f3c60c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1041,7 +1041,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.39 +holidays==0.40 # homeassistant.components.frontend home-assistant-frontend==20240112.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bf1ceda2e8..00078117e7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -834,7 +834,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.39 +holidays==0.40 # homeassistant.components.frontend home-assistant-frontend==20240112.0 From 8395d84bbb07305d34791090962a27f80e31b533 Mon Sep 17 00:00:00 2001 From: Grant <38738958+ThePapaG@users.noreply.github.com> Date: Sun, 14 Jan 2024 02:25:26 +1000 Subject: [PATCH 0556/1544] Add fan mode support to SmartThings fan entity (#106794) * Fix the fan to support preset modes * Add more tests and fix some comments * Don't override inherited member * Don't check for supported feature as the check is already performed before here * Do not check for feature on properties * Update homeassistant/components/smartthings/fan.py Co-authored-by: G Johansson * Fix tests --------- Co-authored-by: G Johansson --- homeassistant/components/smartthings/fan.py | 76 ++++++- tests/components/smartthings/test_fan.py | 227 +++++++++++++++++++- 2 files changed, 290 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 6c814b781b2..647a99ebfa6 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -41,19 +41,50 @@ async def async_setup_entry( def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: """Return all capabilities supported if minimum required are present.""" - supported = [Capability.switch, Capability.fan_speed] - # Must have switch and fan_speed - if all(capability in capabilities for capability in supported): - return supported - return None + + # MUST support switch as we need a way to turn it on and off + if Capability.switch not in capabilities: + return None + + # These are all optional but at least one must be supported + optional = [ + Capability.air_conditioner_fan_mode, + Capability.fan_speed, + ] + + # If none of the optional capabilities are supported then error + if not any(capability in capabilities for capability in optional): + return None + + supported = [Capability.switch] + + for capability in optional: + if capability in capabilities: + supported.append(capability) + + return supported class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_speed_count = int_states_in_range(SPEED_RANGE) + def __init__(self, device): + """Init the class.""" + super().__init__(device) + self._attr_supported_features = self._determine_features() + + def _determine_features(self): + flags = FanEntityFeature(0) + + if self._device.get_capability(Capability.fan_speed): + flags |= FanEntityFeature.SET_SPEED + if self._device.get_capability(Capability.air_conditioner_fan_mode): + flags |= FanEntityFeature.PRESET_MODE + + return flags + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" await self._async_set_percentage(percentage) @@ -70,6 +101,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset_mode of the fan.""" + await self._device.set_fan_mode(preset_mode, set_status=True) + self.async_write_ha_state() + async def async_turn_on( self, percentage: int | None = None, @@ -77,7 +113,15 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - await self._async_set_percentage(percentage) + if FanEntityFeature.SET_SPEED in self._attr_supported_features: + # If speed is set in features then turn the fan on with the speed. + await self._async_set_percentage(percentage) + else: + # If speed is not valid then turn on the fan with the + await self._device.switch_on(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" @@ -92,6 +136,22 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): return self._device.status.switch @property - def percentage(self) -> int: + def percentage(self) -> int | None: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., auto, smart, interval, favorite. + + Requires FanEntityFeature.PRESET_MODE. + """ + return self._device.status.fan_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires FanEntityFeature.PRESET_MODE. + """ + return self._device.status.supported_ac_fan_modes diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index ccf4b50fa1b..751646580d9 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -7,6 +7,8 @@ from pysmartthings import Attribute, Capability from homeassistant.components.fan import ( ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, FanEntityFeature, ) @@ -77,7 +79,87 @@ async def test_entity_and_device_attributes( assert entry.sw_version == "v7.89" -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: +# Setup platform tests with varying capabilities +async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with only the mode capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] == FanEntityFeature.PRESET_MODE + assert state.attributes[ATTR_PRESET_MODE] == "high" + assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] + + +async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with only the speed capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={ + Attribute.switch: "off", + Attribute.fan_speed: 2, + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_SUPPORTED_FEATURES] == FanEntityFeature.SET_SPEED + assert state.attributes[ATTR_PERCENTAGE] == 66 + + +async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: + """Test setting up a fan with both the mode and speed capability.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[ + Capability.switch, + Capability.fan_speed, + Capability.air_conditioner_fan_mode, + ], + status={ + Attribute.switch: "off", + Attribute.fan_speed: 2, + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + ) + assert state.attributes[ATTR_PERCENTAGE] == 66 + assert state.attributes[ATTR_PRESET_MODE] == "high" + assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] + + +# Speed Capability Tests + + +async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: """Test the fan turns of successfully.""" # Arrange device = device_factory( @@ -96,7 +178,7 @@ async def test_turn_off(hass: HomeAssistant, device_factory) -> None: assert state.state == "off" -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: +async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: """Test the fan turns of successfully.""" # Arrange device = device_factory( @@ -115,7 +197,9 @@ async def test_turn_on(hass: HomeAssistant, device_factory) -> None: assert state.state == "on" -async def test_turn_on_with_speed(hass: HomeAssistant, device_factory) -> None: +async def test_turn_on_with_speed_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test the fan turns on to the specified speed.""" # Arrange device = device_factory( @@ -138,7 +222,33 @@ async def test_turn_on_with_speed(hass: HomeAssistant, device_factory) -> None: assert state.attributes[ATTR_PERCENTAGE] == 100 -async def test_set_percentage(hass: HomeAssistant, device_factory) -> None: +async def test_turn_off_with_speed_speed_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test the fan turns off with the speed.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.fan_speed], + status={Attribute.switch: "on", Attribute.fan_speed: 100}, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", + "set_percentage", + {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + blocking=True, + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "off" + + +async def test_set_percentage_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test setting to specific fan speed.""" # Arrange device = device_factory( @@ -161,7 +271,9 @@ async def test_set_percentage(hass: HomeAssistant, device_factory) -> None: assert state.attributes[ATTR_PERCENTAGE] == 100 -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: +async def test_update_from_signal_speed_capability( + hass: HomeAssistant, device_factory +) -> None: """Test the fan updates when receiving a signal.""" # Arrange device = device_factory( @@ -194,3 +306,108 @@ async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: await hass.config_entries.async_forward_entry_unload(config_entry, "fan") # Assert assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE + + +# Preset Mode Tests + + +async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "on", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "off" + assert state.attributes[ATTR_PRESET_MODE] == "high" + + +async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: + """Test the fan turns of successfully.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "on" + assert state.attributes[ATTR_PRESET_MODE] == "high" + + +async def test_update_from_signal_mode_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test the fan updates when receiving a signal.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + await device.switch_on(True) + # Act + async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) + # Assert + await hass.async_block_till_done() + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.state == "on" + + +async def test_set_preset_mode_mode_capability( + hass: HomeAssistant, device_factory +) -> None: + """Test setting to specific fan mode.""" + # Arrange + device = device_factory( + "Fan 1", + capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], + status={ + Attribute.switch: "off", + Attribute.fan_mode: "high", + Attribute.supported_ac_fan_modes: ["high", "low", "medium"], + }, + ) + + await setup_platform(hass, FAN_DOMAIN, devices=[device]) + # Act + await hass.services.async_call( + "fan", + "set_preset_mode", + {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + blocking=True, + ) + # Assert + state = hass.states.get("fan.fan_1") + assert state is not None + assert state.attributes[ATTR_PRESET_MODE] == "low" From b1a246b817615eea9a82a17f3f552f4ec1629a08 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 14 Jan 2024 06:12:40 +1300 Subject: [PATCH 0557/1544] Add account sensors to electric kiwi integration (#97681) * add account sensors * tidy up same issues as other sensors * add unit tests for sensors edit and remove comments assert state and remove HOP sensor types since they aren't being used * try and fix tests * add frozen true * Update tests/components/electric_kiwi/test_sensor.py Co-authored-by: G Johansson * return proper native types Co-authored-by: G Johansson * tidy up attr unique id Co-authored-by: G Johansson * add entities once and use native values properly * Improve conftest Co-authored-by: G Johansson * tidy tests/components/electric_kiwi/test_sensor.py Co-authored-by: G Johansson * add assert to component_setup Co-authored-by: G Johansson * add extra parameters to test Co-authored-by: G Johansson * Update tests/components/electric_kiwi/test_sensor.py Co-authored-by: G Johansson * Update tests/components/electric_kiwi/test_sensor.py Co-authored-by: G Johansson * change coordinator name Co-authored-by: G Johansson * tidy up sensor translation names * Apply suggestions from code review --------- Co-authored-by: G Johansson --- .../components/electric_kiwi/__init__.py | 14 +- .../components/electric_kiwi/const.py | 3 + .../components/electric_kiwi/coordinator.py | 31 +++- .../components/electric_kiwi/select.py | 8 +- .../components/electric_kiwi/sensor.py | 136 ++++++++++++++++-- .../components/electric_kiwi/strings.json | 22 ++- tests/components/electric_kiwi/conftest.py | 13 +- .../fixtures/account_balance.json | 28 ++++ tests/components/electric_kiwi/test_sensor.py | 58 +++++++- 9 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 tests/components/electric_kiwi/fixtures/account_balance.json diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 5af02f69bcf..ea10cdb4dc4 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -12,8 +12,11 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import DOMAIN -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR +from .coordinator import ( + ElectricKiwiAccountDataCoordinator, + ElectricKiwiHOPDataCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT] @@ -41,14 +44,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) + account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api) try: await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() + await account_coordinator.async_config_entry_first_refresh() except ApiException as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hop_coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + HOP_COORDINATOR: hop_coordinator, + ACCOUNT_COORDINATOR: account_coordinator, + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..0b455b045cf 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -9,3 +9,6 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" + +HOP_COORDINATOR = "hop_coordinator" +ACCOUNT_COORDINATOR = "account_coordinator" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index b084f4656d5..3c7edd28421 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -6,7 +6,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import Hop, HopIntervals +from electrickiwi_api.model import AccountBalance, Hop, HopIntervals from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -14,11 +14,38 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator): + """ElectricKiwi Account Data object.""" + + def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + """Initialize ElectricKiwiAccountDataCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Electric Kiwi Account Data", + update_interval=ACCOUNT_SCAN_INTERVAL, + ) + self._ek_api = ek_api + + async def _async_update_data(self) -> AccountBalance: + """Fetch data from Account balance API endpoint.""" + try: + async with asyncio.timeout(60): + return await self._ek_api.get_account_balance() + except AuthException as auth_err: + raise ConfigEntryAuthFailed from auth_err + except ApiException as api_err: + raise UpdateFailed( + f"Error communicating with EK API: {api_err}" + ) from api_err + + class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): - """ElectricKiwi Data object.""" + """ElectricKiwi HOP Data object.""" def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index eb8aaac8c2f..5905efc1604 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN +from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR from .coordinator import ElectricKiwiHOPDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -19,7 +19,7 @@ ATTR_EK_HOP_SELECT = "hop_select" HOP_SELECT = SelectEntityDescription( entity_category=EntityCategory.CONFIG, key=ATTR_EK_HOP_SELECT, - translation_key="hopselector", + translation_key="hop_selector", ) @@ -27,7 +27,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Electric Kiwi select setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] + hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ + HOP_COORDINATOR + ] _LOGGER.debug("Setting up select entity") async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 51d02781554..4f8cc59757d 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -4,28 +4,89 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -import logging -from electrickiwi_api.model import Hop +from electrickiwi_api.model import AccountBalance, Hop from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, DOMAIN -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR +from .coordinator import ( + ElectricKiwiAccountDataCoordinator, + ElectricKiwiHOPDataCoordinator, +) -_LOGGER = logging.getLogger(DOMAIN) +ATTR_EK_HOP_START = "hop_power_start" +ATTR_EK_HOP_END = "hop_power_end" +ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance" +ATTR_TOTAL_CURRENT_BALANCE = "total_account_balance" +ATTR_NEXT_BILLING_DATE = "next_billing_date" +ATTR_HOP_PERCENTAGE = "hop_percentage" -ATTR_EK_HOP_START = "hop_sensor_start" -ATTR_EK_HOP_END = "hop_sensor_end" + +@dataclass(frozen=True) +class ElectricKiwiAccountRequiredKeysMixin: + """Mixin for required keys.""" + + value_func: Callable[[AccountBalance], float | datetime] + + +@dataclass(frozen=True) +class ElectricKiwiAccountSensorEntityDescription( + SensorEntityDescription, ElectricKiwiAccountRequiredKeysMixin +): + """Describes Electric Kiwi sensor entity.""" + + +ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_TOTAL_RUNNING_BALANCE, + translation_key="total_running_balance", + icon="mdi:currency-usd", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=CURRENCY_DOLLAR, + value_func=lambda account_balance: float(account_balance.total_running_balance), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_TOTAL_CURRENT_BALANCE, + translation_key="total_current_balance", + icon="mdi:currency-usd", + device_class=SensorDeviceClass.MONETARY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=CURRENCY_DOLLAR, + value_func=lambda account_balance: float(account_balance.total_account_balance), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_NEXT_BILLING_DATE, + translation_key="next_billing_date", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + value_func=lambda account_balance: datetime.strptime( + account_balance.next_billing_date, "%Y-%m-%d" + ), + ), + ElectricKiwiAccountSensorEntityDescription( + key=ATTR_HOP_PERCENTAGE, + translation_key="hop_power_savings", + icon="mdi:percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_func=lambda account_balance: float( + account_balance.connections[0].hop_percentage + ), + ), +) @dataclass(frozen=True) @@ -65,13 +126,13 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_START, - translation_key="hopfreepowerstart", + translation_key="hop_free_power_start", device_class=SensorDeviceClass.TIMESTAMP, value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time), ), ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_END, - translation_key="hopfreepowerend", + translation_key="hop_free_power_end", device_class=SensorDeviceClass.TIMESTAMP, value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time), ), @@ -81,13 +142,58 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Electric Kiwi Sensor Setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] - hop_entities = [ - ElectricKiwiHOPEntity(hop_coordinator, description) - for description in HOP_SENSOR_TYPES + """Electric Kiwi Sensors Setup.""" + account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][ACCOUNT_COORDINATOR] + + entities: list[SensorEntity] = [ + ElectricKiwiAccountEntity( + account_coordinator, + description, + ) + for description in ACCOUNT_SENSOR_TYPES ] - async_add_entities(hop_entities) + + hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ + HOP_COORDINATOR + ] + entities.extend( + [ + ElectricKiwiHOPEntity(hop_coordinator, description) + for description in HOP_SENSOR_TYPES + ] + ) + async_add_entities(entities) + + +class ElectricKiwiAccountEntity( + CoordinatorEntity[ElectricKiwiAccountDataCoordinator], SensorEntity +): + """Entity object for Electric Kiwi sensor.""" + + entity_description: ElectricKiwiAccountSensorEntityDescription + _attr_has_entity_name = True + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: ElectricKiwiAccountDataCoordinator, + description: ElectricKiwiAccountSensorEntityDescription, + ) -> None: + """Entity object for Electric Kiwi sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) + self.entity_description = description + + @property + def native_value(self) -> float | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) class ElectricKiwiHOPEntity( diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index d21c0d80ca6..359ca8e367d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -28,9 +28,25 @@ }, "entity": { "sensor": { - "hopfreepowerstart": { "name": "Hour of free power start" }, - "hopfreepowerend": { "name": "Hour of free power end" } + "hop_free_power_start": { + "name": "Hour of free power start" + }, + "hop_free_power_end": { + "name": "Hour of free power end" + }, + "total_running_balance": { + "name": "Total running balance" + }, + "total_current_balance": { + "name": "Total current balance" + }, + "next_billing_date": { + "name": "Next billing date" + }, + "hop_power_savings": { + "name": "Hour of power savings" + } }, - "select": { "hopselector": { "name": "Hour of free power" } } + "select": { "hop_selector": { "name": "Hour of free power" } } } } diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index f7e60e975f8..684fef24240 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -6,7 +6,7 @@ from time import time from unittest.mock import AsyncMock, patch import zoneinfo -from electrickiwi_api.model import Hop, HopIntervals +from electrickiwi_api.model import AccountBalance, Hop, HopIntervals import pytest from homeassistant.components.application_credentials import ( @@ -43,14 +43,18 @@ def component_setup( async def _setup_func() -> bool: assert await async_setup_component(hass, "application_credentials", {}) + await hass.async_block_till_done() await async_import_client_credential( hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), DOMAIN, ) + await hass.async_block_till_done() config_entry.add_to_hass(hass) - return await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result return _setup_func @@ -113,4 +117,9 @@ def ek_api() -> YieldFixture: mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( load_json_value_fixture("get_hop.json", DOMAIN) ) + mock_ek_api.return_value.get_account_balance.return_value = ( + AccountBalance.from_dict( + load_json_value_fixture("account_balance.json", DOMAIN) + ) + ) yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json new file mode 100644 index 00000000000..25bc57784ee --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_balance.json @@ -0,0 +1,28 @@ +{ + "data": { + "connections": [ + { + "hop_percentage": "3.5", + "id": 3, + "running_balance": "184.09", + "start_date": "2020-10-04", + "unbilled_days": 15 + } + ], + "last_billed_amount": "-66.31", + "last_billed_date": "2020-10-03", + "next_billing_date": "2020-11-03", + "is_prepay": "N", + "summary": { + "credits": "0.0", + "electricity_used": "184.09", + "other_charges": "0.00", + "payments": "-220.0" + }, + "total_account_balance": "-102.22", + "total_billing_days": 30, + "total_running_balance": "184.09", + "type": "account_running_balance" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index ef268735334..4961f5fdcd4 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -9,7 +9,11 @@ import pytest from homeassistant.components.electric_kiwi.const import ATTRIBUTION from homeassistant.components.electric_kiwi.sensor import _check_and_move_time -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant @@ -65,6 +69,58 @@ async def test_hop_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP +@pytest.mark.parametrize( + ("sensor", "sensor_state", "device_class", "state_class"), + [ + ( + "sensor.total_running_balance", + "184.09", + SensorDeviceClass.MONETARY, + SensorStateClass.TOTAL, + ), + ( + "sensor.total_current_balance", + "-102.22", + SensorDeviceClass.MONETARY, + SensorStateClass.TOTAL, + ), + ( + "sensor.next_billing_date", + "2020-11-03T00:00:00", + SensorDeviceClass.DATE, + None, + ), + ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ], +) +async def test_account_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ek_api: YieldFixture, + ek_auth: YieldFixture, + entity_registry: EntityRegistry, + component_setup: ComponentSetup, + sensor: str, + sensor_state: str, + device_class: str, + state_class: str, +) -> None: + """Test Account sensors for the Electric Kiwi integration.""" + + assert await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get(sensor) + assert entity + + state = hass.states.get(sensor) + assert state + assert state.state == sensor_state + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class + assert state.attributes.get(ATTR_STATE_CLASS) == state_class + + 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() From 7db8a52c232aaa581defee104e80b82b26db875e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 07:27:52 -1000 Subject: [PATCH 0558/1544] Bump aiohomekit to 3.1.3 (#107929) changelog: https://github.com/Jc2k/aiohomekit/compare/3.1.2...3.1.3 fixes maybe #97888 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 4af79a6f811..799058b0e20 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.2"], + "requirements": ["aiohomekit==3.1.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 410d8f3c60c..59f834c00c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.2 +aiohomekit==3.1.3 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00078117e7a..69d2a68b769 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.2 +aiohomekit==3.1.3 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From 6cbf1da76a9ed0359a0f762f4627a39d6335efb7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 14 Jan 2024 04:03:10 +1000 Subject: [PATCH 0559/1544] Add charge cable lock to Tessie (#107212) * Add cable lock * Translate exception * Use ServiceValidationError --- homeassistant/components/tessie/const.py | 7 ++++ homeassistant/components/tessie/lock.py | 42 ++++++++++++++++++-- homeassistant/components/tessie/strings.json | 8 ++++ tests/components/tessie/test_lock.py | 27 ++++++++++++- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 2ba4e514579..28981b87e6d 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -53,3 +53,10 @@ class TessieCoverStates(IntEnum): CLOSED = 0 OPEN = 1 + + +class TessieChargeCableLockStates(StrEnum): + """Tessie Charge Cable Lock states.""" + + ENGAGED = "Engaged" + DISENGAGED = "Disengaged" diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index e8fb8930bbc..1a0d879cd79 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -3,14 +3,15 @@ from __future__ import annotations from typing import Any -from tessie_api import lock, unlock +from tessie_api import lock, open_unlock_charge_port, unlock from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, TessieChargeCableLockStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -21,11 +22,15 @@ async def async_setup_entry( """Set up the Tessie sensor platform from a config entry.""" data = hass.data[DOMAIN][entry.entry_id] - async_add_entities(TessieLockEntity(vehicle.state_coordinator) for vehicle in data) + async_add_entities( + klass(vehicle.state_coordinator) + for klass in (TessieLockEntity, TessieCableLockEntity) + for vehicle in data + ) class TessieLockEntity(TessieEntity, LockEntity): - """Lock entity for current charge.""" + """Lock entity for Tessie.""" def __init__( self, @@ -48,3 +53,32 @@ class TessieLockEntity(TessieEntity, LockEntity): """Set new value.""" await self.run(unlock) self.set((self.key, False)) + + +class TessieCableLockEntity(TessieEntity, LockEntity): + """Cable Lock entity for Tessie.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "charge_state_charge_port_latch") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value == TessieChargeCableLockStates.ENGAGED + + async def async_lock(self, **kwargs: Any) -> None: + """Charge cable Lock cannot be manually locked.""" + raise ServiceValidationError( + "Insert cable to lock", + translation_domain=DOMAIN, + translation_key="no_cable", + ) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock charge cable lock.""" + await self.run(open_unlock_charge_port) + self.set((self.key, TessieChargeCableLockStates.DISENGAGED)) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 7cf511c125c..c2483b1be8c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -56,6 +56,9 @@ "lock": { "vehicle_state_locked": { "name": "[%key:component::lock::title%]" + }, + "charge_state_charge_port_latch": { + "name": "Charge cable lock" } }, "media_player": { @@ -315,5 +318,10 @@ "name": "[%key:component::update::title%]" } } + }, + "exceptions": { + "no_cable": { + "message": "Insert cable to lock" + } } } diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 93a1151a850..d1cbdfe1fa9 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, @@ -9,6 +11,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform @@ -20,7 +23,7 @@ async def test_locks(hass: HomeAssistant) -> None: await setup_platform(hass) - assert len(hass.states.async_all("lock")) == 1 + assert len(hass.states.async_all("lock")) == 2 entity_id = "lock.test_lock" @@ -48,3 +51,25 @@ async def test_locks(hass: HomeAssistant) -> None: ) assert hass.states.get(entity_id).state == STATE_UNLOCKED mock_run.assert_called_once() + + # Test charge cable lock set value functions + entity_id = "lock.test_charge_cable_lock" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + + with patch( + "homeassistant.components.tessie.lock.open_unlock_charge_port" + ) as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_UNLOCKED + mock_run.assert_called_once() From 1cf96a6558a1e87143d18254950be5de38c38248 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 08:39:27 -1000 Subject: [PATCH 0560/1544] Remove useless _update function in ESPHome (#107927) This function is never overwritten so we can remove it --- homeassistant/components/esphome/entity.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 1def6d37e02..7bd769231ac 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -301,13 +301,11 @@ class EsphomeAssistEntity(Entity): connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - @callback - def _update(self) -> None: - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: """Register update callback.""" await super().async_added_to_hass() self.async_on_remove( - self._entry_data.async_subscribe_assist_pipeline_update(self._update) + self._entry_data.async_subscribe_assist_pipeline_update( + self.async_write_ha_state + ) ) From d5c3c19d12949240c47ab544114125464a59503d Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Sat, 13 Jan 2024 19:53:28 +0100 Subject: [PATCH 0561/1544] Bump zamg to 0.3.5 (#107939) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index e7fe584c767..b094846ee22 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.3"] + "requirements": ["zamg==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59f834c00c2..cbb16751e52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2866,7 +2866,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.3 +zamg==0.3.5 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69d2a68b769..5b9bb639589 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2177,7 +2177,7 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.3 +zamg==0.3.5 # homeassistant.components.zeroconf zeroconf==0.131.0 From ca421d4f860971a70e6225805fe6f69d2ee58e00 Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Sat, 13 Jan 2024 19:58:02 +0100 Subject: [PATCH 0562/1544] Add support for Uonet+ Vulcan integration on Python 3.12 (#107959) * Bump vulcan-api to 2.3.2 * Enable vulcan integration on Python 3.12 * Stop skipping tests for vulcan integration on Python 3.12 --- homeassistant/components/vulcan/__init__.py | 15 ++------------- homeassistant/components/vulcan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vulcan/conftest.py | 5 ----- 5 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 tests/components/vulcan/conftest.py diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index b52b4181510..0bfd09d590d 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,32 +1,21 @@ """The Vulcan component.""" -import sys from aiohttp import ClientConnectorError +from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -if sys.version_info < (3, 12): - from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan - PLATFORMS = [Platform.CALENDAR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uonet+ Vulcan integration.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Uonet+ Vulcan is not supported on Python 3.12. Please use Python 3.11." - ) hass.data.setdefault(DOMAIN, {}) try: keystore = Keystore.load(entry.data["keystore"]) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index fea87480cf0..47ab7ec53cb 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["vulcan-api==2.3.0"] + "requirements": ["vulcan-api==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index cbb16751e52..02afa578fb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ vsure==2.6.6 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.0 +vulcan-api==2.3.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b9bb639589..74b15ac523c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2095,7 +2095,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vulcan -vulcan-api==2.3.0 +vulcan-api==2.3.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/conftest.py b/tests/components/vulcan/conftest.py deleted file mode 100644 index 05a518ad7f3..00000000000 --- a/tests/components/vulcan/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection for Python 3.12.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] From 9221f5da103dc73933421ae4cc85c92d34577e29 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Jan 2024 20:08:26 +0100 Subject: [PATCH 0563/1544] Enable strict typing for webhook (#107946) --- .strict-typing | 1 + homeassistant/components/webhook/__init__.py | 2 +- homeassistant/components/webhook/trigger.py | 11 +++++++---- mypy.ini | 10 ++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.strict-typing b/.strict-typing index 041da10ab80..9f91b5ab699 100644 --- a/.strict-typing +++ b/.strict-typing @@ -439,6 +439,7 @@ homeassistant.components.waqi.* homeassistant.components.water_heater.* homeassistant.components.watttime.* homeassistant.components.weather.* +homeassistant.components.webhook.* homeassistant.components.webostv.* homeassistant.components.websocket_api.* homeassistant.components.wemo.* diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 16f3e5c7ef2..00b27fdb647 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -174,7 +174,7 @@ async def async_handle_webhook( ) try: - response = await webhook["handler"](hass, webhook_id, request) + response: Response | None = await webhook["handler"](hass, webhook_id, request) if response is None: response = Response(status=HTTPStatus.OK) return response diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 78728793f5d..e0f3412a562 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass import logging +from typing import Any -from aiohttp import hdrs +from aiohttp import hdrs, web import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID @@ -57,9 +58,11 @@ class TriggerInstance: job: HassJob -async def _handle_webhook(hass, webhook_id, request): +async def _handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request +) -> None: """Handle incoming webhook.""" - base_result = {"platform": "webhook", "webhook_id": webhook_id} + base_result: dict[str, Any] = {"platform": "webhook", "webhook_id": webhook_id} if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): base_result["json"] = await request.json() @@ -133,7 +136,7 @@ async def async_attach_trigger( triggers[webhook_id].append(trigger_instance) @callback - def unregister(): + def unregister() -> None: """Unregister webhook.""" if issue_id: async_delete_issue(hass, DOMAIN, issue_id) diff --git a/mypy.ini b/mypy.ini index 7693912a6c8..c72aba4e62f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4153,6 +4153,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.webhook.*] +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.webostv.*] check_untyped_defs = true disallow_incomplete_defs = true From 852a73267f6124d9f25f8646ab4c629492f0af9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 09:34:49 -1000 Subject: [PATCH 0564/1544] Fix atag test mutating config entry after its adding to hass (#107603) --- tests/components/atag/__init__.py | 3 ++- tests/components/atag/test_config_flow.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/atag/__init__.py b/tests/components/atag/__init__.py index c41632b9715..adea1e07be7 100644 --- a/tests/components/atag/__init__.py +++ b/tests/components/atag/__init__.py @@ -92,10 +92,11 @@ async def init_integration( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, + unique_id: str = UID, ) -> MockConfigEntry: """Set up the Atag integration in Home Assistant.""" mock_connection(aioclient_mock) - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT, unique_id=unique_id) entry.add_to_hass(hass) if not skip_setup: diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py index 8dc73741e90..69e2327c616 100644 --- a/tests/components/atag/test_config_flow.py +++ b/tests/components/atag/test_config_flow.py @@ -31,8 +31,7 @@ async def test_adding_second_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that only one Atag configuration is allowed.""" - entry = await init_integration(hass, aioclient_mock) - entry.unique_id = UID + await init_integration(hass, aioclient_mock, unique_id=UID) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT From 5d3e06965524f056ceb2e61d0cae66ba66435650 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 13 Jan 2024 20:39:34 +0100 Subject: [PATCH 0565/1544] Don't load entities for docker virtual ethernet interfaces in System Monitor (#107966) --- homeassistant/components/systemmonitor/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index aeb7816784b..75b437c19eb 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -45,6 +45,9 @@ def get_all_network_interfaces() -> set[str]: """Return all network interfaces on system.""" interfaces: set[str] = set() for interface, _ in psutil.net_if_addrs().items(): + if interface.startswith("veth"): + # Don't load docker virtual network interfaces + continue interfaces.add(interface) _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) return interfaces From d7910841ef8b33546e026c6007efb95bbf15aaff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 09:49:41 -1000 Subject: [PATCH 0566/1544] Add an index for devices and config entries to the entity registry (#107516) * Add an index for devices and config entries to the entity registry * fixes * tweak * use a list for now since the tests check order --- homeassistant/helpers/entity_registry.py | 58 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 65ae1a8e9e5..1f9da1969f2 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -436,9 +436,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): class EntityRegistryItems(UserDict[str, RegistryEntry]): """Container for entity registry items, maps entity_id -> entry. - Maintains two additional indexes: + Maintains four additional indexes: - id -> entry - (domain, platform, unique_id) -> entity_id + - config_entry_id -> list[key] + - device_id -> list[key] """ def __init__(self) -> None: @@ -446,6 +448,8 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): super().__init__() self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} + self._config_entry_id_index: dict[str, list[str]] = {} + self._device_id_index: dict[str, list[str]] = {} def values(self) -> ValuesView[RegistryEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -455,18 +459,34 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """Add an item.""" data = self.data if key in data: - old_entry = data[key] - del self._entry_ids[old_entry.id] - del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] + self._unindex_entry(key) data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + if (config_entry_id := entry.config_entry_id) is not None: + self._config_entry_id_index.setdefault(config_entry_id, []).append(key) + if (device_id := entry.device_id) is not None: + self._device_id_index.setdefault(device_id, []).append(key) + + def _unindex_entry(self, key: str) -> None: + """Unindex an entry.""" + entry = self.data[key] + del self._entry_ids[entry.id] + del self._index[(entry.domain, entry.platform, entry.unique_id)] + if (config_entry_id := entry.config_entry_id) is not None: + entries = self._config_entry_id_index[config_entry_id] + entries.remove(key) + if not entries: + del self._config_entry_id_index[config_entry_id] + if (device_id := entry.device_id) is not None: + entries = self._device_id_index[device_id] + entries.remove(key) + if not entries: + del self._device_id_index[device_id] def __delitem__(self, key: str) -> None: """Remove an item.""" - entry = self[key] - del self._entry_ids[entry.id] - del self._index[(entry.domain, entry.platform, entry.unique_id)] + self._unindex_entry(key) super().__delitem__(key) def get_entity_id(self, key: tuple[str, str, str]) -> str | None: @@ -477,6 +497,19 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """Get entry from id.""" return self._entry_ids.get(key) + def get_entries_for_device_id(self, device_id: str) -> list[RegistryEntry]: + """Get entries for device.""" + return [self.data[key] for key in self._device_id_index.get(device_id, ())] + + def get_entries_for_config_entry_id( + self, config_entry_id: str + ) -> list[RegistryEntry]: + """Get entries for config entry.""" + return [ + self.data[key] + for key in self._config_entry_id_index.get(config_entry_id, ()) + ] + class EntityRegistry: """Class to hold a registry of entities.""" @@ -1217,9 +1250,8 @@ def async_entries_for_device( """Return entries that match a device.""" return [ entry - for entry in registry.entities.values() - if entry.device_id == device_id - and (not entry.disabled_by or include_disabled_entities) + for entry in registry.entities.get_entries_for_device_id(device_id) + if (not entry.disabled_by or include_disabled_entities) ] @@ -1236,11 +1268,7 @@ def async_entries_for_config_entry( registry: EntityRegistry, config_entry_id: str ) -> list[RegistryEntry]: """Return entries that match a config entry.""" - return [ - entry - for entry in registry.entities.values() - if entry.config_entry_id == config_entry_id - ] + return registry.entities.get_entries_for_config_entry_id(config_entry_id) @callback From b1d0c6a4f13b759ad4a07deec1f6ea3801fd98f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 10:10:50 -1000 Subject: [PATCH 0567/1544] Refactor User attribute caching to be safer and more efficient (#96723) * Cache construction of is_admin This has to be checked for a lot of api calls and the websocket every time the call is made * Cache construction of is_admin This has to be checked for a lot of api calls and the websocket every time the call is made * Cache construction of is_admin This has to be checked for a lot of api calls and the websocket every time the call is made * modernize * coverage * coverage * verify caching * verify caching * fix type * fix mocking --- homeassistant/auth/auth_store.py | 1 - homeassistant/auth/models.py | 63 +++++++++++++++++-------------- tests/auth/test_models.py | 34 +++++++++++++++++ tests/common.py | 2 +- tests/components/api/test_init.py | 2 + 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 50d5d630429..c8f5001a515 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -171,7 +171,6 @@ class AuthStore: groups.append(group) user.groups = groups - user.invalidate_permission_cache() for attr_name, value in ( ("name", name), diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 32a700d65f9..574f0cc75c0 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -3,10 +3,12 @@ from __future__ import annotations from datetime import datetime, timedelta import secrets -from typing import NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple import uuid import attr +from attr import Attribute +from attr.setters import validate from homeassistant.const import __version__ from homeassistant.util import dt as dt_util @@ -14,6 +16,12 @@ from homeassistant.util import dt as dt_util from . import permissions as perm_mdl from .const import GROUP_ID_ADMIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + TOKEN_TYPE_NORMAL = "normal" TOKEN_TYPE_SYSTEM = "system" TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token" @@ -29,19 +37,27 @@ class Group: system_generated: bool = attr.ib(default=False) -@attr.s(slots=True) +def _handle_permissions_change(self: User, user_attr: Attribute, new: Any) -> Any: + """Handle a change to a permissions.""" + self.invalidate_cache() + return validate(self, user_attr, new) + + +@attr.s(slots=False) class User: """A user.""" name: str | None = attr.ib() perm_lookup: perm_mdl.PermissionLookup = attr.ib(eq=False, order=False) id: str = attr.ib(factory=lambda: uuid.uuid4().hex) - is_owner: bool = attr.ib(default=False) - is_active: bool = attr.ib(default=False) + is_owner: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) + is_active: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) system_generated: bool = attr.ib(default=False) local_only: bool = attr.ib(default=False) - groups: list[Group] = attr.ib(factory=list, eq=False, order=False) + groups: list[Group] = attr.ib( + factory=list, eq=False, order=False, on_setattr=_handle_permissions_change + ) # List of credentials of a user. credentials: list[Credentials] = attr.ib(factory=list, eq=False, order=False) @@ -51,40 +67,31 @@ class User: factory=dict, eq=False, order=False ) - _permissions: perm_mdl.PolicyPermissions | None = attr.ib( - init=False, - eq=False, - order=False, - default=None, - ) - - @property + @cached_property def permissions(self) -> perm_mdl.AbstractPermissions: """Return permissions object for user.""" if self.is_owner: return perm_mdl.OwnerPermissions - - if self._permissions is not None: - return self._permissions - - self._permissions = perm_mdl.PolicyPermissions( + return perm_mdl.PolicyPermissions( perm_mdl.merge_policies([group.policy for group in self.groups]), self.perm_lookup, ) - return self._permissions - - @property + @cached_property def is_admin(self) -> bool: """Return if user is part of the admin group.""" - if self.is_owner: - return True + return self.is_owner or ( + self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) + ) - return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) - - def invalidate_permission_cache(self) -> None: - """Invalidate permission cache.""" - self._permissions = None + def invalidate_cache(self) -> None: + """Invalidate permission and is_admin cache.""" + for attr_to_invalidate in ("permissions", "is_admin"): + # try is must more efficient than suppress + try: # noqa: SIM105 + delattr(self, attr_to_invalidate) + except AttributeError: + pass @attr.s(slots=True) diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py index 1c518cf061d..3f0ad7acc1d 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -26,3 +26,37 @@ def test_permissions_merged() -> None: assert user.permissions.check_entity("switch.bla", "read") is True assert user.permissions.check_entity("light.kitchen", "read") is True assert user.permissions.check_entity("light.not_kitchen", "read") is False + + +def test_cache_cleared_on_group_change() -> None: + """Test we clear the cache when a group changes.""" + group = models.Group( + name="Test Group", policy={"entities": {"domains": {"switch": True}}} + ) + admin_group = models.Group( + name="Admin group", id=models.GROUP_ID_ADMIN, policy={"entities": {}} + ) + user = models.User( + name="Test User", perm_lookup=None, groups=[group], is_active=True + ) + # Make sure we cache instance + assert user.permissions is user.permissions + + # Make sure we cache is_admin + assert user.is_admin is user.is_admin + assert user.is_active is True + + user.groups = [] + assert user.groups == [] + assert user.is_admin is False + + user.is_owner = True + assert user.is_admin is True + user.is_owner = False + + assert user.is_admin is False + user.groups = [admin_group] + assert user.is_admin is True + + user.is_active = False + assert user.is_admin is False diff --git a/tests/common.py b/tests/common.py index 02c7150588d..35171799728 100644 --- a/tests/common.py +++ b/tests/common.py @@ -669,7 +669,7 @@ class MockUser(auth_models.User): def mock_policy(self, policy): """Mock a policy for a user.""" - self._permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) + self.permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) async def register_auth_provider( diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 08cb77b4559..d9c8e7481fa 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -684,6 +684,8 @@ async def test_get_entity_state_read_perm( ) -> None: """Test getting a state requires read permission.""" hass_admin_user.mock_policy({}) + hass_admin_user.groups = [] + assert hass_admin_user.is_admin is False resp = await mock_api_client.get("/api/states/light.test") assert resp.status == HTTPStatus.UNAUTHORIZED From 5a198e05ad48b15b01665c574a34e55d2f8eb554 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 10:27:58 -1000 Subject: [PATCH 0568/1544] Small cleanups to ESPHome (#107924) - Remove unused variables - Remove unneeded static info lookups --- .../components/esphome/alarm_control_panel.py | 12 ++++++------ homeassistant/components/esphome/climate.py | 4 ++-- homeassistant/components/esphome/entity.py | 11 ++++------- homeassistant/components/esphome/number.py | 2 +- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 6f3f903f248..e4f44dfd1fd 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -89,17 +89,17 @@ class EsphomeAlarmControlPanel( super()._on_static_info_update(static_info) static_info = self._static_info feature = 0 - if self._static_info.supported_features & EspHomeACPFeatures.ARM_HOME: + if static_info.supported_features & EspHomeACPFeatures.ARM_HOME: feature |= AlarmControlPanelEntityFeature.ARM_HOME - if self._static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: + if static_info.supported_features & EspHomeACPFeatures.ARM_AWAY: feature |= AlarmControlPanelEntityFeature.ARM_AWAY - if self._static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: + if static_info.supported_features & EspHomeACPFeatures.ARM_NIGHT: feature |= AlarmControlPanelEntityFeature.ARM_NIGHT - if self._static_info.supported_features & EspHomeACPFeatures.TRIGGER: + if static_info.supported_features & EspHomeACPFeatures.TRIGGER: feature |= AlarmControlPanelEntityFeature.TRIGGER - if self._static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: + if static_info.supported_features & EspHomeACPFeatures.ARM_CUSTOM_BYPASS: feature |= AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - if self._static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: + if static_info.supported_features & EspHomeACPFeatures.ARM_VACATION: feature |= AlarmControlPanelEntityFeature.ARM_VACATION self._attr_supported_features = AlarmControlPanelEntityFeature(feature) self._attr_code_format = ( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 08ed2f1109d..5c265068216 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -167,11 +167,11 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti self._attr_min_humidity = round(static_info.visual_min_humidity) self._attr_max_humidity = round(static_info.visual_max_humidity) features = ClimateEntityFeature(0) - if self._static_info.supports_two_point_target_temperature: + if static_info.supports_two_point_target_temperature: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE else: features |= ClimateEntityFeature.TARGET_TEMPERATURE - if self._static_info.supports_target_humidity: + if static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 7bd769231ac..1abf60be18a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -145,7 +145,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): state_type: type[_StateT], ) -> None: """Initialize.""" - self._entry_data = entry_data self._on_entry_data_changed() self._key = entity_info.key @@ -157,7 +156,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - self._entry_id = entry_data.entry_id # # If `friendly_name` is set, we use the Friendly naming rules, if # `friendly_name` is not set we make an exception to the naming rules for @@ -183,10 +181,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): entry_data = self._entry_data hass = self.hass key = self._key + static_info = self._static_info self.async_on_remove( entry_data.async_register_key_static_info_remove_callback( - self._static_info, + static_info, functools.partial(self.async_remove, force_remove=True), ) ) @@ -204,7 +203,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) self.async_on_remove( entry_data.async_register_key_static_info_updated_callback( - self._static_info, self._on_static_info_update + static_info, self._on_static_info_update ) ) self._update_state_from_entry_data() @@ -236,12 +235,10 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): @callback def _update_state_from_entry_data(self) -> None: """Update state from entry data.""" - state = self._entry_data.state key = self._key state_type = self._state_type - has_state = key in state[state_type] - if has_state: + if has_state := key in state[state_type]: self._state = cast(_StateT, state[state_type][key]) self._has_state = has_state diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index bc694ec39cf..f1902bdb39d 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -54,7 +54,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): super()._on_static_info_update(static_info) static_info = self._static_info self._attr_device_class = try_parse_enum( - NumberDeviceClass, self._static_info.device_class + NumberDeviceClass, static_info.device_class ) self._attr_native_min_value = static_info.min_value self._attr_native_max_value = static_info.max_value From 2c6aa80bc7db5e473f02d0eebafc1910db3c3e43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 10:28:43 -1000 Subject: [PATCH 0569/1544] Use more shorthand attributes in ESPHome fans (#107923) --- homeassistant/components/esphome/fan.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 08135e1a702..4c44134374a 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -53,10 +53,7 @@ _FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection, str] = EsphomeEnumMapper( class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """A fan implementation for ESPHome.""" - @property - def _supports_speed_levels(self) -> bool: - api_version = self._api_version - return api_version.major == 1 and api_version.minor > 3 + _supports_speed_levels: bool = True async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -129,13 +126,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): (1, self._static_info.supported_speed_levels), self._state.speed_level ) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - if not self._supports_speed_levels: - return len(ORDERED_NAMED_FAN_SPEEDS) - return self._static_info.supported_speed_levels - @property @esphome_state_property def oscillating(self) -> bool | None: @@ -154,16 +144,14 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan preset mode.""" return self._state.preset_mode - @property - def preset_modes(self) -> list[str] | None: - """Return the supported fan preset modes.""" - return self._static_info.supported_preset_modes - @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" super()._on_static_info_update(static_info) static_info = self._static_info + api_version = self._api_version + supports_speed_levels = api_version.major == 1 and api_version.minor > 3 + self._supports_speed_levels = supports_speed_levels flags = FanEntityFeature(0) if static_info.supports_oscillation: flags |= FanEntityFeature.OSCILLATE @@ -174,3 +162,8 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): if static_info.supported_preset_modes: flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags + self._attr_preset_modes = static_info.supported_preset_modes + if not supports_speed_levels: + self._attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) + else: + self._attr_speed_count = static_info.supported_speed_levels From 3649cb96de36561985f4fa310de4818fcd2cec84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 10:34:15 -1000 Subject: [PATCH 0570/1544] Refactor config entry storage and index (#107590) --- homeassistant/config_entries.py | 191 +++++++++++++++++++++++--------- tests/common.py | 2 - tests/test_config_entries.py | 17 +++ 3 files changed, 153 insertions(+), 57 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9e4791fdef6..819b813832d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,7 +2,15 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping +from collections import UserDict +from collections.abc import ( + Callable, + Coroutine, + Generator, + Iterable, + Mapping, + ValuesView, +) from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -336,6 +344,13 @@ class ConfigEntry: self._tries = 0 self._setup_again_job: HassJob | None = None + def __repr__(self) -> str: + """Representation of ConfigEntry.""" + return ( + f"" + ) + async def async_setup( self, hass: HomeAssistant, @@ -1057,6 +1072,67 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): ) +class ConfigEntryItems(UserDict[str, ConfigEntry]): + """Container for config items, maps config_entry_id -> entry. + + Maintains two additional indexes: + - domain -> list[ConfigEntry] + - domain -> unique_id -> ConfigEntry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._domain_index: dict[str, list[ConfigEntry]] = {} + self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} + + def values(self) -> ValuesView[ConfigEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: + """Add an item.""" + data = self.data + if entry_id in data: + # This is likely a bug in a test that is adding the same entry twice. + # In the future, once we have fixed the tests, this will raise HomeAssistantError. + _LOGGER.error("An entry with the id %s already exists", entry_id) + self._unindex_entry(entry_id) + data[entry_id] = entry + self._domain_index.setdefault(entry.domain, []).append(entry) + if entry.unique_id is not None: + self._domain_unique_id_index.setdefault(entry.domain, {})[ + entry.unique_id + ] = entry + + def _unindex_entry(self, entry_id: str) -> None: + """Unindex an entry.""" + entry = self.data[entry_id] + domain = entry.domain + self._domain_index[domain].remove(entry) + if not self._domain_index[domain]: + del self._domain_index[domain] + if (unique_id := entry.unique_id) is not None: + del self._domain_unique_id_index[domain][unique_id] + if not self._domain_unique_id_index[domain]: + del self._domain_unique_id_index[domain] + + def __delitem__(self, entry_id: str) -> None: + """Remove an item.""" + self._unindex_entry(entry_id) + super().__delitem__(entry_id) + + def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: + """Get entries for a domain.""" + return self._domain_index.get(domain, []) + + def get_entry_by_domain_and_unique_id( + self, domain: str, unique_id: str + ) -> ConfigEntry | None: + """Get entry by domain and unique id.""" + return self._domain_unique_id_index.get(domain, {}).get(unique_id) + + class ConfigEntries: """Manage the configuration entries. @@ -1069,8 +1145,7 @@ class ConfigEntries: self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) self._hass_config = hass_config - self._entries: dict[str, ConfigEntry] = {} - self._domain_index: dict[str, list[ConfigEntry]] = {} + self._entries = ConfigEntryItems() self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1093,23 +1168,29 @@ class ConfigEntries: @callback def async_get_entry(self, entry_id: str) -> ConfigEntry | None: """Return entry with matching entry_id.""" - return self._entries.get(entry_id) + return self._entries.data.get(entry_id) @callback def async_entries(self, domain: str | None = None) -> list[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return list(self._domain_index.get(domain, [])) + return list(self._entries.get_entries_for_domain(domain)) + + @callback + def async_entry_for_domain_unique_id( + self, domain: str, unique_id: str + ) -> ConfigEntry | None: + """Return entry for a domain with a matching unique id.""" + return self._entries.get_entry_by_domain_and_unique_id(domain, unique_id) async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" - if entry.entry_id in self._entries: + if entry.entry_id in self._entries.data: raise HomeAssistantError( f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry - self._domain_index.setdefault(entry.domain, []).append(entry) self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -1127,9 +1208,6 @@ class ConfigEntries: await entry.async_remove(self.hass) del self._entries[entry.entry_id] - self._domain_index[entry.domain].remove(entry) - if not self._domain_index[entry.domain]: - del self._domain_index[entry.domain] self._async_schedule_save() dev_reg = device_registry.async_get(self.hass) @@ -1189,13 +1267,10 @@ class ConfigEntries: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) if config is None: - self._entries = {} - self._domain_index = {} + self._entries = ConfigEntryItems() return - entries = {} - domain_index: dict[str, list[ConfigEntry]] = {} - + entries: ConfigEntryItems = ConfigEntryItems() for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1230,9 +1305,7 @@ class ConfigEntries: pref_disable_polling=entry.get("pref_disable_polling"), ) entries[entry_id] = config_entry - domain_index.setdefault(domain, []).append(config_entry) - self._domain_index = domain_index self._entries = entries async def async_setup(self, entry_id: str) -> bool: @@ -1365,8 +1438,15 @@ class ConfigEntries: """ changed = False + if unique_id is not UNDEFINED and entry.unique_id != unique_id: + # Reindex the entry if the unique_id has changed + entry_id = entry.entry_id + del self._entries[entry_id] + entry.unique_id = unique_id + self._entries[entry_id] = entry + changed = True + for attr, value in ( - ("unique_id", unique_id), ("title", title), ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), @@ -1579,38 +1659,41 @@ class ConfigFlow(data_entry_flow.FlowHandler): if self.unique_id is None: return - for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id != self.unique_id: - continue - should_reload = False - if ( - updates is not None - and self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - and reload_on_update - and entry.state - in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) - ): - # Existing config entry present, and the - # entry data just changed - should_reload = True - elif ( - self.source in DISCOVERY_SOURCES - and entry.state is ConfigEntryState.SETUP_RETRY - ): - # Existing config entry present in retry state, and we - # just discovered the unique id so we know its online - should_reload = True - # Allow ignored entries to be configured on manual user step - if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: - continue - if should_reload: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id), - f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", - ) - raise data_entry_flow.AbortFlow(error) + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + ): + return + + should_reload = False + if ( + updates is not None + and self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + and reload_on_update + and entry.state in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) + ): + # Existing config entry present, and the + # entry data just changed + should_reload = True + elif ( + self.source in DISCOVERY_SOURCES + and entry.state is ConfigEntryState.SETUP_RETRY + ): + # Existing config entry present in retry state, and we + # just discovered the unique id so we know its online + should_reload = True + # Allow ignored entries to be configured on manual user step + if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: + return + if should_reload: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + raise data_entry_flow.AbortFlow(error) async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True @@ -1639,11 +1722,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): ): self.hass.config_entries.flow.async_abort(progress["flow_id"]) - for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id == unique_id: - return entry - - return None + return self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, unique_id + ) @callback def _set_confirm_only( diff --git a/tests/common.py b/tests/common.py index 35171799728..8b5a16c7104 100644 --- a/tests/common.py +++ b/tests/common.py @@ -939,12 +939,10 @@ class MockConfigEntry(config_entries.ConfigEntry): def add_to_hass(self, hass: HomeAssistant) -> None: """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self - hass.config_entries._domain_index.setdefault(self.domain, []).append(self) def add_to_manager(self, manager: config_entries.ConfigEntries) -> None: """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self - manager._domain_index.setdefault(self.domain, []).append(self) def patch_yaml_files(files_dict, endswith=True): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e9989b6839e..fd74a2e6286 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3123,6 +3123,9 @@ async def test_updating_entry_with_and_without_changes( state=config_entries.ConfigEntryState.SETUP_ERROR, ) entry.add_to_manager(manager) + assert "abc123" in str(entry) + + assert manager.async_entry_for_domain_unique_id("test", "abc123") is entry assert manager.async_update_entry(entry) is False @@ -3138,6 +3141,10 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry, **change) is True assert manager.async_update_entry(entry, **change) is False + assert manager.async_entry_for_domain_unique_id("test", "abc123") is None + assert manager.async_entry_for_domain_unique_id("test", "abcd1234") is entry + assert "abcd1234" in str(entry) + async def test_entry_reload_calls_on_unload_listeners( hass: HomeAssistant, manager: config_entries.ConfigEntries @@ -4127,3 +4134,13 @@ async def test_preview_not_supported( ) assert result["preview"] is None + + +def test_raise_trying_to_add_same_config_entry_twice( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we log an error if trying to add same config entry twice.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + entry.add_to_hass(hass) + assert f"An entry with the id {entry.entry_id} already exists" in caplog.text From 5e79a0e5836d2b730bc5d944321ce8ccf0c55c0b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 13 Jan 2024 21:40:50 +0100 Subject: [PATCH 0571/1544] Enable strict typing for search (#107912) --- .strict-typing | 1 + homeassistant/components/search/__init__.py | 26 ++++++++++----------- mypy.ini | 10 ++++++++ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9f91b5ab699..4b0d03ef095 100644 --- a/.strict-typing +++ b/.strict-typing @@ -354,6 +354,7 @@ homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.schedule.* homeassistant.components.scrape.* +homeassistant.components.search.* homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index ac9a13850d6..7dd7d952e95 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -111,7 +111,7 @@ class Searcher: self._to_resolve: deque[tuple[str, str]] = deque() @callback - def async_search(self, item_type, item_id): + def async_search(self, item_type: str, item_id: str) -> dict[str, set[str]]: """Find results.""" _LOGGER.debug("Searching for %s/%s", item_type, item_id) self.results[item_type].add(item_id) @@ -140,7 +140,7 @@ class Searcher: return {key: val for key, val in self.results.items() if val} @callback - def _add_or_resolve(self, item_type, item_id): + def _add_or_resolve(self, item_type: str, item_id: str) -> None: """Add an item to explore.""" if item_id in self.results[item_type]: return @@ -151,7 +151,7 @@ class Searcher: self._to_resolve.append((item_type, item_id)) @callback - def _resolve_area(self, area_id) -> None: + def _resolve_area(self, area_id: str) -> None: """Resolve an area.""" for device in dr.async_entries_for_area(self._device_reg, area_id): self._add_or_resolve("device", device.id) @@ -166,7 +166,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_automation(self, automation_entity_id) -> None: + def _resolve_automation(self, automation_entity_id: str) -> None: """Resolve an automation. Will only be called if automation is an entry point. @@ -188,7 +188,7 @@ class Searcher: self._add_or_resolve("automation_blueprint", blueprint) @callback - def _resolve_automation_blueprint(self, blueprint_path) -> None: + def _resolve_automation_blueprint(self, blueprint_path: str) -> None: """Resolve an automation blueprint. Will only be called if blueprint is an entry point. @@ -199,7 +199,7 @@ class Searcher: self._add_or_resolve("automation", entity_id) @callback - def _resolve_config_entry(self, config_entry_id) -> None: + def _resolve_config_entry(self, config_entry_id: str) -> None: """Resolve a config entry. Will only be called if config entry is an entry point. @@ -215,7 +215,7 @@ class Searcher: self._add_or_resolve("entity", entity_entry.entity_id) @callback - def _resolve_device(self, device_id) -> None: + def _resolve_device(self, device_id: str) -> None: """Resolve a device.""" device_entry = self._device_reg.async_get(device_id) # Unlikely entry doesn't exist, but let's guard for bad data. @@ -239,7 +239,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_entity(self, entity_id) -> None: + def _resolve_entity(self, entity_id: str) -> None: """Resolve an entity.""" # Extra: Find automations and scripts that reference this entity. @@ -277,7 +277,7 @@ class Searcher: self._add_or_resolve(domain, entity_id) @callback - def _resolve_group(self, group_entity_id) -> None: + def _resolve_group(self, group_entity_id: str) -> None: """Resolve a group. Will only be called if group is an entry point. @@ -286,7 +286,7 @@ class Searcher: self._add_or_resolve("entity", entity_id) @callback - def _resolve_person(self, person_entity_id) -> None: + def _resolve_person(self, person_entity_id: str) -> None: """Resolve a person. Will only be called if person is an entry point. @@ -295,7 +295,7 @@ class Searcher: self._add_or_resolve("entity", entity) @callback - def _resolve_scene(self, scene_entity_id) -> None: + def _resolve_scene(self, scene_entity_id: str) -> None: """Resolve a scene. Will only be called if scene is an entry point. @@ -304,7 +304,7 @@ class Searcher: self._add_or_resolve("entity", entity) @callback - def _resolve_script(self, script_entity_id) -> None: + def _resolve_script(self, script_entity_id: str) -> None: """Resolve a script. Will only be called if script is an entry point. @@ -322,7 +322,7 @@ class Searcher: self._add_or_resolve("script_blueprint", blueprint) @callback - def _resolve_script_blueprint(self, blueprint_path) -> None: + def _resolve_script_blueprint(self, blueprint_path: str) -> None: """Resolve a script blueprint. Will only be called if blueprint is an entry point. diff --git a/mypy.ini b/mypy.ini index c72aba4e62f..b92bac5e1f7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3301,6 +3301,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.search.*] +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.select.*] check_untyped_defs = true disallow_incomplete_defs = true From 6ada825805eca3c43eaecc38777b14aaf34e46db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 10:42:41 -1000 Subject: [PATCH 0572/1544] Use faster is_admin check for websocket state and event subscriptions (#107621) --- .../components/websocket_api/commands.py | 19 ++++++++++++------- .../components/websocket_api/test_commands.py | 2 ++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index dfd04aa001a..0edb6ad5261 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -113,9 +113,11 @@ def _forward_events_check_permissions( # We have to lookup the permissions again because the user might have # changed since the subscription was created. permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + if ( + not user.is_admin + and not permissions.access_all_entities(POLICY_READ) + and not permissions.check_entity(event.data["entity_id"], POLICY_READ) + ): return send_message(messages.cached_event_message(msg_id, event)) @@ -306,7 +308,8 @@ async def handle_call_service( def _async_get_allowed_states( hass: HomeAssistant, connection: ActiveConnection ) -> list[State]: - if connection.user.permissions.access_all_entities(POLICY_READ): + user = connection.user + if user.is_admin or user.permissions.access_all_entities(POLICY_READ): return hass.states.async_all() entity_perm = connection.user.permissions.check_entity return [ @@ -372,9 +375,11 @@ def _forward_entity_changes( # We have to lookup the permissions again because the user might have # changed since the subscription was created. permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + if ( + not user.is_admin + and not permissions.access_all_entities(POLICY_READ) + and not permissions.check_entity(event.data["entity_id"], POLICY_READ) + ): return send_message(messages.cached_state_diff_message(msg_id, event)) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 127b45484be..270ad9bf178 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -804,6 +804,7 @@ async def test_states_filters_visible( hass: HomeAssistant, hass_admin_user: MockUser, websocket_client ) -> None: """Test we only get entities that we're allowed to see.""" + hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") @@ -1048,6 +1049,7 @@ async def test_subscribe_unsubscribe_entities( } hass_admin_user.groups = [] hass_admin_user.mock_policy({"entities": {"entity_ids": {"light.permitted": True}}}) + assert not hass_admin_user.is_admin await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) From b0adaece257bba74125dc5d292c90c3307897f76 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:32:25 -0500 Subject: [PATCH 0573/1544] Reload ZHA only a single time when the connection is lost multiple times (#107963) * Reload only a single time when the connection is lost multiple times * Ignore when reset task finishes, allow only one reset per `ZHAGateway` --- homeassistant/components/zha/core/gateway.py | 15 ++++++++-- tests/components/zha/test_gateway.py | 30 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 12e439f1059..3efdc77934a 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -142,7 +142,9 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + self.shutting_down = False + self._reload_task: asyncio.Task | None = None def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" @@ -231,12 +233,17 @@ class ZHAGateway: def connection_lost(self, exc: Exception) -> None: """Handle connection lost event.""" + _LOGGER.debug("Connection to the radio was lost: %r", exc) + if self.shutting_down: return - _LOGGER.debug("Connection to the radio was lost: %r", exc) + # Ensure we do not queue up multiple resets + if self._reload_task is not None: + _LOGGER.debug("Ignoring reset, one is already running") + return - self.hass.async_create_task( + self._reload_task = self.hass.async_create_task( self.hass.config_entries.async_reload(self.config_entry.entry_id) ) @@ -760,6 +767,10 @@ class ZHAGateway: async def shutdown(self) -> None: """Stop ZHA Controller Application.""" + if self.shutting_down: + _LOGGER.debug("Ignoring duplicate shutdown event") + return + _LOGGER.debug("Shutting down ZHA ControllerApplication") self.shutting_down = True diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 4f520920704..9c3cf7aa2f8 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -291,3 +291,33 @@ async def test_gateway_force_multi_pan_channel( _, config = zha_gateway.get_application_controller_data() assert config["network"]["channel"] == expected_channel + + +async def test_single_reload_on_multiple_connection_loss( + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, +): + """Test that we only reload once when we lose the connection multiple times.""" + config_entry.add_to_hass(hass) + + zha_gateway = ZHAGateway(hass, {}, config_entry) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_gateway.async_initialize() + + with patch.object( + hass.config_entries, "async_reload", wraps=hass.config_entries.async_reload + ) as mock_reload: + zha_gateway.connection_lost(RuntimeError()) + zha_gateway.connection_lost(RuntimeError()) + zha_gateway.connection_lost(RuntimeError()) + zha_gateway.connection_lost(RuntimeError()) + zha_gateway.connection_lost(RuntimeError()) + + assert len(mock_reload.mock_calls) == 1 + + await hass.async_block_till_done() From ef8d394c16eb7c63422e7ec78fc3373e42b135a1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 13 Jan 2024 22:33:02 +0100 Subject: [PATCH 0574/1544] Update sleep period for Shelly devices with buggy fw (#107961) * update sleep period for Shelly devices with buggy fw * code quality * update model list * add test * Apply review comments * fix test * use costant --- homeassistant/components/shelly/__init__.py | 19 +++++++++++++++++++ homeassistant/components/shelly/const.py | 13 +++++++++++++ tests/components/shelly/test_init.py | 18 ++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6b7a00db8e2..6b8d100ea8f 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -30,12 +30,15 @@ from homeassistant.helpers.device_registry import ( from homeassistant.helpers.typing import ConfigType from .const import ( + BLOCK_EXPECTED_SLEEP_PERIOD, + BLOCK_WRONG_SLEEP_PERIOD, CONF_COAP_PORT, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, LOGGER, + MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, ) from .coordinator import ( @@ -162,6 +165,22 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b sleep_period = entry.data.get(CONF_SLEEP_PERIOD) shelly_entry_data = get_entry_data(hass)[entry.entry_id] + # Some old firmware have a wrong sleep period hardcoded value. + # Following code block will force the right value for affected devices + if ( + sleep_period == BLOCK_WRONG_SLEEP_PERIOD + and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD + ): + LOGGER.warning( + "Updating stored sleep period for %s: from %s to %s", + entry.title, + sleep_period, + BLOCK_EXPECTED_SLEEP_PERIOD, + ) + data = {**entry.data} + data[CONF_SLEEP_PERIOD] = sleep_period = BLOCK_EXPECTED_SLEEP_PERIOD + hass.config_entries.async_update_entry(entry, data=data) + async def _async_block_device_setup() -> None: """Set up a block based device that is online.""" shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 1e2c22691fb..6cc513015d3 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -14,7 +14,10 @@ from aioshelly.const import ( MODEL_DIMMER, MODEL_DIMMER_2, MODEL_DUO, + MODEL_DW, + MODEL_DW_2, MODEL_GAS, + MODEL_HT, MODEL_MOTION, MODEL_MOTION_2, MODEL_RGBW2, @@ -55,6 +58,12 @@ MODELS_SUPPORTING_LIGHT_EFFECTS: Final = ( MODEL_RGBW2, ) +MODELS_WITH_WRONG_SLEEP_PERIOD: Final = ( + MODEL_DW, + MODEL_DW_2, + MODEL_HT, +) + # Bulbs that support white & color modes DUAL_MODE_LIGHT_MODELS: Final = ( MODEL_BULB, @@ -176,6 +185,10 @@ KELVIN_MAX_VALUE: Final = 6500 KELVIN_MIN_VALUE_WHITE: Final = 2700 KELVIN_MIN_VALUE_COLOR: Final = 3000 +# Sleep period +BLOCK_WRONG_SLEEP_PERIOD = 21600 +BLOCK_EXPECTED_SLEEP_PERIOD = 43200 + UPTIME_DEVIATION: Final = 5 # Time to wait before reloading entry upon device config change diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 643fc775cc4..bc0ba045a55 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -11,8 +11,12 @@ from aioshelly.exceptions import ( import pytest from homeassistant.components.shelly.const import ( + BLOCK_EXPECTED_SLEEP_PERIOD, + BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, + CONF_SLEEP_PERIOD, DOMAIN, + MODELS_WITH_WRONG_SLEEP_PERIOD, BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -309,3 +313,17 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device) -> None assert entry.state is ConfigEntryState.LOADED assert hass.states.get("switch.test_name_channel_1").state is STATE_ON + + +@pytest.mark.parametrize(("model"), MODELS_WITH_WRONG_SLEEP_PERIOD) +async def test_sleeping_block_device_wrong_sleep_period( + hass: HomeAssistant, mock_block_device, model +) -> None: + """Test sleeping block device with wrong sleep period.""" + entry = await init_integration( + hass, 1, model=model, sleep_period=BLOCK_WRONG_SLEEP_PERIOD, skip_setup=True + ) + assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_WRONG_SLEEP_PERIOD + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD From 2584de63246fc6c34c8600216e0cfe4e5eb638e5 Mon Sep 17 00:00:00 2001 From: Dorian Benech <47485034+xmow49@users.noreply.github.com> Date: Sat, 13 Jan 2024 22:53:53 +0100 Subject: [PATCH 0575/1544] Add TICMeter Energy Metering sensors (#107956) --- homeassistant/components/zha/sensor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index ea5d09dd6f4..3e41537c53c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -585,7 +585,7 @@ class SmartEnergySummation(SmartEnergyMetering): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"TS011F", "ZLinky_TIC"}, + models={"TS011F", "ZLinky_TIC", "TICMeter"}, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -597,7 +597,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier1SmartEnergySummation(PolledSmartEnergySummation): @@ -611,7 +611,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier2SmartEnergySummation(PolledSmartEnergySummation): @@ -625,7 +625,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier3SmartEnergySummation(PolledSmartEnergySummation): @@ -639,7 +639,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier4SmartEnergySummation(PolledSmartEnergySummation): @@ -653,7 +653,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier5SmartEnergySummation(PolledSmartEnergySummation): @@ -667,7 +667,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, - models={"ZLinky_TIC"}, + models={"ZLinky_TIC", "TICMeter"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier6SmartEnergySummation(PolledSmartEnergySummation): From 3c1e2e17a0d4205e8f5f8c4d990b621c15da6fbf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 14 Jan 2024 02:56:22 +0100 Subject: [PATCH 0576/1544] Use prometheus_client module directly (#107918) --- .../components/prometheus/__init__.py | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 7beac4cc54b..308bbb599ea 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -95,9 +95,7 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Prometheus component.""" - hass.http.register_view( - PrometheusView(prometheus_client, config[DOMAIN][CONF_REQUIRES_AUTH]) - ) + hass.http.register_view(PrometheusView(config[DOMAIN][CONF_REQUIRES_AUTH])) conf = config[DOMAIN] entity_filter = conf[CONF_FILTER] @@ -112,7 +110,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) metrics = PrometheusMetrics( - prometheus_client, entity_filter, namespace, climate_units, @@ -138,7 +135,6 @@ class PrometheusMetrics: def __init__( self, - prometheus_cli, entity_filter, namespace, climate_units, @@ -147,7 +143,6 @@ class PrometheusMetrics: default_metric, ): """Initialize Prometheus Metrics.""" - self.prometheus_cli = prometheus_cli self._component_config = component_config self._override_metric = override_metric self._default_metric = default_metric @@ -199,20 +194,20 @@ class PrometheusMetrics: labels = self._labels(state) state_change = self._metric( - "state_change", self.prometheus_cli.Counter, "The number of state changes" + "state_change", prometheus_client.Counter, "The number of state changes" ) state_change.labels(**labels).inc() entity_available = self._metric( "entity_available", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Entity is available (not in the unavailable or unknown state)", ) entity_available.labels(**labels).set(float(state.state not in ignored_states)) last_updated_time_seconds = self._metric( "last_updated_time_seconds", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "The last_updated timestamp", ) last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) @@ -259,7 +254,7 @@ class PrometheusMetrics: for key, value in state.attributes.items(): metric = self._metric( f"{state.domain}_attr_{key.lower()}", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"{key} attribute of {state.domain} entity", ) @@ -284,7 +279,7 @@ class PrometheusMetrics: full_metric_name, documentation, labels, - registry=self.prometheus_cli.REGISTRY, + registry=prometheus_client.REGISTRY, ) return self._metrics[metric] @@ -327,7 +322,7 @@ class PrometheusMetrics: if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Battery level as a percentage of its capacity", ) try: @@ -339,7 +334,7 @@ class PrometheusMetrics: def _handle_binary_sensor(self, state): metric = self._metric( "binary_sensor_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the binary sensor (0/1)", ) value = self.state_as_number(state) @@ -348,7 +343,7 @@ class PrometheusMetrics: def _handle_input_boolean(self, state): metric = self._metric( "input_boolean_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the input boolean (0/1)", ) value = self.state_as_number(state) @@ -358,13 +353,13 @@ class PrometheusMetrics: if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( f"{domain}_state_{unit}", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"State of the {title} measured in {unit}", ) else: metric = self._metric( f"{domain}_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, f"State of the {title}", ) @@ -388,7 +383,7 @@ class PrometheusMetrics: def _handle_device_tracker(self, state): metric = self._metric( "device_tracker_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the device tracker (0/1)", ) value = self.state_as_number(state) @@ -396,7 +391,7 @@ class PrometheusMetrics: def _handle_person(self, state): metric = self._metric( - "person_state", self.prometheus_cli.Gauge, "State of the person (0/1)" + "person_state", prometheus_client.Gauge, "State of the person (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) @@ -404,7 +399,7 @@ class PrometheusMetrics: def _handle_cover(self, state): metric = self._metric( "cover_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the cover (0/1)", ["state"], ) @@ -419,7 +414,7 @@ class PrometheusMetrics: if position is not None: position_metric = self._metric( "cover_position", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Position of the cover (0-100)", ) position_metric.labels(**self._labels(state)).set(float(position)) @@ -428,7 +423,7 @@ class PrometheusMetrics: if tilt_position is not None: tilt_position_metric = self._metric( "cover_tilt_position", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Tilt Position of the cover (0-100)", ) tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position)) @@ -436,7 +431,7 @@ class PrometheusMetrics: def _handle_light(self, state): metric = self._metric( "light_brightness_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Light brightness percentage (0..100)", ) @@ -453,7 +448,7 @@ class PrometheusMetrics: def _handle_lock(self, state): metric = self._metric( - "lock_state", self.prometheus_cli.Gauge, "State of the lock (0/1)" + "lock_state", prometheus_client.Gauge, "State of the lock (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) @@ -466,7 +461,7 @@ class PrometheusMetrics: ) metric = self._metric( metric_name, - self.prometheus_cli.Gauge, + prometheus_client.Gauge, metric_description, ) metric.labels(**self._labels(state)).set(temp) @@ -500,7 +495,7 @@ class PrometheusMetrics: if current_action := state.attributes.get(ATTR_HVAC_ACTION): metric = self._metric( "climate_action", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "HVAC action", ["action"], ) @@ -514,7 +509,7 @@ class PrometheusMetrics: if current_mode and available_modes: metric = self._metric( "climate_mode", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "HVAC mode", ["mode"], ) @@ -528,14 +523,14 @@ class PrometheusMetrics: if humidifier_target_humidity_percent: metric = self._metric( "humidifier_target_humidity_percent", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Target Relative Humidity", ) metric.labels(**self._labels(state)).set(humidifier_target_humidity_percent) metric = self._metric( "humidifier_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "State of the humidifier (0/1)", ) try: @@ -549,7 +544,7 @@ class PrometheusMetrics: if current_mode and available_modes: metric = self._metric( "humidifier_mode", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Humidifier Mode", ["mode"], ) @@ -571,7 +566,7 @@ class PrometheusMetrics: if unit: documentation = f"Sensor data measured in {unit}" - _metric = self._metric(metric, self.prometheus_cli.Gauge, documentation) + _metric = self._metric(metric, prometheus_client.Gauge, documentation) try: value = self.state_as_number(state) @@ -647,7 +642,7 @@ class PrometheusMetrics: def _handle_switch(self, state): metric = self._metric( - "switch_state", self.prometheus_cli.Gauge, "State of the switch (0/1)" + "switch_state", prometheus_client.Gauge, "State of the switch (0/1)" ) try: @@ -664,7 +659,7 @@ class PrometheusMetrics: def _handle_automation(self, state): metric = self._metric( "automation_triggered_count", - self.prometheus_cli.Counter, + prometheus_client.Counter, "Count of times an automation has been triggered", ) @@ -673,7 +668,7 @@ class PrometheusMetrics: def _handle_counter(self, state): metric = self._metric( "counter_value", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Value of counter entities", ) @@ -682,7 +677,7 @@ class PrometheusMetrics: def _handle_update(self, state): metric = self._metric( "update_state", - self.prometheus_cli.Gauge, + prometheus_client.Gauge, "Update state, indicating if an update is available (0/1)", ) value = self.state_as_number(state) @@ -695,16 +690,15 @@ class PrometheusView(HomeAssistantView): url = API_ENDPOINT name = "api:prometheus" - def __init__(self, prometheus_cli, requires_auth: bool) -> None: + def __init__(self, requires_auth: bool) -> None: """Initialize Prometheus view.""" self.requires_auth = requires_auth - self.prometheus_cli = prometheus_cli async def get(self, request): """Handle request for Prometheus metrics.""" _LOGGER.debug("Received Prometheus metrics request") return web.Response( - body=self.prometheus_cli.generate_latest(self.prometheus_cli.REGISTRY), + body=prometheus_client.generate_latest(prometheus_client.REGISTRY), content_type=CONTENT_TYPE_TEXT_PLAIN, ) From 9c82df4b983eb745fead8bd7e9e6ad4568bf438b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:01:18 -1000 Subject: [PATCH 0577/1544] Fix duplicate config entry additions in tests (#107984) zha and plex still add the same config entry multiple times but they are going to need seperate PRs as they have more complex logic --- tests/components/anova/test_init.py | 1 - tests/components/august/test_sensor.py | 3 +-- tests/components/efergy/__init__.py | 1 - tests/components/google/test_calendar.py | 1 - tests/components/kraken/test_sensor.py | 1 - tests/components/ruckus_unleashed/test_device_tracker.py | 1 - tests/components/yeelight/test_light.py | 3 +++ 7 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py index cbd7231f366..631a69e103b 100644 --- a/tests/components/anova/test_init.py +++ b/tests/components/anova/test_init.py @@ -26,7 +26,6 @@ async def test_wrong_login( ) -> None: """Test for setup failure if connection to Anova is missing.""" entry = create_entry(hass) - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index d71d22064fc..f2ea0066345 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -351,8 +351,7 @@ async def test_restored_state( ], ) - august_entry = await _create_august_with_devices(hass, [lock_one]) - august_entry.add_to_hass(hass) + await _create_august_with_devices(hass, [lock_one]) await hass.async_block_till_done() diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py index 5d77acc6838..3780bcb5494 100644 --- a/tests/components/efergy/__init__.py +++ b/tests/components/efergy/__init__.py @@ -40,7 +40,6 @@ async def init_integration( """Set up the Efergy integration in Home Assistant.""" entry = create_entry(hass, token=token) await mock_responses(hass, aioclient_mock, token=token, error=error) - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index d1cc41e166a..55a9f814a63 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -571,7 +571,6 @@ async def test_scan_calendar_error( config_entry, ) -> None: """Test that the calendar update handles a server error.""" - config_entry.add_to_hass(hass) mock_calendars_list({}, exc=ClientError()) assert await component_setup() diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 3ba351a4225..791b70c1283 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -162,7 +162,6 @@ async def test_sensors_available_after_restart( manufacturer="Kraken.com", entry_type=dr.DeviceEntryType.SERVICE, ) - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 403ea7d0ca7..cda3836a0a4 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -98,7 +98,6 @@ async def test_restoring_clients(hass: HomeAssistant) -> None: ) with RuckusAjaxApiPatchContext(active_clients={}): - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 441ec202b28..da907fdee33 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -826,6 +826,9 @@ async def test_device_types( # nightlight as a setting of the main entity if nightlight_mode_properties is not None: mocked_bulb.last_properties["active_mode"] = True + config_entry = MockConfigEntry( + domain=DOMAIN, data={**CONFIG_ENTRY_DATA, CONF_NIGHTLIGHT_SWITCH: False} + ) config_entry.add_to_hass(hass) await _async_setup(config_entry) state = hass.states.get(entity_id) From f1228a1cfba136d16dfcd618fbcb050cb89abb2e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:02:38 -1000 Subject: [PATCH 0578/1544] Add H5106 support to govee-ble (#107781) * Bump govee-ble to 0.27.0 changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.26.0...v0.27.0 note: H5106 is partially supported, full support will be added in another PR + docs * .1 * Add Govee H5106 support * 0.27.2 * commit the tests --- homeassistant/components/govee_ble/sensor.py | 10 +++++++ tests/components/govee_ble/__init__.py | 10 +++++++ tests/components/govee_ble/test_sensor.py | 28 ++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index cbef769bdc9..3809a2390f3 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature, @@ -58,6 +59,15 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + ( + DeviceClass.PM25, + Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ): SensorEntityDescription( + key=f"{DeviceClass.PM25}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/tests/components/govee_ble/__init__.py b/tests/components/govee_ble/__init__.py index 5dd67adb160..c093a6dddb5 100644 --- a/tests/components/govee_ble/__init__.py +++ b/tests/components/govee_ble/__init__.py @@ -74,3 +74,13 @@ GVH5178_SERVICE_INFO_ERROR = BluetoothServiceInfo( service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], source="local", ) + +GVH5106_SERVICE_INFO = BluetoothServiceInfo( + name="GVH5106_4E05", + address="CC:32:37:35:4E:05", + rssi=-66, + manufacturer_data={1: b"\x01\x01\x0e\xd12\x98"}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 5e7ca299fb6..55f3d293096 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from . import ( GVH5075_SERVICE_INFO, + GVH5106_SERVICE_INFO, GVH5178_PRIMARY_SERVICE_INFO, GVH5178_REMOTE_SERVICE_INFO, GVH5178_SERVICE_INFO_ERROR, @@ -153,3 +154,30 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: primary_temp_sensor = hass.states.get("sensor.b51782bc8_primary_temperature") assert primary_temp_sensor.state == STATE_UNAVAILABLE + + +async def test_gvh5106(hass: HomeAssistant) -> None: + """Test setting up creates the sensors for a device with PM25.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="CC:32:37:35:4E:05", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, GVH5106_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + pm25_sensor = hass.states.get("sensor.h5106_4e05_pm25") + pm25_sensor_attributes = pm25_sensor.attributes + assert pm25_sensor.state == "0" + assert pm25_sensor_attributes[ATTR_FRIENDLY_NAME] == "H5106 4E05 Pm25" + assert pm25_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "µg/m³" + assert pm25_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From bc2738c3a1b355411158f8ae197b2473b9f04396 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:04:04 -1000 Subject: [PATCH 0579/1544] Avoid entity registry check in live logbook on each state update (#107622) Avoid entity registry fetch in live logbook There is no need to check the entity registry for the state class since we already have the state --- homeassistant/components/logbook/helpers.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index c2ea9823535..6bfd88c976a 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -171,7 +171,6 @@ def async_subscribe_events( These are the events we need to listen for to do the live logbook stream. """ - ent_reg = er.async_get(hass) assert is_callback(target), "target must be a callback" event_forwarder = event_forwarder_filtered( target, entities_filter, entity_ids, device_ids @@ -193,7 +192,7 @@ def async_subscribe_events( new_state := event.data["new_state"] ) is None: return - if _is_state_filtered(ent_reg, new_state, old_state) or ( + if _is_state_filtered(new_state, old_state) or ( entities_filter and not entities_filter(new_state.entity_id) ): return @@ -232,9 +231,7 @@ def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool: ) -def _is_state_filtered( - ent_reg: er.EntityRegistry, new_state: State, old_state: State -) -> bool: +def _is_state_filtered(new_state: State, old_state: State) -> bool: """Check if the logbook should filter a state. Used when we are in live mode to ensure @@ -245,7 +242,7 @@ def _is_state_filtered( or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS or new_state.last_changed != new_state.last_updated or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes - or is_sensor_continuous(ent_reg, new_state.entity_id) + or ATTR_STATE_CLASS in new_state.attributes ) From 454c62b5b471a9b8542e80f34aaa7bd8bc95b27b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:04:32 -1000 Subject: [PATCH 0580/1544] Avoid total_seconds conversion every state write when context is set (#107617) --- homeassistant/helpers/entity.py | 6 ++---- tests/helpers/test_entity.py | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 706e3136a8a..32aa97ab8fe 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -6,7 +6,6 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping import dataclasses -from datetime import timedelta from enum import Enum, IntFlag, auto import functools as ft import logging @@ -88,7 +87,7 @@ FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) # How many times per hour we allow capabilities to be updated before logging a warning CAPABILITIES_UPDATE_LIMIT = 100 -CONTEXT_RECENT_TIME = timedelta(seconds=5) # Time that a context is considered recent +CONTEXT_RECENT_TIME_SECONDS = 5 # Time that a context is considered recent @callback @@ -1164,8 +1163,7 @@ class Entity( if ( self._context_set is not None - and hass.loop.time() - self._context_set - > CONTEXT_RECENT_TIME.total_seconds() + and hass.loop.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS ): self._context = None self._context_set = None diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 1dc878a8eba..19600506ae2 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -655,9 +655,7 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() - with patch( - "homeassistant.helpers.entity.CONTEXT_RECENT_TIME", timedelta(seconds=-5) - ): + with patch("homeassistant.helpers.entity.CONTEXT_RECENT_TIME_SECONDS", -5): ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" From d8564eba1719df318617f3a26d4c481aedf26bd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:08:42 -1000 Subject: [PATCH 0581/1544] Bump lxml to 5.1.0 (#106696) * Bump lxml to 5.0.0 cython 3.0.7+ is required ? * bump * Apply suggestions from code review * 5.1.0 --- homeassistant/components/scrape/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 4 ---- 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 708ecc14d16..ade210b304a 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.4"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==5.1.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98d5b9a1967..c62a64f8de2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -182,10 +182,6 @@ get-mac==1000000000.0.0 # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 -# lxml 5.0.0 currently does not build on alpine 3.18 -# https://bugs.launchpad.net/lxml/+bug/2047718 -lxml==4.9.4 - # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 diff --git a/requirements_all.txt b/requirements_all.txt index 02afa578fb2..3a30320ae7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1228,7 +1228,7 @@ lupupy==0.3.1 lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.4 +lxml==5.1.0 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74b15ac523c..c70b173ec89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -964,7 +964,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.4 +lxml==5.1.0 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7f652b14302..70d20f7e135 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -175,10 +175,6 @@ get-mac==1000000000.0.0 # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 -# lxml 5.0.0 currently does not build on alpine 3.18 -# https://bugs.launchpad.net/lxml/+bug/2047718 -lxml==4.9.4 - # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 From e4a15354f44f13b8fd7216ee52a8d7ac8f36b701 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:11:34 -1000 Subject: [PATCH 0582/1544] Fix logger creating many thread locks when reloading the integrations page (#93768) * Fix logger creating many thread locks We call getLogger for each integration to get the current log level when loading the integrations page. This creates a storm of threading locks * fixes --- homeassistant/components/logger/helpers.py | 9 +++++++++ homeassistant/components/logger/websocket_api.py | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 87ec2cc8cd5..bf37ab3625b 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import contextlib from dataclasses import asdict, dataclass from enum import StrEnum +from functools import lru_cache import logging from typing import Any, cast @@ -216,3 +217,11 @@ class LoggerSettings: ) return dict(combined_logs) + + +get_logger = lru_cache(maxsize=256)(logging.getLogger) +"""Get a logger. + +getLogger uses a threading.RLock, so we cache the result to avoid +locking the threads every time the integrations page is loaded. +""" diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 89026a07b8a..240db3144af 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -1,5 +1,4 @@ """Websocket API handlers for the logger integration.""" -import logging from typing import Any import voluptuous as vol @@ -16,6 +15,7 @@ from .helpers import ( LogPersistance, LogSettingsType, async_get_domain_config, + get_logger, ) @@ -38,7 +38,7 @@ def handle_integration_log_info( [ { "domain": integration, - "level": logging.getLogger( + "level": get_logger( f"homeassistant.components.{integration}" ).getEffectiveLevel(), } From e7c25d1c363b121d53fc20093b0b74138751acbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 16:17:37 -1000 Subject: [PATCH 0583/1544] Migrate powerwall unique ids to use the gateway din (#107509) --- .../components/powerwall/__init__.py | 28 +++++++- homeassistant/components/powerwall/entity.py | 3 +- homeassistant/components/powerwall/models.py | 2 +- tests/components/powerwall/test_sensor.py | 67 ++++++++++++++++++- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 8fcc56e449f..5cccd54a32a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -151,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err gateway_din = base_info.gateway_din - if gateway_din and entry.unique_id is not None and is_ip_address(entry.unique_id): + if entry.unique_id is not None and is_ip_address(entry.unique_id): hass.config_entries.async_update_entry(entry, unique_id=gateway_din) runtime_data = PowerwallRuntimeData( @@ -178,11 +179,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data + await async_migrate_entity_unique_ids(hass, entry, base_info) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_migrate_entity_unique_ids( + hass: HomeAssistant, entry: ConfigEntry, base_info: PowerwallBaseInfo +) -> None: + """Migrate old entity unique ids to use gateway_din.""" + old_base_unique_id = "_".join(base_info.serial_numbers) + new_base_unique_id = base_info.gateway_din + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device(identifiers={(DOMAIN, old_base_unique_id)}): + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, new_base_unique_id)} + ) + + ent_reg = er.async_get(hass) + for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): + current_unique_id = ent_entry.unique_id + if current_unique_id.startswith(old_base_unique_id): + new_unique_id = f"{new_base_unique_id}{current_unique_id.removeprefix(old_base_unique_id)}" + ent_reg.async_update_entity( + ent_entry.entity_id, new_unique_id=new_unique_id + ) + + async def _login_and_fetch_base_info( power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index f0cfec2cbc5..0ee4249a8e9 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -29,8 +29,7 @@ class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): assert coordinator is not None super().__init__(coordinator) self.power_wall = powerwall_data[POWERWALL_API] - # The serial numbers of the powerwalls are unique to every site - self.base_unique_id = "_".join(base_info.serial_numbers) + self.base_unique_id = base_info.gateway_din self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.base_unique_id)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/models.py index d67a21a0d53..65213065d0e 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/models.py @@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator class PowerwallBaseInfo: """Base information for the powerwall integration.""" - gateway_din: None | str + gateway_din: str site_info: SiteInfoResponse status: PowerwallStatusResponse device_type: DeviceType diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index a58c30f332e..bca17638629 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -16,10 +16,10 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from .mocks import _mock_powerwall_with_fixtures +from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures from tests.common import MockConfigEntry, async_fire_time_changed @@ -44,7 +44,7 @@ async def test_sensors( device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( - identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")}, + identifiers={("powerwall", MOCK_GATEWAY_DIN)}, ) assert reg_device.model == "PowerWall 2 (GW1)" assert reg_device.sw_version == "1.50.1 c58c2df3" @@ -173,3 +173,64 @@ async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.states.get("sensor.mysite_solar_power") is None + + +async def test_unique_id_migrate( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test we can migrate unique ids of the sensors.""" + device_registry = dr.async_get(hass) + ent_reg = er.async_get(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) + config_entry.add_to_hass(hass) + + mock_powerwall = await _mock_powerwall_with_fixtures(hass) + old_unique_id = "_".join(sorted(["TG0123456789AB", "TG9876543210BA"])) + new_unique_id = MOCK_GATEWAY_DIN + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("powerwall", old_unique_id)}, + manufacturer="Tesla", + ) + old_mysite_load_power_entity = ent_reg.async_get_or_create( + "sensor", + DOMAIN, + unique_id=f"{old_unique_id}_load_instant_power", + suggested_object_id="mysite_load_power", + config_entry=config_entry, + ) + assert old_mysite_load_power_entity.entity_id == "sensor.mysite_load_power" + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reg_device = device_registry.async_get_device( + identifiers={("powerwall", MOCK_GATEWAY_DIN)}, + ) + old_reg_device = device_registry.async_get_device( + identifiers={("powerwall", old_unique_id)}, + ) + assert old_reg_device is None + assert reg_device is not None + + assert ( + ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{old_unique_id}_load_instant_power" + ) + is None + ) + assert ( + ent_reg.async_get_entity_id( + "sensor", DOMAIN, f"{new_unique_id}_load_instant_power" + ) + is not None + ) + + state = hass.states.get("sensor.mysite_load_power") + assert state.state == "1.971" From 659ee51914bb712f9b47c4f5b6b37dab7f869d15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 17:17:55 -1000 Subject: [PATCH 0584/1544] Refactor event time trackers to avoid using nonlocal (#107997) --- homeassistant/helpers/event.py | 172 ++++++++++++++++++++------------- 1 file changed, 104 insertions(+), 68 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 02add8ff012..bd1454cf637 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -10,7 +10,7 @@ import functools as ft import logging from random import randint import time -from typing import Any, Concatenate, ParamSpec, TypedDict, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypedDict, TypeVar import attr @@ -1389,6 +1389,45 @@ def async_track_point_in_time( track_point_in_time = threaded_listener_factory(async_track_point_in_time) +@dataclass(slots=True) +class _TrackPointUTCTime: + hass: HomeAssistant + job: HassJob[[datetime], Coroutine[Any, Any, None] | None] + utc_point_in_time: datetime + expected_fire_timestamp: float + _cancel_callback: asyncio.TimerHandle | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + loop = self.hass.loop + self._cancel_callback = loop.call_at( + loop.time() + self.expected_fire_timestamp - time.time(), self._run_action + ) + + @callback + def _run_action(self) -> None: + """Call the action.""" + # Depending on the available clock support (including timer hardware + # and the OS kernel) it can happen that we fire a little bit too early + # as measured by utcnow(). That is bad when callbacks have assumptions + # about the current time. Thus, we rearm the timer for the remaining + # time. + if (delta := (self.expected_fire_timestamp - time_tracker_timestamp())) > 0: + _LOGGER.debug("Called %f seconds too early, rearming", delta) + loop = self.hass.loop + self._cancel_callback = loop.call_at(loop.time() + delta, self._run_action) + return + + self.hass.async_run_hass_job(self.job, self.utc_point_in_time) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback.cancel() + + @callback @bind_hass def async_track_point_in_utc_time( @@ -1404,44 +1443,14 @@ def async_track_point_in_utc_time( # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) - - # Since this is called once, we accept a HassJob so we can avoid - # having to figure out how to call the action every time its called. - cancel_callback: asyncio.TimerHandle | None = None - loop = hass.loop - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - nonlocal cancel_callback - # Depending on the available clock support (including timer hardware - # and the OS kernel) it can happen that we fire a little bit too early - # as measured by utcnow(). That is bad when callbacks have assumptions - # about the current time. Thus, we rearm the timer for the remaining - # time. - if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: - _LOGGER.debug("Called %f seconds too early, rearming", delta) - - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - return - - hass.async_run_hass_job(job, utc_point_in_time) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"track point in utc time {utc_point_in_time}") ) - delta = expected_fire_timestamp - time.time() - cancel_callback = loop.call_at(loop.time() + delta, run_action, job) - - @callback - def unsub_point_in_time_listener() -> None: - """Cancel the call_at.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_point_in_time_listener + track = _TrackPointUTCTime(hass, job, utc_point_in_time, expected_fire_timestamp) + track.async_attach() + return track.async_cancel track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) @@ -1500,6 +1509,61 @@ def async_call_later( call_later = threaded_listener_factory(async_call_later) +@dataclass(slots=True) +class _TrackTimeInterval: + """Helper class to help listen to time interval events.""" + + hass: HomeAssistant + seconds: float + job_name: str + action: Callable[[datetime], Coroutine[Any, Any, None] | None] + cancel_on_shutdown: bool | None + _track_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _run_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None + _cancel_callback: CALLBACK_TYPE | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + hass = self.hass + self._track_job = HassJob( + self._interval_listener, + self.job_name, + job_type=HassJobType.Callback, + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._run_job = HassJob( + self.action, + f"track time interval {self.seconds}", + cancel_on_shutdown=self.cancel_on_shutdown, + ) + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + + @callback + def _interval_listener(self, now: datetime) -> None: + """Handle elapsed intervals.""" + if TYPE_CHECKING: + assert self._run_job is not None + assert self._track_job is not None + hass = self.hass + self._cancel_callback = async_call_at( + hass, + self._track_job, + hass.loop.time() + self.seconds, + ) + hass.async_run_hass_job(self._run_job, now) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback() + + @callback @bind_hass def async_track_time_interval( @@ -1514,41 +1578,13 @@ def async_track_time_interval( The listener is passed the time it fires in UTC time. """ - remove: CALLBACK_TYPE - interval_listener_job: HassJob[[datetime], None] - interval_seconds = interval.total_seconds() - - job = HassJob( - action, f"track time interval {interval}", cancel_on_shutdown=cancel_on_shutdown - ) - - @callback - def interval_listener(now: datetime) -> None: - """Handle elapsed intervals.""" - nonlocal remove - nonlocal interval_listener_job - - remove = async_call_later(hass, interval_seconds, interval_listener_job) - hass.async_run_hass_job(job, now) - + seconds = interval.total_seconds() + job_name = f"track time interval {seconds} {action}" if name: - job_name = f"{name}: track time interval {interval} {action}" - else: - job_name = f"track time interval {interval} {action}" - - interval_listener_job = HassJob( - interval_listener, - job_name, - cancel_on_shutdown=cancel_on_shutdown, - job_type=HassJobType.Callback, - ) - remove = async_call_later(hass, interval_seconds, interval_listener_job) - - def remove_listener() -> None: - """Remove interval listener.""" - remove() - - return remove_listener + job_name = f"{name}: {job_name}" + track = _TrackTimeInterval(hass, seconds, job_name, action, cancel_on_shutdown) + track.async_attach() + return track.async_cancel track_time_interval = threaded_listener_factory(async_track_time_interval) From 9033f1f3e836c663ff8535ce5bded94272147db7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 17:22:17 -1000 Subject: [PATCH 0585/1544] Break long lines in powerwall integration (#108002) --- homeassistant/components/powerwall/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 5cccd54a32a..29e890e6027 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -203,7 +203,8 @@ async def async_migrate_entity_unique_ids( for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id): current_unique_id = ent_entry.unique_id if current_unique_id.startswith(old_base_unique_id): - new_unique_id = f"{new_base_unique_id}{current_unique_id.removeprefix(old_base_unique_id)}" + unique_id_postfix = current_unique_id.removeprefix(old_base_unique_id) + new_unique_id = f"{new_base_unique_id}{unique_id_postfix}" ent_reg.async_update_entity( ent_entry.entity_id, new_unique_id=new_unique_id ) From 8d3f693907fa24a54544e3fd7e017c9ec5c2a652 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 18:40:07 -1000 Subject: [PATCH 0586/1544] Avoid useless time fetch in DataUpdateCoordinator (#107999) * Avoid useless time fetch in DataUpdateCoordinator Since we used the async_call_at helper, it would always call dt_util.utcnow() to feed the _handle_refresh_interval which threw it away. This meant we had to fetch time twice as much as needed for each update * tweak * compat * adjust comment --- homeassistant/helpers/update_coordinator.py | 35 ++++++++++++++------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 606b90e6005..d8631398db7 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,6 +84,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.logger = logger self.name = name self.update_method = update_method + self._update_interval_seconds: float | None = None self.update_interval = update_interval self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() @@ -212,10 +213,21 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._unsub_shutdown() self._unsub_shutdown = None + @property + def update_interval(self) -> timedelta | None: + """Interval between updates.""" + return self._update_interval + + @update_interval.setter + def update_interval(self, value: timedelta | None) -> None: + """Set interval between updates.""" + self._update_interval = value + self._update_interval_seconds = value.total_seconds() if value else None + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" - if self.update_interval is None: + if self._update_interval_seconds is None: return if self.config_entry and self.config_entry.pref_disable_polling: @@ -225,19 +237,20 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # than the debouncer cooldown, this would cause the debounce to never be called self._async_unsub_refresh() - # We use event.async_call_at because DataUpdateCoordinator does - # not need an exact update interval. - now = self.hass.loop.time() + # We use loop.call_at because DataUpdateCoordinator does + # not need an exact update interval which also avoids + # calling dt_util.utcnow() on every update. + hass = self.hass + loop = hass.loop - next_refresh = int(now) + self._microsecond - next_refresh += self.update_interval.total_seconds() - self._unsub_refresh = event.async_call_at( - self.hass, - self._job, - next_refresh, + next_refresh = ( + int(loop.time()) + self._microsecond + self._update_interval_seconds ) + self._unsub_refresh = loop.call_at( + next_refresh, hass.async_run_hass_job, self._job + ).cancel - async def _handle_refresh_interval(self, _now: datetime) -> None: + async def _handle_refresh_interval(self, _now: datetime | None = None) -> None: """Handle a refresh interval occurrence.""" self._unsub_refresh = None await self._async_refresh(log_failures=True, scheduled=True) From da9fc77333fcf8c080f33dbf88b0c59d0af7b15d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 18:50:21 -1000 Subject: [PATCH 0587/1544] Save the HassJob type in wemo discovery to avoid checking it each time (#107998) --- homeassistant/components/wemo/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 3f7cbe4cf45..2f4d4c84c5c 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType @@ -252,6 +252,7 @@ class WemoDiscovery: self._stop: CALLBACK_TYPE | None = None self._scan_delay = 0 self._static_config = static_config + self._discover_job: HassJob[[datetime], Coroutine[Any, Any, None]] | None = None async def async_discover_and_schedule( self, event_time: datetime | None = None @@ -271,10 +272,12 @@ class WemoDiscovery: self._scan_delay + self.ADDITIONAL_SECONDS_BETWEEN_SCANS, self.MAX_SECONDS_BETWEEN_SCANS, ) + if not self._discover_job: + self._discover_job = HassJob(self.async_discover_and_schedule) self._stop = async_call_later( self._hass, self._scan_delay, - self.async_discover_and_schedule, + self._discover_job, ) @callback From 07810926d0cc59e23e52858d3b9d1a90118d8af2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 18:50:47 -1000 Subject: [PATCH 0588/1544] Update habluetooth to 2.2.0 (#108000) * Update habluetooth to 2.2.0 * fixes * lib --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/__init__.py | 16 +++++++++++++++- tests/components/bluetooth/test_api.py | 3 +-- tests/components/bluetooth/test_diagnostics.py | 5 +++-- tests/components/bluetooth/test_models.py | 13 +++++++------ 8 files changed, 30 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1551a83ad6a..9dfa4b84bb8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.1.0" + "habluetooth==2.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c62a64f8de2..59e003c1263 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.1.0 +habluetooth==2.2.0 hass-nabucasa==0.75.1 hassil==1.5.2 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3a30320ae7a..3858efa640a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.1.0 +habluetooth==2.2.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c70b173ec89..ded65914e01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -806,7 +806,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.1.0 +habluetooth==2.2.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 5ad4b5a6c31..f4616abf8e5 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,6 +1,7 @@ """Tests for the Bluetooth integration.""" +from collections.abc import Iterable from contextlib import contextmanager import itertools import time @@ -295,7 +296,20 @@ class MockBleakClient(BleakClient): return True -class FakeScanner(BaseHaScanner): +class FakeScannerMixin: + def get_discovered_device_advertisement_data( + self, address: str + ) -> tuple[BLEDevice, AdvertisementData] | None: + """Return the advertisement data for a discovered device.""" + return self.discovered_devices_and_advertisement_data.get(address) + + @property + def discovered_addresses(self) -> Iterable[str]: + """Return an iterable of discovered devices.""" + return self.discovered_devices_and_advertisement_data + + +class FakeScanner(FakeScannerMixin, BaseHaScanner): """Fake scanner.""" @property diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index a42752dcfc7..bc65874b0cc 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -8,7 +8,6 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, - BaseHaScanner, HaBluetoothConnector, async_scanner_by_source, async_scanner_devices_by_address, @@ -124,7 +123,7 @@ async def test_async_scanner_devices_by_address_non_connectable( rssi=-100, ) - class FakeStaticScanner(BaseHaScanner): + class FakeStaticScanner(FakeScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index a8e693c3f99..debecb0ac80 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,17 +3,18 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS -from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, HaBluetoothConnector, + HaScanner, ) from homeassistant.core import HomeAssistant from . import ( + FakeScannerMixin, MockBleakClient, _get_manager, generate_advertisement_data, @@ -26,7 +27,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -class FakeHaScanner(HaScanner): +class FakeHaScanner(FakeScannerMixin, HaScanner): """Fake HaScanner.""" @property diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 9b513ed2197..680d7c2e798 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.core import HomeAssistant from . import ( + FakeScannerMixin, MockBleakClient, _get_manager, generate_advertisement_data, @@ -79,7 +80,7 @@ async def test_wrapped_bleak_client_local_adapter_only( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaScanner): + class FakeScanner(FakeScannerMixin, BaseHaScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -155,7 +156,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -266,7 +267,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -331,7 +332,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -434,7 +435,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -546,7 +547,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" From 8b4d99f7d2aac27b07bb759b204adff47803cc96 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 14 Jan 2024 17:08:54 +1000 Subject: [PATCH 0589/1544] Add route sensors to Tessie (#106530) * keys may be missing at startup * Add route sensors and tracker location * Fix keys and add translation * Allow a sensor to have no value * Move attribute to sensor * Remove state attribute string --- homeassistant/components/tessie/sensor.py | 34 +++++++++++++++++-- homeassistant/components/tessie/strings.json | 15 ++++++++ .../components/tessie/fixtures/vehicles.json | 4 +++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index aaf37e51d61..7ed1b0416e3 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -181,6 +182,36 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( suggested_display_precision=1, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieSensorEntityDescription( + key="drive_state_active_route_traffic_minutes_delay", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_energy_at_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_miles_to_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_minutes_to_arrival", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + TessieSensorEntityDescription( + key="drive_state_active_route_destination", + icon="mdi:map-marker", + entity_category=EntityCategory.DIAGNOSTIC, + ), ) @@ -194,7 +225,6 @@ async def async_setup_entry( TessieSensorEntity(vehicle.state_coordinator, description) for vehicle in data for description in DESCRIPTIONS - if description.key in vehicle.state_coordinator.data ) @@ -215,4 +245,4 @@ class TessieSensorEntity(TessieEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self._value) + return self.entity_description.value_fn(self.get()) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index c2483b1be8c..be5be229c81 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -129,6 +129,21 @@ }, "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" + }, + "active_route_traffic_minutes_delay": { + "name": "Traffic delay" + }, + "active_route_energy_at_arrival": { + "name": "State of charge at arrival" + }, + "active_route_miles_to_arrival": { + "name": "Distance to arrival" + }, + "active_route_time_to_arrival": { + "name": "Time to arrival" + }, + "drive_state_active_route_destination": { + "name": "Destination" } }, "cover": { diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index e150b9e60e7..c1b0851eee6 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -127,6 +127,10 @@ "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_traffic_minutes_delay": 0, + "active_route_destination": "Giga Texas", + "active_route_energy_at_arrival": 65, + "active_route_miles_to_arrival": 46.707353, + "active_route_minutes_to_arrival": 59.2, "gps_as_of": 1701129612, "heading": 185, "latitude": -30.222626, From f48d057307c2b409cad3352348dd25e6f7b6b58a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 14 Jan 2024 09:03:00 +0100 Subject: [PATCH 0590/1544] Remove YAML support from gdacs (#107962) --- homeassistant/components/gdacs/__init__.py | 56 +------------------ homeassistant/components/gdacs/config_flow.py | 43 +------------- tests/components/gdacs/conftest.py | 2 +- tests/components/gdacs/test_config_flow.py | 54 +----------------- tests/components/gdacs/test_geo_location.py | 34 +++++++---- tests/components/gdacs/test_sensor.py | 28 ++++++++-- 6 files changed, 52 insertions(+), 165 deletions(-) diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 557af9474ed..1530e4712d8 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -3,9 +3,8 @@ from datetime import timedelta import logging from aio_georss_gdacs import GdacsFeedManager -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -14,71 +13,22 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( +from .const import ( # noqa: F401 CONF_CATEGORIES, - DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED, PLATFORMS, - VALID_CATEGORIES, ) _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_CATEGORIES, default=[]): vol.All( - cv.ensure_list, [vol.In(VALID_CATEGORIES)] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the GDACS component.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - latitude = conf.get(CONF_LATITUDE, hass.config.latitude) - longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) - scan_interval = conf[CONF_SCAN_INTERVAL] - categories = conf[CONF_CATEGORIES] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_RADIUS: conf[CONF_RADIUS], - CONF_SCAN_INTERVAL: scan_interval, - CONF_CATEGORIES: categories, - }, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the GDACS component as config entry.""" diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py index fb2b8416937..b59b3bcc775 100644 --- a/homeassistant/components/gdacs/config_flow.py +++ b/homeassistant/components/gdacs/config_flow.py @@ -10,10 +10,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_CATEGORIES, DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -33,26 +30,6 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - result = await self.async_step_user(import_config) - if result["type"] == FlowResultType.CREATE_ENTRY: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - return result - async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" _LOGGER.debug("User input: %s", user_input) @@ -67,25 +44,7 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) - try: - self._abort_if_unique_id_configured() - except AbortFlow: - if self.context["source"] == config_entries.SOURCE_IMPORT: - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - raise + self._abort_if_unique_id_configured() scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.total_seconds() diff --git a/tests/components/gdacs/conftest.py b/tests/components/gdacs/conftest.py index 47185cf5387..ee82a3131b1 100644 --- a/tests/components/gdacs/conftest.py +++ b/tests/components/gdacs/conftest.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def config_entry(): +def config_entry() -> MockConfigEntry: """Create a mock GDACS config entry.""" return MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index f8dfa0cd7fd..ad673815ace 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -1,5 +1,4 @@ """Define tests for the GDACS config flow.""" -from datetime import timedelta from unittest.mock import patch import pytest @@ -12,8 +11,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.core import HomeAssistant @pytest.fixture(name="gdacs_setup", autouse=True) @@ -44,56 +42,6 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_step_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - conf = { - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_SCAN_INTERVAL: timedelta(minutes=4), - CONF_CATEGORIES: ["Drought", "Earthquake"], - } - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "-41.2, 174.7" - assert result["data"] == { - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_SCAN_INTERVAL: 240.0, - CONF_CATEGORIES: ["Drought", "Earthquake"], - } - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_step_import_already_exist( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" hass.config.latitude = -41.2 diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index dfdce7635df..9fd8c5c0134 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -4,7 +4,6 @@ from unittest.mock import patch from freezegun import freeze_time -from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED from homeassistant.components.gdacs.geo_location import ( ATTR_ALERT_LEVEL, @@ -33,18 +32,21 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed -CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} +CONFIG = {CONF_RADIUS: 200} -async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -94,8 +96,12 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> "aio_georss_client.feed.GeoRssFeed.update" ) as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] - assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG) - await hass.async_block_till_done() + + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | CONFIG + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() @@ -202,7 +208,9 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> assert len(entity_registry.entities) == 1 -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. @@ -224,9 +232,13 @@ async def test_setup_imperial(hass: HomeAssistant) -> None: "aio_georss_client.feed.GeoRssFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] - assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG) - await hass.async_block_till_done() - # Artificially trigger update and collect events. + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | CONFIG + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup( + config_entry.entry_id + ) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 670d3efce51..9e585de41dd 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -5,6 +5,7 @@ from freezegun import freeze_time from homeassistant.components import gdacs from homeassistant.components.gdacs import DEFAULT_SCAN_INTERVAL +from homeassistant.components.gdacs.const import CONF_CATEGORIES from homeassistant.components.gdacs.sensor import ( ATTR_CREATED, ATTR_LAST_UPDATE, @@ -16,18 +17,18 @@ from homeassistant.components.gdacs.sensor import ( from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import _generate_mock_feed_entry -from tests.common import async_fire_time_changed - -CONFIG = {gdacs.DOMAIN: {CONF_RADIUS: 200}} +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup(hass: HomeAssistant) -> None: @@ -60,7 +61,24 @@ async def test_setup(hass: HomeAssistant) -> None: "aio_georss_client.feed.GeoRssFeed.update" ) as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] - assert await async_setup_component(hass, gdacs.DOMAIN, CONFIG) + latitude = 32.87336 + longitude = -117.22743 + radius = 200 + entry_data = { + CONF_RADIUS: radius, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_CATEGORIES: [], + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL.seconds, + } + config_entry = MockConfigEntry( + domain=gdacs.DOMAIN, + title=f"{latitude}, {longitude}", + data=entry_data, + unique_id="my_very_unique_id", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) # Artificially trigger update and collect events. hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() From 4b8d8baa693a0c1763d36cda137c6792e24f4f44 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 14 Jan 2024 09:36:00 +0100 Subject: [PATCH 0591/1544] Remove deprecated YAML import from generic camera (#107992) --- homeassistant/components/generic/camera.py | 65 +--- .../components/generic/config_flow.py | 44 +-- tests/components/generic/test_camera.py | 291 ++++++++---------- tests/components/generic/test_config_flow.py | 33 +- 4 files changed, 132 insertions(+), 301 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index f4c02a2ab9f..171497f479b 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -8,28 +8,20 @@ import logging from typing import Any import httpx -import voluptuous as vol import yarl -from homeassistant.components.camera import ( - DEFAULT_CONTENT_TYPE, - PLATFORM_SCHEMA, - Camera, - CameraEntityFeature, -) +from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, - RTSP_TRANSPORTS, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant @@ -38,7 +30,6 @@ from homeassistant.helpers import config_validation as cv, template as template_ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .const import ( @@ -47,64 +38,12 @@ from .const import ( CONF_LIMIT_REFETCH_TO_URL_CHANGE, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, - DEFAULT_NAME, GET_IMAGE_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, - vol.Optional(vol.Any(CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE)): cv.template, - vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, - vol.Optional(CONF_FRAMERATE, default=2): vol.Any( - cv.small_float, cv.positive_int - ), - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up a generic IP Camera.""" - - image = config.get(CONF_STILL_IMAGE_URL) - stream = config.get(CONF_STREAM_SOURCE) - config_new = { - CONF_NAME: config[CONF_NAME], - CONF_STILL_IMAGE_URL: image.template if image is not None else None, - CONF_STREAM_SOURCE: stream.template if stream is not None else None, - CONF_AUTHENTICATION: config.get(CONF_AUTHENTICATION), - CONF_USERNAME: config.get(CONF_USERNAME), - CONF_PASSWORD: config.get(CONF_PASSWORD), - CONF_LIMIT_REFETCH_TO_URL_CHANGE: config.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE), - CONF_CONTENT_TYPE: config.get(CONF_CONTENT_TYPE), - CONF_FRAMERATE: config.get(CONF_FRAMERATE), - CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL), - } - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config_new - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 67ff5a84ed9..af3ff414ac5 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -40,12 +40,11 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -379,47 +378,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=None, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle config import from yaml.""" - - _LOGGER.warning( - "Loading generic IP camera via configuration.yaml is deprecated, " - "it will be automatically imported. Once you have confirmed correct " - "operation, please remove 'generic' (IP camera) section(s) from " - "configuration.yaml" - ) - - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Generic IP Camera", - }, - ) - # abort if we've already got this one. - if self.check_for_existing(import_config): - return self.async_abort(reason="already_exists") - # Don't bother testing the still or stream details on yaml import. - still_url = import_config.get(CONF_STILL_IMAGE_URL) - stream_url = import_config.get(CONF_STREAM_SOURCE) - name = import_config.get( - CONF_NAME, - slug(self.hass, still_url) or slug(self.hass, stream_url) or DEFAULT_NAME, - ) - - if CONF_LIMIT_REFETCH_TO_URL_CHANGE not in import_config: - import_config[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - still_format = import_config.get(CONF_CONTENT_TYPE, "image/jpeg") - import_config[CONF_CONTENT_TYPE] = still_format - return self.async_create_entry(title=name, data={}, options=import_config) - class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 70746f70c9a..5a4bae22e9f 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -11,6 +12,7 @@ import pytest import respx from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, async_get_mjpeg_stream, async_get_stream_source, ) @@ -24,8 +26,13 @@ from homeassistant.components.generic.const import ( ) from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -33,6 +40,34 @@ from tests.common import Mock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +async def help_setup_mock_config_entry( + hass: HomeAssistant, options: dict[str, Any], unique_id: Any | None = None +) -> MockConfigEntry: + """Help setting up a generic camera config entry.""" + entry_options = { + CONF_STILL_IMAGE_URL: options.get(CONF_STILL_IMAGE_URL), + CONF_STREAM_SOURCE: options.get(CONF_STREAM_SOURCE), + CONF_AUTHENTICATION: options.get(CONF_AUTHENTICATION), + CONF_USERNAME: options.get(CONF_USERNAME), + CONF_PASSWORD: options.get(CONF_PASSWORD), + CONF_LIMIT_REFETCH_TO_URL_CHANGE: options.get( + CONF_LIMIT_REFETCH_TO_URL_CHANGE, False + ), + CONF_CONTENT_TYPE: options.get(CONF_CONTENT_TYPE, DEFAULT_CONTENT_TYPE), + CONF_FRAMERATE: options.get(CONF_FRAMERATE, 2), + CONF_VERIFY_SSL: options.get(CONF_VERIFY_SSL), + } + entry = MockConfigEntry( + domain="generic", + title=options[CONF_NAME], + options=entry_options, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + return entry + + @respx.mock async def test_fetching_url( hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png @@ -40,22 +75,16 @@ async def test_fetching_url( """Test that it fetches the given url.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "authentication": "basic", - "framerate": 20, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": 20, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -84,22 +113,16 @@ async def test_image_caching( respx.get("http://example.com").respond(stream=fakeimgbytes_png) framerate = 5 - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "authentication": "basic", - "framerate": framerate, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -154,21 +177,15 @@ async def test_fetching_without_verify_ssl( """Test that it fetches the given url when ssl verify is off.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "username": "user", - "password": "pass", - "verify_ssl": "false", - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "username": "user", + "password": "pass", + "verify_ssl": "false", + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -184,21 +201,15 @@ async def test_fetching_url_with_verify_ssl( """Test that it fetches the given url when ssl verify is explicitly on.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "username": "user", - "password": "pass", - "verify_ssl": "true", - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "username": "user", + "password": "pass", + "verify_ssl": True, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -223,19 +234,13 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "0") - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -350,20 +355,15 @@ async def test_stream_source_error( """Test that the stream source has an error.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - # Does not exist - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, - }, - ) + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + # Does not exist + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) assert await async_setup_component(hass, "stream", {}) await hass.async_block_till_done() @@ -397,23 +397,17 @@ async def test_setup_alternative_options( """Test that the stream source is setup with different config options.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "authentication": "digest", - "username": "user", - "password": "pass", - "stream_source": "rtsp://example.com:554/rtsp/", - "rtsp_transport": "udp", - }, - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "authentication": "digest", + "username": "user", + "password": "pass", + "stream_source": "rtsp://example.com:554/rtsp/", + "rtsp_transport": "udp", + } + await help_setup_mock_config_entry(hass, options) assert hass.states.get("camera.config_test") @@ -427,19 +421,13 @@ async def test_no_stream_source( """Test a stream request without stream source option set.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "limit_refetch_to_url_change": True, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "limit_refetch_to_url_change": True, + } + await help_setup_mock_config_entry(hass, options) with patch( "homeassistant.components.camera.Stream.endpoint_url", @@ -494,22 +482,9 @@ async def test_camera_content_type( "framerate": 2, "verify_ssl": True, } + await help_setup_mock_config_entry(hass, cam_config_jpg, unique_id=12345) + await help_setup_mock_config_entry(hass, cam_config_svg, unique_id=54321) - result1 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_jpg, - context={"source": SOURCE_IMPORT, "unique_id": 12345}, - ) - await hass.async_block_till_done() - result2 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_svg, - context={"source": SOURCE_IMPORT, "unique_id": 54321}, - ) - await hass.async_block_till_done() - - assert result1["type"] == "create_entry" - assert result2["type"] == "create_entry" client = await hass_client() resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg") @@ -538,21 +513,15 @@ async def test_timeout_cancelled( respx.get("http://example.com").respond(stream=fakeimgbytes_png) - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "framerate": 20, - } - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "framerate": 20, + } + await help_setup_mock_config_entry(hass, options) client = await hass_client() @@ -589,19 +558,13 @@ async def test_timeout_cancelled( async def test_frame_interval_property(hass: HomeAssistant) -> None: """Test that the frame interval is calculated and returned correctly.""" - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - "framerate": 5, - }, - }, - ) - await hass.async_block_till_done() + options = { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + "framerate": 5, + } + await help_setup_mock_config_entry(hass, options) request = Mock() with patch( diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index c4d11d4af22..86bd552bcf3 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -34,9 +34,9 @@ from homeassistant.const import ( CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -756,35 +756,6 @@ async def test_options_only_stream( assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" -# These below can be deleted after deprecation period is finished. -@respx.mock -async def test_import(hass: HomeAssistant, fakeimg_png) -> None: - """Test configuration.yaml import used during migration.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - # duplicate import should be aborted - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Yaml Defined Name" - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_generic" - ) - assert issue.translation_key == "deprecated_yaml" - - # Any name defined in yaml should end up as the entity id. - assert hass.states.get("camera.yaml_defined_name") - assert result2["type"] == FlowResultType.ABORT - - -# These above can be deleted after deprecation period is finished. - - async def test_unload_entry(hass: HomeAssistant, fakeimg_png) -> None: """Test unloading the generic IP Camera entry.""" mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) From 93d363ea577beafe497b5d4152baa25f6a491b8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 22:37:04 -1000 Subject: [PATCH 0592/1544] Improve apple_tv typing (#107694) --- homeassistant/components/apple_tv/__init__.py | 51 +++++++++++-------- .../components/apple_tv/media_player.py | 20 +++++--- homeassistant/components/apple_tv/remote.py | 2 +- 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 818d27bcf77..8f52db13cfa 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -2,10 +2,13 @@ import asyncio import logging from random import randrange +from typing import TYPE_CHECKING, cast from pyatv import connect, exceptions, scan +from pyatv.conf import AppleTV from pyatv.const import DeviceModel, Protocol from pyatv.convert import model_str +from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry @@ -92,10 +95,14 @@ class AppleTVEntity(Entity): _attr_has_entity_name = True _attr_name = None - def __init__(self, name, identifier, manager): + def __init__( + self, name: str, identifier: str | None, manager: "AppleTVManager" + ) -> None: """Initialize device.""" - self.atv = None + self.atv: AppleTVInterface = None # type: ignore[assignment] self.manager = manager + if TYPE_CHECKING: + assert identifier is not None self._attr_unique_id = identifier self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, @@ -143,7 +150,7 @@ class AppleTVEntity(Entity): """Handle when connection was lost to device.""" -class AppleTVManager: +class AppleTVManager(DeviceListener): """Connection and power manager for an Apple TV. An instance is used per device to share the same power state between @@ -151,11 +158,11 @@ class AppleTVManager: in case of problems. """ - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize power manager.""" self.config_entry = config_entry self.hass = hass - self.atv = None + self.atv: AppleTVInterface | None = None self.is_on = not config_entry.options.get(CONF_START_OFF, False) self._connection_attempts = 0 self._connection_was_lost = False @@ -220,7 +227,7 @@ class AppleTVManager: "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) - async def connect_once(self, raise_missing_credentials): + async def connect_once(self, raise_missing_credentials: bool) -> None: """Try to connect once.""" try: if conf := await self._scan(): @@ -264,49 +271,51 @@ class AppleTVManager: _LOGGER.debug("Connect loop ended") self._task = None - async def _scan(self): + async def _scan(self) -> AppleTV | None: """Try to find device by scanning for it.""" - identifiers = set( - self.config_entry.data.get(CONF_IDENTIFIERS, [self.config_entry.unique_id]) + config_entry = self.config_entry + identifiers: set[str] = set( + config_entry.data.get(CONF_IDENTIFIERS, [config_entry.unique_id]) ) - address = self.config_entry.data[CONF_ADDRESS] + address: str = config_entry.data[CONF_ADDRESS] + hass = self.hass # Only scan for and set up protocols that was successfully paired protocols = { - Protocol(int(protocol)) - for protocol in self.config_entry.data[CONF_CREDENTIALS] + Protocol(int(protocol)) for protocol in config_entry.data[CONF_CREDENTIALS] } - _LOGGER.debug("Discovering device %s", self.config_entry.title) - aiozc = await zeroconf.async_get_async_instance(self.hass) + _LOGGER.debug("Discovering device %s", config_entry.title) + aiozc = await zeroconf.async_get_async_instance(hass) atvs = await scan( - self.hass.loop, + hass.loop, identifier=identifiers, protocol=protocols, hosts=[address], aiozc=aiozc, ) if atvs: - return atvs[0] + return cast(AppleTV, atvs[0]) _LOGGER.debug( "Failed to find device %s with address %s", - self.config_entry.title, + config_entry.title, address, ) # We no longer multicast scan for the device since as soon as async_step_zeroconf runs, # it will update the address and reload the config entry when the device is found. return None - async def _connect(self, conf, raise_missing_credentials): + async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None: """Connect to device.""" - credentials = self.config_entry.data[CONF_CREDENTIALS] - name = self.config_entry.data[CONF_NAME] + config_entry = self.config_entry + credentials: dict[int, str | None] = config_entry.data[CONF_CREDENTIALS] + name: str = config_entry.data[CONF_NAME] missing_protocols = [] for protocol_int, creds in credentials.items(): protocol = Protocol(int(protocol_int)) if conf.get_service(protocol) is not None: - conf.set_credentials(protocol, creds) + conf.set_credentials(protocol, creds) # type: ignore[arg-type] else: missing_protocols.append(protocol.name) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index dd1f554919e..e8fd9d5acfc 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -154,9 +154,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): _LOGGER.exception("Failed to update app list") else: self._app_list = { - app.name: app.identifier - for app in sorted(apps, key=lambda app: app.name.lower()) - if app.name is not None + app_name: app.identifier + for app in sorted(apps, key=lambda app: app_name.lower()) + if (app_name := app.name) is not None } self.async_write_ha_state() @@ -214,15 +214,19 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def app_id(self) -> str | None: """ID of the current running app.""" - if self._is_feature_available(FeatureName.App): - return self.atv.metadata.app.identifier + if self._is_feature_available(FeatureName.App) and ( + app := self.atv.metadata.app + ): + return app.identifier return None @property def app_name(self) -> str | None: """Name of the current running app.""" - if self._is_feature_available(FeatureName.App): - return self.atv.metadata.app.name + if self._is_feature_available(FeatureName.App) and ( + app := self.atv.metadata.app + ): + return app.name return None @property @@ -479,7 +483,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_media_seek(self, position: float) -> None: """Send seek command.""" if self.atv: - await self.atv.remote_control.set_position(position) + await self.atv.remote_control.set_position(round(position)) async def async_volume_up(self) -> None: """Turn volume up for media player.""" diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index bab3421c58d..24d2ef68ed4 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -81,5 +81,5 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() + await attr_value() # type: ignore[operator] await asyncio.sleep(delay) From d4cb055d75126ab9db134164be6f51cdef528135 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 09:37:54 +0100 Subject: [PATCH 0593/1544] Improve calls to async_show_progress in improv_ble (#107790) --- .../components/improv_ble/config_flow.py | 32 ++++++++----------- .../components/improv_ble/test_config_flow.py | 20 +++--------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 762f37ef5d4..6f940f91946 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging from typing import Any, TypeVar @@ -325,14 +325,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return if not self._provision_task: - self._provision_task = self.hass.async_create_task( - self._resume_flow_when_done(_do_provision()) - ) + self._provision_task = self.hass.async_create_task(_do_provision()) + + if not self._provision_task.done(): return self.async_show_progress( - step_id="do_provision", progress_action="provisioning" + step_id="do_provision", + progress_action="provisioning", + progress_task=self._provision_task, ) - await self._provision_task self._provision_task = None return self.async_show_progress_done(next_step_id="provision_done") @@ -347,14 +348,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._provision_result = None return result - async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: - try: - await awaitable - finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) - async def async_step_authorize( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -378,14 +371,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except AbortFlow as err: return self.async_abort(reason=err.reason) - self._authorize_task = self.hass.async_create_task( - self._resume_flow_when_done(authorized_event.wait()) - ) + self._authorize_task = self.hass.async_create_task(authorized_event.wait()) + + if not self._authorize_task.done(): return self.async_show_progress( - step_id="authorize", progress_action="authorize" + step_id="authorize", + progress_action="authorize", + progress_task=self._authorize_task, ) - await self._authorize_task self._authorize_task = None if self._unsub: self._unsub() diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index e333071b0bd..d5e5e0c33ee 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -265,10 +265,7 @@ async def _test_common_success( assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("description_placeholders") == placeholders @@ -321,10 +318,7 @@ async def _test_common_success_w_authorize( assert result["progress_action"] == "authorize" assert result["step_id"] == "authorize" mock_subscribe_state_updates.assert_awaited_once() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision" + await hass.async_block_till_done() with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", @@ -337,10 +331,7 @@ async def _test_common_success_w_authorize( assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["description_placeholders"] == {"url": "http://blabla.local"} @@ -578,10 +569,7 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> None: assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "provisioning" assert result["step_id"] == "do_provision" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "provision_done" + await hass.async_block_till_done() return result["flow_id"] From 88d7fc87c993e3c49b2d204bf79a69f57a5308a1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 14 Jan 2024 09:38:53 +0100 Subject: [PATCH 0594/1544] Enable strict typing for shopping_list (#107913) --- .strict-typing | 1 + .../components/shopping_list/__init__.py | 57 ++++++++++++------- .../components/shopping_list/intent.py | 7 ++- mypy.ini | 10 ++++ 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4b0d03ef095..3e524e48345 100644 --- a/.strict-typing +++ b/.strict-typing @@ -362,6 +362,7 @@ homeassistant.components.sensor.* homeassistant.components.senz.* homeassistant.components.sfr_box.* homeassistant.components.shelly.* +homeassistant.components.shopping_list.* homeassistant.components.simplepush.* homeassistant.components.simplisafe.* homeassistant.components.siren.* diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index e2f04b5d880..e030f15d26e 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,10 +1,13 @@ """Support to manage a shopping list.""" +from __future__ import annotations + from collections.abc import Callable from http import HTTPStatus import logging from typing import Any, cast import uuid +from aiohttp import web import voluptuous as vol from homeassistant import config_entries @@ -12,7 +15,7 @@ from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -197,9 +200,15 @@ class ShoppingData: self.items: list[dict[str, JsonValueType]] = [] self._listeners: list[Callable[[], None]] = [] - async def async_add(self, name, complete=False, context=None): + async def async_add( + self, name: str | None, complete: bool = False, context: Context | None = None + ) -> dict[str, JsonValueType]: """Add a shopping list item.""" - item = {"name": name, "id": uuid.uuid4().hex, "complete": complete} + item: dict[str, JsonValueType] = { + "name": name, + "id": uuid.uuid4().hex, + "complete": complete, + } self.items.append(item) await self.hass.async_add_executor_job(self.save) self._async_notify() @@ -211,7 +220,7 @@ class ShoppingData: return item async def async_remove( - self, item_id: str, context=None + self, item_id: str, context: Context | None = None ) -> dict[str, JsonValueType] | None: """Remove a shopping list item.""" removed = await self.async_remove_items( @@ -220,7 +229,7 @@ class ShoppingData: return next(iter(removed), None) async def async_remove_items( - self, item_ids: set[str], context=None + self, item_ids: set[str], context: Context | None = None ) -> list[dict[str, JsonValueType]]: """Remove a shopping list item.""" items_dict: dict[str, dict[str, JsonValueType]] = {} @@ -248,7 +257,9 @@ class ShoppingData: ) return removed - async def async_update(self, item_id, info, context=None): + async def async_update( + self, item_id: str | None, info: dict[str, Any], context: Context | None = None + ) -> dict[str, JsonValueType]: """Update a shopping list item.""" item = next((itm for itm in self.items if itm["id"] == item_id), None) @@ -266,7 +277,7 @@ class ShoppingData: ) return item - async def async_clear_completed(self, context=None): + async def async_clear_completed(self, context: Context | None = None) -> None: """Clear completed items.""" self.items = [itm for itm in self.items if not itm["complete"]] await self.hass.async_add_executor_job(self.save) @@ -277,7 +288,9 @@ class ShoppingData: context=context, ) - async def async_update_list(self, info, context=None): + async def async_update_list( + self, info: dict[str, JsonValueType], context: Context | None = None + ) -> list[dict[str, JsonValueType]]: """Update all items in the list.""" for item in self.items: item.update(info) @@ -291,7 +304,9 @@ class ShoppingData: return self.items @callback - def async_reorder(self, item_ids, context=None): + def async_reorder( + self, item_ids: list[str], context: Context | None = None + ) -> None: """Reorder items.""" # The array for sorted items. new_items = [] @@ -346,9 +361,11 @@ class ShoppingData: {"action": "reorder"}, ) - async def async_sort(self, reverse=False, context=None): + async def async_sort( + self, reverse: bool = False, context: Context | None = None + ) -> None: """Sort items by name.""" - self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) + self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value] self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( @@ -376,7 +393,7 @@ class ShoppingData: def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: """Add a listener to notify when data is updated.""" - def unsub(): + def unsub() -> None: self._listeners.remove(cb) self._listeners.append(cb) @@ -395,7 +412,7 @@ class ShoppingListView(http.HomeAssistantView): name = "api:shopping_list" @callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Retrieve shopping list items.""" return self.json(request.app["hass"].data[DOMAIN].items) @@ -406,12 +423,13 @@ class UpdateShoppingListItemView(http.HomeAssistantView): url = "/api/shopping_list/item/{item_id}" name = "api:shopping_list:item:id" - async def post(self, request, item_id): + async def post(self, request: web.Request, item_id: str) -> web.Response: """Update a shopping list item.""" data = await request.json() + hass: HomeAssistant = request.app["hass"] try: - item = await request.app["hass"].data[DOMAIN].async_update(item_id, data) + item = await hass.data[DOMAIN].async_update(item_id, data) return self.json(item) except NoMatchingShoppingListItem: return self.json_message("Item not found", HTTPStatus.NOT_FOUND) @@ -426,9 +444,10 @@ class CreateShoppingListItemView(http.HomeAssistantView): name = "api:shopping_list:item" @RequestDataValidator(vol.Schema({vol.Required("name"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: """Create a new shopping list item.""" - item = await request.app["hass"].data[DOMAIN].async_add(data["name"]) + hass: HomeAssistant = request.app["hass"] + item = await hass.data[DOMAIN].async_add(data["name"]) return self.json(item) @@ -438,9 +457,9 @@ class ClearCompletedItemsView(http.HomeAssistantView): url = "/api/shopping_list/clear_completed" name = "api:shopping_list:clear_completed" - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] await hass.data[DOMAIN].async_clear_completed() return self.json_message("Cleared completed items.") diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index d6a29eb73f3..180007c2dfb 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,6 +1,7 @@ """Intents for the Shopping List integration.""" from __future__ import annotations +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -10,7 +11,7 @@ INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_LAST_ITEMS = "HassShoppingListLastItems" -async def async_setup_intents(hass): +async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the Shopping List intents.""" intent.async_register(hass, AddItemIntent()) intent.async_register(hass, ListTopItemsIntent()) @@ -22,7 +23,7 @@ class AddItemIntent(intent.IntentHandler): intent_type = INTENT_ADD_ITEM slot_schema = {"item": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" slots = self.async_validate_slots(intent_obj.slots) item = slots["item"]["value"] @@ -39,7 +40,7 @@ class ListTopItemsIntent(intent.IntentHandler): intent_type = INTENT_LAST_ITEMS slot_schema = {"item": cv.string} - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" items = intent_obj.hass.data[DOMAIN].items[-5:] response = intent_obj.create_response() diff --git a/mypy.ini b/mypy.ini index b92bac5e1f7..5045659cabd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3381,6 +3381,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.shopping_list.*] +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.simplepush.*] check_untyped_defs = true disallow_incomplete_defs = true From ec708811d0853541fe1e666d49bd9b8265563aa3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 14 Jan 2024 09:39:22 +0100 Subject: [PATCH 0595/1544] Enable strict typing for trace (#107945) --- .strict-typing | 1 + homeassistant/components/trace/__init__.py | 2 +- homeassistant/components/trace/models.py | 4 ++-- .../components/trace/websocket_api.py | 18 +++++++++--------- homeassistant/helpers/script.py | 18 +++++++++++------- mypy.ini | 10 ++++++++++ 6 files changed, 34 insertions(+), 19 deletions(-) diff --git a/.strict-typing b/.strict-typing index 3e524e48345..c9c1ab56377 100644 --- a/.strict-typing +++ b/.strict-typing @@ -412,6 +412,7 @@ homeassistant.components.todo.* homeassistant.components.tolo.* homeassistant.components.tplink.* homeassistant.components.tplink_omada.* +homeassistant.components.trace.* homeassistant.components.tractive.* homeassistant.components.tradfri.* homeassistant.components.trafikverket_camera.* diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 84619b7a983..43e591bc6e1 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -44,7 +44,7 @@ TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] @callback def _get_data(hass: HomeAssistant) -> TraceData: - return hass.data[DATA_TRACE] + return hass.data[DATA_TRACE] # type: ignore[no-any-return] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index 9530554449e..2fe37412dfb 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -163,8 +163,8 @@ class RestoredTrace(BaseTrace): def as_extended_dict(self) -> dict[str, Any]: """Return an extended dictionary version of this RestoredTrace.""" - return self._dict + return self._dict # type: ignore[no-any-return] def as_short_dict(self) -> dict[str, Any]: """Return a brief dictionary version of this RestoredTrace.""" - return self._short_dict + return self._short_dict # type: ignore[no-any-return] diff --git a/homeassistant/components/trace/websocket_api.py b/homeassistant/components/trace/websocket_api.py index bf5ebfc1031..6a5280aacf7 100644 --- a/homeassistant/components/trace/websocket_api.py +++ b/homeassistant/components/trace/websocket_api.py @@ -142,8 +142,8 @@ def websocket_breakpoint_set( ) -> None: """Set breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" - node = msg["node"] - run_id = msg.get("run_id") + node: str = msg["node"] + run_id: str | None = msg.get("run_id") if ( SCRIPT_BREAKPOINT_HIT not in hass.data.get(DATA_DISPATCHER, {}) @@ -173,8 +173,8 @@ def websocket_breakpoint_clear( ) -> None: """Clear breakpoint.""" key = f"{msg['domain']}.{msg['item_id']}" - node = msg["node"] - run_id = msg.get("run_id") + node: str = msg["node"] + run_id: str | None = msg.get("run_id") result = breakpoint_clear(hass, key, run_id, node) @@ -211,7 +211,7 @@ def websocket_subscribe_breakpoint_events( """Subscribe to breakpoint events.""" @callback - def breakpoint_hit(key, run_id, node): + def breakpoint_hit(key: str, run_id: str, node: str) -> None: """Forward events to websocket.""" domain, item_id = key.split(".", 1) connection.send_message( @@ -231,7 +231,7 @@ def websocket_subscribe_breakpoint_events( ) @callback - def unsub(): + def unsub() -> None: """Unsubscribe from breakpoint events.""" remove_signal() if ( @@ -263,7 +263,7 @@ def websocket_debug_continue( ) -> None: """Resume execution of halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_continue(hass, key, run_id) @@ -287,7 +287,7 @@ def websocket_debug_step( ) -> None: """Single step a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_step(hass, key, run_id) @@ -311,7 +311,7 @@ def websocket_debug_stop( ) -> None: """Stop a halted script or automation.""" key = f"{msg['domain']}.{msg['item_id']}" - run_id = msg["run_id"] + run_id: str = msg["run_id"] result = debug_stop(hass, key, run_id) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 07f10e13dbf..823a5c171f4 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -78,7 +78,7 @@ from homeassistant.util.dt import utcnow from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function -from .dispatcher import async_dispatcher_connect, async_dispatcher_send +from .dispatcher import SignalType, async_dispatcher_connect, async_dispatcher_send from .event import async_call_later, async_track_template from .script_variables import ScriptVariables from .trace import ( @@ -142,7 +142,7 @@ _SHUTDOWN_MAX_WAIT = 60 ACTION_TRACE_NODE_MAX_LEN = 20 # Max length of a trace node for repeated actions -SCRIPT_BREAKPOINT_HIT = "script_breakpoint_hit" +SCRIPT_BREAKPOINT_HIT = SignalType[str, str, str]("script_breakpoint_hit") SCRIPT_DEBUG_CONTINUE_STOP = "script_debug_continue_stop_{}_{}" SCRIPT_DEBUG_CONTINUE_ALL = "script_debug_continue_all" @@ -1793,7 +1793,9 @@ class Script: @callback -def breakpoint_clear(hass, key, run_id, node): +def breakpoint_clear( + hass: HomeAssistant, key: str, run_id: str | None, node: str +) -> None: """Clear a breakpoint.""" run_id = run_id or RUN_ID_ANY breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] @@ -1809,7 +1811,9 @@ def breakpoint_clear_all(hass: HomeAssistant) -> None: @callback -def breakpoint_set(hass, key, run_id, node): +def breakpoint_set( + hass: HomeAssistant, key: str, run_id: str | None, node: str +) -> None: """Set a breakpoint.""" run_id = run_id or RUN_ID_ANY breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] @@ -1834,7 +1838,7 @@ def breakpoint_list(hass: HomeAssistant) -> list[dict[str, Any]]: @callback -def debug_continue(hass, key, run_id): +def debug_continue(hass: HomeAssistant, key: str, run_id: str) -> None: """Continue execution of a halted script.""" # Clear any wildcard breakpoint breakpoint_clear(hass, key, run_id, NODE_ANY) @@ -1844,7 +1848,7 @@ def debug_continue(hass, key, run_id): @callback -def debug_step(hass, key, run_id): +def debug_step(hass: HomeAssistant, key: str, run_id: str) -> None: """Single step a halted script.""" # Set a wildcard breakpoint breakpoint_set(hass, key, run_id, NODE_ANY) @@ -1854,7 +1858,7 @@ def debug_step(hass, key, run_id): @callback -def debug_stop(hass, key, run_id): +def debug_stop(hass: HomeAssistant, key: str, run_id: str) -> None: """Stop execution of a running or halted script.""" signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) async_dispatcher_send(hass, signal, "stop") diff --git a/mypy.ini b/mypy.ini index 5045659cabd..42051b4ce82 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3882,6 +3882,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trace.*] +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.tractive.*] check_untyped_defs = true disallow_incomplete_defs = true From 01204356fabc6516abbd7bf54aa5a87c56567628 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 14 Jan 2024 09:40:14 +0100 Subject: [PATCH 0596/1544] Enable strict typing for timer (#107915) --- .strict-typing | 1 + homeassistant/components/timer/__init__.py | 41 +++++++++++----------- mypy.ini | 10 ++++++ 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.strict-typing b/.strict-typing index c9c1ab56377..af4bd4a9cf4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -407,6 +407,7 @@ homeassistant.components.tile.* homeassistant.components.tilt_ble.* homeassistant.components.time.* homeassistant.components.time_date.* +homeassistant.components.timer.* homeassistant.components.tod.* homeassistant.components.todo.* homeassistant.components.tolo.* diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 17712b6aef1..4c611962436 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Self +from typing import Any, Self, TypeVar import voluptuous as vol @@ -28,6 +28,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +_T = TypeVar("_T") _LOGGER = logging.getLogger(__name__) DOMAIN = "timer" @@ -73,14 +74,14 @@ STORAGE_FIELDS = { } -def _format_timedelta(delta: timedelta): +def _format_timedelta(delta: timedelta) -> str: total_seconds = delta.total_seconds() hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}" -def _none_to_empty_dict(value): +def _none_to_empty_dict(value: _T | None) -> _T | dict[Any, Any]: if value is None: return {} return value @@ -185,7 +186,7 @@ class TimerStorageCollection(collection.DictStorageCollection): @callback def _get_suggested_id(self, info: dict) -> str: """Suggest an ID based on the config.""" - return info[CONF_NAME] + return info[CONF_NAME] # type: ignore[no-any-return] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" @@ -193,7 +194,7 @@ class TimerStorageCollection(collection.DictStorageCollection): # make duration JSON serializeable if CONF_DURATION in update_data: data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION]) - return data + return data # type: ignore[no-any-return] class Timer(collection.CollectionEntity, RestoreEntity): @@ -231,24 +232,24 @@ class Timer(collection.CollectionEntity, RestoreEntity): return timer @property - def name(self): + def name(self) -> str | None: """Return name of the timer.""" return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> str | None: """Return the icon to be used for this entity.""" return self._config.get(CONF_ICON) @property - def state(self): + def state(self) -> str: """Return the current value of the timer.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = { + attrs: dict[str, Any] = { ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, } @@ -264,9 +265,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): @property def unique_id(self) -> str | None: """Return unique id for the entity.""" - return self._config[CONF_ID] + return self._config[CONF_ID] # type: ignore[no-any-return] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is about to be added to Home Assistant.""" # If we don't need to restore a previous state or no previous state exists, # start at idle @@ -302,7 +303,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_finish() @callback - def async_start(self, duration: timedelta | None = None): + def async_start(self, duration: timedelta | None = None) -> None: """Start a timer.""" if self._listener: self._listener() @@ -356,9 +357,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_pause(self): + def async_pause(self) -> None: """Pause a timer.""" - if self._listener is None: + if self._listener is None or self._end is None: return self._listener() @@ -370,7 +371,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_cancel(self): + def async_cancel(self) -> None: """Cancel a timer.""" if self._listener: self._listener() @@ -385,9 +386,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def async_finish(self): + def async_finish(self) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE: + if self._state != STATUS_ACTIVE or self._end is None: return if self._listener: @@ -405,9 +406,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def _async_finished(self, time): + def _async_finished(self, time: datetime) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE: + if self._state != STATUS_ACTIVE or self._end is None: return self._listener = None diff --git a/mypy.ini b/mypy.ini index 42051b4ce82..ce0b4a3575c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3832,6 +3832,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.timer.*] +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.tod.*] check_untyped_defs = true disallow_incomplete_defs = true From c86b45b454cd8048b617e25002909f8e946afbf1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 14 Jan 2024 09:57:17 +0100 Subject: [PATCH 0597/1544] Remove deprecated vacuum service from roborock (#107895) --- .../components/roborock/strings.json | 13 ------- homeassistant/components/roborock/vacuum.py | 18 --------- tests/components/roborock/test_vacuum.py | 38 +------------------ 3 files changed, 1 insertion(+), 68 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 72a1035f5ca..b24b3501ed8 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -276,18 +276,5 @@ } } } - }, - "issues": { - "service_deprecation_start_pause": { - "title": "Roborock vacuum support for vacuum.start_pause is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::roborock::issues::service_deprecation_start_pause::title%]", - "description": "Roborock vacuum support for the vacuum.start_pause service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.pause or vacuum.start and select submit below to mark this issue as resolved." - } - } - } - } } } diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index c8b43e74efd..3b8f0e756b7 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -17,7 +17,6 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -149,23 +148,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): [self._device_status.get_fan_speed_code(fan_speed)], ) - async def async_start_pause(self) -> None: - """Start, pause or resume the cleaning task.""" - if self.state == STATE_CLEANING: - await self.async_pause() - else: - await self.async_start() - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_start_pause", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_start_pause", - ) - async def async_send_command( self, command: str, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 080893f1d95..c8970517dce 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -15,12 +15,11 @@ from homeassistant.components.vacuum import ( SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START, - SERVICE_START_PAUSE, SERVICE_STOP, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -46,7 +45,6 @@ async def test_registry_entries( (SERVICE_RETURN_TO_BASE, RoborockCommand.APP_CHARGE, None, None), (SERVICE_CLEAN_SPOT, RoborockCommand.APP_SPOT, None, None), (SERVICE_LOCATE, RoborockCommand.FIND_ME, None, None), - (SERVICE_START_PAUSE, RoborockCommand.APP_START, None, None), ( SERVICE_SET_FAN_SPEED, RoborockCommand.SET_CUSTOM_MODE, @@ -88,37 +86,3 @@ async def test_commands( assert mock_send_command.call_count == 1 assert mock_send_command.call_args[0][0] == command assert mock_send_command.call_args[0][1] == called_params - - -@pytest.mark.parametrize( - ("service", "issue_id"), - [ - (SERVICE_START_PAUSE, "service_deprecation_start_pause"), - ], -) -async def test_issues( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - service: str, - issue_id: str, -) -> None: - """Test issues raised by calling deprecated services.""" - vacuum = hass.states.get(ENTITY_ID) - assert vacuum - - data = {ATTR_ENTITY_ID: ENTITY_ID} - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command" - ): - await hass.services.async_call( - Platform.VACUUM, - service, - data, - blocking=True, - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue("roborock", issue_id) - assert issue.is_fixable is True - assert issue.is_persistent is True From bca629ed31f00df17c2d601962ca9a5a110be1bd Mon Sep 17 00:00:00 2001 From: Sam Reed Date: Sun, 14 Jan 2024 09:40:05 +0000 Subject: [PATCH 0598/1544] Drop facebox integration (#107005) --- homeassistant/components/facebox/__init__.py | 1 - homeassistant/components/facebox/const.py | 4 - .../components/facebox/image_processing.py | 282 --------------- .../components/facebox/manifest.json | 7 - .../components/facebox/services.yaml | 17 - homeassistant/components/facebox/strings.json | 22 -- homeassistant/generated/integrations.json | 6 - tests/components/facebox/__init__.py | 1 - .../facebox/test_image_processing.py | 341 ------------------ 9 files changed, 681 deletions(-) delete mode 100644 homeassistant/components/facebox/__init__.py delete mode 100644 homeassistant/components/facebox/const.py delete mode 100644 homeassistant/components/facebox/image_processing.py delete mode 100644 homeassistant/components/facebox/manifest.json delete mode 100644 homeassistant/components/facebox/services.yaml delete mode 100644 homeassistant/components/facebox/strings.json delete mode 100644 tests/components/facebox/__init__.py delete mode 100644 tests/components/facebox/test_image_processing.py diff --git a/homeassistant/components/facebox/__init__.py b/homeassistant/components/facebox/__init__.py deleted file mode 100644 index 9e5b6afb10b..00000000000 --- a/homeassistant/components/facebox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The facebox component.""" diff --git a/homeassistant/components/facebox/const.py b/homeassistant/components/facebox/const.py deleted file mode 100644 index 991ec925a98..00000000000 --- a/homeassistant/components/facebox/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Constants for the Facebox component.""" - -DOMAIN = "facebox" -SERVICE_TEACH_FACE = "teach_face" diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py deleted file mode 100644 index 5584efb883a..00000000000 --- a/homeassistant/components/facebox/image_processing.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Component for facial detection and identification via facebox.""" -from __future__ import annotations - -import base64 -from http import HTTPStatus -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.image_processing import ( - ATTR_CONFIDENCE, - PLATFORM_SCHEMA, - ImageProcessingFaceEntity, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ID, - ATTR_NAME, - CONF_ENTITY_ID, - CONF_IP_ADDRESS, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SOURCE, - CONF_USERNAME, -) -from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import DOMAIN, SERVICE_TEACH_FACE - -_LOGGER = logging.getLogger(__name__) - -ATTR_BOUNDING_BOX = "bounding_box" -ATTR_CLASSIFIER = "classifier" -ATTR_IMAGE_ID = "image_id" -ATTR_MATCHED = "matched" -FACEBOX_NAME = "name" -CLASSIFIER = "facebox" -DATA_FACEBOX = "facebox_classifiers" -FILE_PATH = "file_path" - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - } -) - -SERVICE_TEACH_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_NAME): cv.string, - vol.Required(FILE_PATH): cv.string, - } -) - - -def check_box_health(url, username, password): - """Check the health of the classifier and return its id if healthy.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - response = requests.get(url, **kwargs, timeout=10) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - return None - if response.status_code == HTTPStatus.OK: - return response.json()["hostname"] - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - return None - - -def encode_image(image): - """base64 encode an image stream.""" - base64_img = base64.b64encode(image).decode("ascii") - return base64_img - - -def get_matched_faces(faces): - """Return the name and rounded confidence of matched faces.""" - return { - face["name"]: round(face["confidence"], 2) for face in faces if face["matched"] - } - - -def parse_faces(api_faces): - """Parse the API face data into the format required.""" - known_faces = [] - for entry in api_faces: - face = {} - if entry["matched"]: # This data is only in matched faces. - face[FACEBOX_NAME] = entry["name"] - face[ATTR_IMAGE_ID] = entry["id"] - else: # Lets be explicit. - face[FACEBOX_NAME] = None - face[ATTR_IMAGE_ID] = None - face[ATTR_CONFIDENCE] = round(100.0 * entry["confidence"], 2) - face[ATTR_MATCHED] = entry["matched"] - face[ATTR_BOUNDING_BOX] = entry["rect"] - known_faces.append(face) - return known_faces - - -def post_image(url, image, username, password): - """Post an image to the classifier.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - response = requests.post( - url, json={"base64": encode_image(image)}, timeout=10, **kwargs - ) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - return None - return response - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - return None - - -def teach_file(url, name, file_path, username, password): - """Teach the classifier a name associated with a file.""" - kwargs = {} - if username: - kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) - try: - with open(file_path, "rb") as open_file: - response = requests.post( - url, - data={FACEBOX_NAME: name, ATTR_ID: file_path}, - files={"file": open_file}, - timeout=10, - **kwargs, - ) - if response.status_code == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("AuthenticationError on %s", CLASSIFIER) - elif response.status_code == HTTPStatus.BAD_REQUEST: - _LOGGER.error( - "%s teaching of file %s failed with message:%s", - CLASSIFIER, - file_path, - response.text, - ) - except requests.exceptions.ConnectionError: - _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) - - -def valid_file_path(file_path): - """Check that a file_path points to a valid file.""" - try: - cv.isfile(file_path) - return True - except vol.Invalid: - _LOGGER.error("%s error: Invalid file path: %s", CLASSIFIER, file_path) - return False - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the classifier.""" - if DATA_FACEBOX not in hass.data: - hass.data[DATA_FACEBOX] = [] - - ip_address = config[CONF_IP_ADDRESS] - port = config[CONF_PORT] - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - url_health = f"http://{ip_address}:{port}/healthz" - hostname = check_box_health(url_health, username, password) - if hostname is None: - return - - entities = [] - for camera in config[CONF_SOURCE]: - facebox = FaceClassifyEntity( - ip_address, - port, - username, - password, - hostname, - camera[CONF_ENTITY_ID], - camera.get(CONF_NAME), - ) - entities.append(facebox) - hass.data[DATA_FACEBOX].append(facebox) - add_entities(entities) - - def service_handle(service: ServiceCall) -> None: - """Handle for services.""" - entity_ids = service.data.get("entity_id") - - classifiers = hass.data[DATA_FACEBOX] - if entity_ids: - classifiers = [c for c in classifiers if c.entity_id in entity_ids] - - for classifier in classifiers: - name = service.data.get(ATTR_NAME) - file_path = service.data.get(FILE_PATH) - classifier.teach(name, file_path) - - hass.services.register( - DOMAIN, SERVICE_TEACH_FACE, service_handle, schema=SERVICE_TEACH_SCHEMA - ) - - -class FaceClassifyEntity(ImageProcessingFaceEntity): - """Perform a face classification.""" - - def __init__( - self, ip_address, port, username, password, hostname, camera_entity, name=None - ): - """Init with the API key and model id.""" - super().__init__() - self._url_check = f"http://{ip_address}:{port}/{CLASSIFIER}/check" - self._url_teach = f"http://{ip_address}:{port}/{CLASSIFIER}/teach" - self._username = username - self._password = password - self._hostname = hostname - self._camera = camera_entity - if name: - self._name = name - else: - camera_name = split_entity_id(camera_entity)[1] - self._name = f"{CLASSIFIER} {camera_name}" - self._matched = {} - - def process_image(self, image): - """Process an image.""" - response = post_image(self._url_check, image, self._username, self._password) - if response: - response_json = response.json() - if response_json["success"]: - total_faces = response_json["facesCount"] - faces = parse_faces(response_json["faces"]) - self._matched = get_matched_faces(faces) - self.process_faces(faces, total_faces) - - else: - self.total_faces = None - self.faces = [] - self._matched = {} - - def teach(self, name, file_path): - """Teach classifier a face name.""" - if not self.hass.config.is_allowed_path(file_path) or not valid_file_path( - file_path - ): - return - teach_file(self._url_teach, name, file_path, self._username, self._password) - - @property - def camera_entity(self): - """Return camera entity id from process pictures.""" - return self._camera - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the classifier attributes.""" - return { - "matched_faces": self._matched, - "total_matched_faces": len(self._matched), - "hostname": self._hostname, - } diff --git a/homeassistant/components/facebox/manifest.json b/homeassistant/components/facebox/manifest.json deleted file mode 100644 index f552fef1b87..00000000000 --- a/homeassistant/components/facebox/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "facebox", - "name": "Facebox", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/facebox", - "iot_class": "local_push" -} diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml deleted file mode 100644 index 0438338f55e..00000000000 --- a/homeassistant/components/facebox/services.yaml +++ /dev/null @@ -1,17 +0,0 @@ -teach_face: - fields: - entity_id: - selector: - entity: - integration: facebox - domain: image_processing - name: - required: true - example: "my_name" - selector: - text: - file_path: - required: true - example: "/images/my_image.jpg" - selector: - text: diff --git a/homeassistant/components/facebox/strings.json b/homeassistant/components/facebox/strings.json deleted file mode 100644 index 1869673b643..00000000000 --- a/homeassistant/components/facebox/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "services": { - "teach_face": { - "name": "Teach face", - "description": "Teaches facebox a face using a file.", - "fields": { - "entity_id": { - "name": "Entity", - "description": "The facebox entity to teach." - }, - "name": { - "name": "[%key:common::config_flow::data::name%]", - "description": "The name of the face to teach." - }, - "file_path": { - "name": "File path", - "description": "The path to the image file." - } - } - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d5f8354574f..49527ba6dd0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1680,12 +1680,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "facebox": { - "name": "Facebox", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" - }, "fail2ban": { "name": "Fail2Ban", "integration_type": "hub", diff --git a/tests/components/facebox/__init__.py b/tests/components/facebox/__init__.py deleted file mode 100644 index fbbb6640e40..00000000000 --- a/tests/components/facebox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the facebox component.""" diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py deleted file mode 100644 index 4c6497b975b..00000000000 --- a/tests/components/facebox/test_image_processing.py +++ /dev/null @@ -1,341 +0,0 @@ -"""The tests for the facebox component.""" -from http import HTTPStatus -from unittest.mock import Mock, mock_open, patch - -import pytest -import requests -import requests_mock - -import homeassistant.components.facebox.image_processing as fb -import homeassistant.components.image_processing as ip -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_NAME, - CONF_FRIENDLY_NAME, - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.setup import async_setup_component - -MOCK_IP = "192.168.0.1" -MOCK_PORT = "8080" - -# Mock data returned by the facebox API. -MOCK_BOX_ID = "b893cc4f7fd6" -MOCK_ERROR_NO_FACE = "No face found" -MOCK_FACE = { - "confidence": 0.5812028911604818, - "id": "john.jpg", - "matched": True, - "name": "John Lennon", - "rect": {"height": 75, "left": 63, "top": 262, "width": 74}, -} - -MOCK_FILE_PATH = "/images/mock.jpg" - -MOCK_HEALTH = { - "success": True, - "hostname": "b893cc4f7fd6", - "metadata": {"boxname": "facebox", "build": "development"}, - "errors": [], -} - -MOCK_JSON = {"facesCount": 1, "success": True, "faces": [MOCK_FACE]} - -MOCK_NAME = "mock_name" -MOCK_USERNAME = "mock_username" -MOCK_PASSWORD = "mock_password" - -# Faces data after parsing. -PARSED_FACES = [ - { - fb.FACEBOX_NAME: "John Lennon", - fb.ATTR_IMAGE_ID: "john.jpg", - fb.ATTR_CONFIDENCE: 58.12, - fb.ATTR_MATCHED: True, - fb.ATTR_BOUNDING_BOX: {"height": 75, "left": 63, "top": 262, "width": 74}, - } -] - -MATCHED_FACES = {"John Lennon": 58.12} - -VALID_ENTITY_ID = "image_processing.facebox_demo_camera" -VALID_CONFIG = { - ip.DOMAIN: { - "platform": "facebox", - CONF_IP_ADDRESS: MOCK_IP, - CONF_PORT: MOCK_PORT, - ip.CONF_SOURCE: {ip.CONF_ENTITY_ID: "camera.demo_camera"}, - }, - "camera": {"platform": "demo"}, -} - - -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) - - -@pytest.fixture -def mock_healthybox(): - """Mock fb.check_box_health.""" - check_box_health = ( - "homeassistant.components.facebox.image_processing.check_box_health" - ) - with patch(check_box_health, return_value=MOCK_BOX_ID) as _mock_healthybox: - yield _mock_healthybox - - -@pytest.fixture -def mock_isfile(): - """Mock os.path.isfile.""" - with patch( - "homeassistant.components.facebox.image_processing.cv.isfile", return_value=True - ) as _mock_isfile: - yield _mock_isfile - - -@pytest.fixture -def mock_image(): - """Return a mock camera image.""" - with patch( - "homeassistant.components.demo.camera.DemoCamera.camera_image", - return_value=b"Test", - ) as image: - yield image - - -@pytest.fixture -def mock_open_file(): - """Mock open.""" - mopen = mock_open() - with patch( - "homeassistant.components.facebox.image_processing.open", mopen, create=True - ) as _mock_open: - yield _mock_open - - -def test_check_box_health(caplog: pytest.LogCaptureFixture) -> None: - """Test check box health.""" - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/healthz" - mock_req.get(url, status_code=HTTPStatus.OK, json=MOCK_HEALTH) - assert fb.check_box_health(url, "user", "pass") == MOCK_BOX_ID - - mock_req.get(url, status_code=HTTPStatus.UNAUTHORIZED) - assert fb.check_box_health(url, None, None) is None - assert "AuthenticationError on facebox" in caplog.text - - mock_req.get(url, exc=requests.exceptions.ConnectTimeout) - fb.check_box_health(url, None, None) - assert "ConnectionError: Is facebox running?" in caplog.text - - -def test_encode_image() -> None: - """Test that binary data is encoded correctly.""" - assert fb.encode_image(b"test") == "dGVzdA==" - - -def test_get_matched_faces() -> None: - """Test that matched_faces are parsed correctly.""" - assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES - - -def test_parse_faces() -> None: - """Test parsing of raw face data, and generation of matched_faces.""" - assert fb.parse_faces(MOCK_JSON["faces"]) == PARSED_FACES - - -@patch("os.access", Mock(return_value=False)) -def test_valid_file_path() -> None: - """Test that an invalid file_path is caught.""" - assert not fb.valid_file_path("test_path") - - -async def test_setup_platform(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - -async def test_setup_platform_with_auth(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity and auth.""" - valid_config_auth = VALID_CONFIG.copy() - valid_config_auth[ip.DOMAIN][CONF_USERNAME] = MOCK_USERNAME - valid_config_auth[ip.DOMAIN][CONF_PASSWORD] = MOCK_PASSWORD - - await async_setup_component(hass, ip.DOMAIN, valid_config_auth) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - -async def test_process_image(hass: HomeAssistant, mock_healthybox, mock_image) -> None: - """Test successful processing of an image.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - face_events = [] - - @callback - def mock_face_event(event): - """Mock event.""" - face_events.append(event) - - hass.bus.async_listen("image_processing.detect_face", mock_face_event) - - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.post(url, json=MOCK_JSON) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - - state = hass.states.get(VALID_ENTITY_ID) - assert state.state == "1" - assert state.attributes.get("matched_faces") == MATCHED_FACES - assert state.attributes.get("total_matched_faces") == 1 - - PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. - assert state.attributes.get("faces") == PARSED_FACES - assert state.attributes.get(CONF_FRIENDLY_NAME) == "facebox demo_camera" - - assert len(face_events) == 1 - assert face_events[0].data[ATTR_NAME] == PARSED_FACES[0][ATTR_NAME] - assert ( - face_events[0].data[fb.ATTR_CONFIDENCE] == PARSED_FACES[0][fb.ATTR_CONFIDENCE] - ) - assert face_events[0].data[ATTR_ENTITY_ID] == VALID_ENTITY_ID - assert face_events[0].data[fb.ATTR_IMAGE_ID] == PARSED_FACES[0][fb.ATTR_IMAGE_ID] - assert ( - face_events[0].data[fb.ATTR_BOUNDING_BOX] - == PARSED_FACES[0][fb.ATTR_BOUNDING_BOX] - ) - - -async def test_process_image_errors( - hass: HomeAssistant, mock_healthybox, mock_image, caplog: pytest.LogCaptureFixture -) -> None: - """Test process_image errors.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - # Test connection error. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.register_uri("POST", url, exc=requests.exceptions.ConnectTimeout) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - assert "ConnectionError: Is facebox running?" in caplog.text - - state = hass.states.get(VALID_ENTITY_ID) - assert state.state == STATE_UNKNOWN - assert state.attributes.get("faces") == [] - assert state.attributes.get("matched_faces") == {} - - # Now test with bad auth. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" - mock_req.register_uri("POST", url, status_code=HTTPStatus.UNAUTHORIZED) - data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) - await hass.async_block_till_done() - assert "AuthenticationError on facebox" in caplog.text - - -async def test_teach_service( - hass: HomeAssistant, - mock_healthybox, - mock_image, - mock_isfile, - mock_open_file, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test teaching of facebox.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(VALID_ENTITY_ID) - - # Patch out 'is_allowed_path' as the mock files aren't allowed - hass.config.is_allowed_path = Mock(return_value=True) - - # Test successful teach. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.OK) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - - # Now test with bad auth. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.UNAUTHORIZED) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert "AuthenticationError on facebox" in caplog.text - - # Now test the failed teaching. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, status_code=HTTPStatus.BAD_REQUEST, text=MOCK_ERROR_NO_FACE) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert MOCK_ERROR_NO_FACE in caplog.text - - # Now test connection error. - with requests_mock.Mocker() as mock_req: - url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" - mock_req.post(url, exc=requests.exceptions.ConnectTimeout) - data = { - ATTR_ENTITY_ID: VALID_ENTITY_ID, - ATTR_NAME: MOCK_NAME, - fb.FILE_PATH: MOCK_FILE_PATH, - } - await hass.services.async_call( - fb.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data - ) - await hass.async_block_till_done() - assert "ConnectionError: Is facebox running?" in caplog.text - - -async def test_setup_platform_with_name(hass: HomeAssistant, mock_healthybox) -> None: - """Set up platform with one entity and a name.""" - named_entity_id = f"image_processing.{MOCK_NAME}" - - valid_config_named = VALID_CONFIG.copy() - valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME - - await async_setup_component(hass, ip.DOMAIN, valid_config_named) - await hass.async_block_till_done() - assert hass.states.get(named_entity_id) - state = hass.states.get(named_entity_id) - assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME From 3895defff99c01980c6ca966a495320b7d805a69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 10:41:44 +0100 Subject: [PATCH 0599/1544] Improve calls to async_show_progress in homeassistant_hardware (#107789) --- .../silabs_multiprotocol_addon.py | 58 ++++++------ .../test_silabs_multiprotocol_addon.py | 93 +++++-------------- .../test_config_flow.py | 16 +--- .../homeassistant_yellow/test_config_flow.py | 16 +--- 4 files changed, 59 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 7884d3f5617..ef953213fc8 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio -from collections.abc import Awaitable import dataclasses import logging from typing import Any, Protocol @@ -339,14 +338,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): """Return the correct flow manager.""" return self.hass.config_entries.options - async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: - try: - await awaitable - finally: - self.hass.async_create_task( - self.flow_manager.async_configure(flow_id=self.flow_id) - ) - async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" try: @@ -411,18 +402,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Install Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + if not self.install_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.install_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_install_addon_waiting() - ), + multipan_manager.async_install_addon_waiting(), "SiLabs Multiprotocol addon install", ) + + if not self.install_task.done(): return self.async_show_progress( step_id="install_addon", progress_action="install_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.install_task, ) try: @@ -518,27 +511,29 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Start Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + if not self.start_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.start_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_start_addon_waiting() - ) + multipan_manager.async_start_addon_waiting() ) + + if not self.start_task.done(): return self.async_show_progress( step_id="start_addon", progress_action="start_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.start_task, ) try: await self.start_task except (AddonError, AbortFlow) as err: - self.start_task = None _LOGGER.error(err) return self.async_show_progress_done(next_step_id="start_failed") + finally: + self.start_task = None - self.start_task = None return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -715,15 +710,16 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): if not self.install_task: self.install_task = self.hass.async_create_task( - self._resume_flow_when_done( - flasher_manager.async_install_addon_waiting() - ), + flasher_manager.async_install_addon_waiting(), "SiLabs Flasher addon install", ) + + if not self.install_task.done(): return self.async_show_progress( step_id="install_flasher_addon", progress_action="install_addon", description_placeholders={"addon_name": flasher_manager.addon_name}, + progress_task=self.install_task, ) try: @@ -800,19 +796,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Uninstall Silicon Labs Multiprotocol add-on.""" + multipan_manager = await get_multiprotocol_addon_manager(self.hass) if not self.stop_task: - multipan_manager = await get_multiprotocol_addon_manager(self.hass) self.stop_task = self.hass.async_create_task( - self._resume_flow_when_done( - multipan_manager.async_uninstall_addon_waiting() - ), + multipan_manager.async_uninstall_addon_waiting(), "SiLabs Multiprotocol addon uninstall", ) + + if not self.stop_task.done(): return self.async_show_progress( step_id="uninstall_multiprotocol_addon", progress_action="uninstall_multiprotocol_addon", description_placeholders={"addon_name": multipan_manager.addon_name}, + progress_task=self.stop_task, ) try: @@ -826,9 +823,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Start Silicon Labs Flasher add-on.""" + flasher_manager = get_flasher_addon_manager(self.hass) if not self.start_task: - flasher_manager = get_flasher_addon_manager(self.hass) async def start_and_wait_until_done() -> None: await flasher_manager.async_start_addon_waiting() @@ -837,13 +834,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): AddonState.NOT_RUNNING ) - self.start_task = self.hass.async_create_task( - self._resume_flow_when_done(start_and_wait_until_done()) - ) + self.start_task = self.hass.async_create_task(start_and_wait_until_done()) + + if not self.start_task.done(): return self.async_show_progress( step_id="start_flasher_addon", progress_action="start_flasher_addon", description_placeholders={"addon_name": flasher_manager.addon_name}, + progress_task=self.start_task, ) try: diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index f58d561bfb3..43fcd69e4db 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -242,9 +242,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -263,9 +261,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -321,9 +317,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( @@ -362,9 +356,7 @@ async def test_option_flow_install_multi_pan_addon_zha( } assert zha_config_entry.title == "Test Multiprotocol" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -420,9 +412,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") addon_info.return_value["hostname"] = "core-silabs-multiprotocol" @@ -442,9 +432,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -700,19 +688,15 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["step_id"] == "install_flasher_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_flasher_addon" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -720,10 +704,8 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_flasher") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -878,10 +860,8 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -894,10 +874,8 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed "available": True, "state": "not_running", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() install_addon.assert_not_called() - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -964,9 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["step_id"] == "install_flasher_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "install_failed" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_flasher") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1019,10 +995,8 @@ async def test_option_flow_flasher_addon_flash_failure( start_addon.side_effect = HassioAPIError("Boom") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -1030,10 +1004,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - addon_store_info.return_value["installed"] = True - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flasher_failed" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -1158,10 +1129,8 @@ async def test_option_flow_uninstall_migration_finish_failure( result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done() uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_flasher_addon" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS @@ -1169,9 +1138,7 @@ async def test_option_flow_uninstall_migration_finish_failure( assert result["progress_action"] == "start_flasher_addon" assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "flashing_complete" + await hass.async_block_till_done() result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -1242,9 +1209,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "install_failed" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1287,9 +1252,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1308,9 +1271,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "start_failed" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1353,9 +1314,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1432,9 +1391,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1490,9 +1447,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -1511,9 +1466,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 65636b27a16..36f0a259b7f 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -210,9 +210,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -231,9 +229,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -313,9 +309,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -343,9 +337,7 @@ async def test_option_flow_install_multi_pan_addon_zha( "radio_type": "ezsp", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 242b316de66..bd61400fa8e 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -143,9 +143,7 @@ async def test_option_flow_install_multi_pan_addon( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -164,9 +162,7 @@ async def test_option_flow_install_multi_pan_addon( }, ) - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -225,9 +221,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "install_addon" assert result["progress_action"] == "install_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "configure_addon" + await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) @@ -255,9 +249,7 @@ async def test_option_flow_install_multi_pan_addon_zha( "radio_type": "ezsp", } - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE - assert result["step_id"] == "finish_addon_setup" + await hass.async_block_till_done() start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) From 99b6c7d25f57853e3f71eab9008e1c3e8da04dd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Jan 2024 23:57:54 -1000 Subject: [PATCH 0600/1544] Refactor async_track_utc_time_change to avoid using nonlocal (#108007) --- homeassistant/helpers/event.py | 108 ++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 42 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index bd1454cf637..d3f4144a293 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1686,6 +1686,62 @@ time_tracker_utcnow = dt_util.utcnow time_tracker_timestamp = time.time +@dataclass(slots=True) +class _TrackUTCTimeChange: + hass: HomeAssistant + time_match_expression: tuple[list[int], list[int], list[int]] + microsecond: int + local: bool + job: HassJob[[datetime], Coroutine[Any, Any, None] | None] + listener_job_name: str + _pattern_time_change_listener_job: HassJob[[datetime], None] | None = None + _cancel_callback: CALLBACK_TYPE | None = None + + def async_attach(self) -> None: + """Initialize track job.""" + self._pattern_time_change_listener_job = HassJob( + self._pattern_time_change_listener, + self.listener_job_name, + job_type=HassJobType.Callback, + ) + self._cancel_callback = async_track_point_in_utc_time( + self.hass, + self._pattern_time_change_listener_job, + self._calculate_next(dt_util.utcnow()), + ) + + def _calculate_next(self, utc_now: datetime) -> datetime: + """Calculate and set the next time the trigger should fire.""" + localized_now = dt_util.as_local(utc_now) if self.local else utc_now + return dt_util.find_next_time_expression_time( + localized_now, *self.time_match_expression + ).replace(microsecond=self.microsecond) + + @callback + def _pattern_time_change_listener(self, _: datetime) -> None: + """Listen for matching time_changed events.""" + hass = self.hass + # Fetch time again because we want the actual time, not the + # time when the timer was scheduled + utc_now = time_tracker_utcnow() + localized_now = dt_util.as_local(utc_now) if self.local else utc_now + hass.async_run_hass_job(self.job, localized_now) + if TYPE_CHECKING: + assert self._pattern_time_change_listener_job is not None + self._cancel_callback = async_track_point_in_utc_time( + hass, + self._pattern_time_change_listener_job, + self._calculate_next(utc_now + timedelta(seconds=1)), + ) + + @callback + def async_cancel(self) -> None: + """Cancel the call_at.""" + if TYPE_CHECKING: + assert self._cancel_callback is not None + self._cancel_callback() + + @callback @bind_hass def async_track_utc_time_change( @@ -1718,49 +1774,17 @@ def async_track_utc_time_change( # since it can create a thundering herd problem # https://github.com/home-assistant/core/issues/82231 microsecond = randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) - - def calculate_next(now: datetime) -> datetime: - """Calculate and set the next time the trigger should fire.""" - localized_now = dt_util.as_local(now) if local else now - return dt_util.find_next_time_expression_time( - localized_now, matching_seconds, matching_minutes, matching_hours - ).replace(microsecond=microsecond) - - time_listener: CALLBACK_TYPE | None = None - pattern_time_change_listener_job: HassJob[[datetime], Any] | None = None - - @callback - def pattern_time_change_listener(_: datetime) -> None: - """Listen for matching time_changed events.""" - nonlocal time_listener - nonlocal pattern_time_change_listener_job - - now = time_tracker_utcnow() - hass.async_run_hass_job(job, dt_util.as_local(now) if local else now) - assert pattern_time_change_listener_job is not None - - time_listener = async_track_point_in_utc_time( - hass, - pattern_time_change_listener_job, - calculate_next(now + timedelta(seconds=1)), - ) - - pattern_time_change_listener_job = HassJob( - pattern_time_change_listener, - f"time change listener {hour}:{minute}:{second} {action}", - job_type=HassJobType.Callback, + listener_job_name = f"time change listener {hour}:{minute}:{second} {action}" + track = _TrackUTCTimeChange( + hass, + (matching_seconds, matching_minutes, matching_hours), + microsecond, + local, + job, + listener_job_name, ) - time_listener = async_track_point_in_utc_time( - hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) - ) - - @callback - def unsub_pattern_time_change_listener() -> None: - """Cancel the time listener.""" - assert time_listener is not None - time_listener() - - return unsub_pattern_time_change_listener + track.async_attach() + return track.async_cancel track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) From 51cdb4ce366500900bc79567dd30d9fa1f554d4d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 14 Jan 2024 10:58:17 +0100 Subject: [PATCH 0601/1544] Update pipdeptree to 2.13.2 (#108009) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c814a035d2d..85dc72e0430 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ pre-commit==3.6.0 pydantic==1.10.12 pylint==3.0.3 pylint-per-file-ignores==1.2.1 -pipdeptree==2.11.0 +pipdeptree==2.13.2 pytest-asyncio==0.21.0 pytest-aiohttp==1.0.5 pytest-cov==4.1.0 From e12dcfc1b4ae550989ffe46fb9e724556705254f Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 14 Jan 2024 04:59:04 -0500 Subject: [PATCH 0602/1544] Fix wifi sensor units in Blink (#107539) --- homeassistant/components/blink/sensor.py | 11 +++-------- homeassistant/components/blink/strings.json | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 74db76c421e..ea31d1b29ab 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -10,11 +10,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - EntityCategory, - UnitOfTemperature, -) +from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,9 +31,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, - translation_key="wifi_rssi", - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, + translation_key="wifi_strength", + icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index a875fb3e343..09bbba4c226 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -39,8 +39,8 @@ }, "entity": { "sensor": { - "wifi_rssi": { - "name": "Wi-Fi RSSI" + "wifi_strength": { + "name": "Wi-Fi signal strength" } }, "binary_sensor": { From 1c9764bc446d1ac8c1b85f6a069ecdfe88bb41ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 11:00:10 +0100 Subject: [PATCH 0603/1544] Improve calls to async_show_progress in snooz (#107793) --- homeassistant/components/snooz/config_flow.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index eb05edcbefa..d2188eeec73 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -134,18 +134,20 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): self._pairing_task = self.hass.async_create_task( self._async_wait_for_pairing_mode() ) + + if not self._pairing_task.done(): return self.async_show_progress( step_id="wait_for_pairing_mode", progress_action="wait_for_pairing_mode", + progress_task=self._pairing_task, ) try: await self._pairing_task except asyncio.TimeoutError: - self._pairing_task = None return self.async_show_progress_done(next_step_id="pairing_timeout") - - self._pairing_task = None + finally: + self._pairing_task = None return self.async_show_progress_done(next_step_id="pairing_complete") @@ -192,15 +194,10 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): ) -> bool: return device.supported(service_info) and device.is_pairing - try: - await async_process_advertisements( - self.hass, - is_device_in_pairing_mode, - {"address": self._discovery.info.address}, - BluetoothScanningMode.ACTIVE, - WAIT_FOR_PAIRING_TIMEOUT, - ) - finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + await async_process_advertisements( + self.hass, + is_device_in_pairing_mode, + {"address": self._discovery.info.address}, + BluetoothScanningMode.ACTIVE, + WAIT_FOR_PAIRING_TIMEOUT, + ) From 7fc3f8e47330cea60bec605d7ad8d334ca184d40 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 11:06:35 +0100 Subject: [PATCH 0604/1544] Improve calls to async_show_progress in octoprint (#107792) --- .../components/octoprint/config_flow.py | 45 ++++++++----------- .../components/octoprint/test_config_flow.py | 20 +++++---- 2 files changed, 31 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 696898400bf..01a3e9518c0 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -53,12 +53,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 api_key_task: asyncio.Task[None] | None = None + discovery_schema: vol.Schema | None = None _reauth_data: dict[str, Any] | None = None + _user_input: dict[str, Any] | None = None def __init__(self) -> None: """Handle a config flow for OctoPrint.""" - self.discovery_schema = None - self._user_input = None self._sessions: list[aiohttp.ClientSession] = [] async def async_step_user(self, user_input=None): @@ -97,17 +97,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), ) - self.api_key_task = None - return await self.async_step_get_api_key(user_input) + self._user_input = user_input + return await self.async_step_get_api_key() - async def async_step_get_api_key(self, user_input): + async def async_step_get_api_key(self, user_input=None): """Get an Application Api Key.""" if not self.api_key_task: - self.api_key_task = self.hass.async_create_task( - self._async_get_auth_key(user_input) - ) + self.api_key_task = self.hass.async_create_task(self._async_get_auth_key()) + if not self.api_key_task.done(): return self.async_show_progress( - step_id="get_api_key", progress_action="get_api_key" + step_id="get_api_key", + progress_action="get_api_key", + progress_task=self.api_key_task, ) try: @@ -118,9 +119,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Failed to get an application key : %s", err) return self.async_show_progress_done(next_step_id="auth_failed") + finally: + self.api_key_task = None - # store this off here to pick back up in the user step - self._user_input = user_input return self.async_show_progress_done(next_step_id="user") async def _finish_config(self, user_input: dict): @@ -238,26 +239,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), ) - self.api_key_task = None self._reauth_data[CONF_USERNAME] = user_input[CONF_USERNAME] - return await self.async_step_get_api_key(self._reauth_data) + self._user_input = self._reauth_data + return await self.async_step_get_api_key() - async def _async_get_auth_key(self, user_input: dict): + async def _async_get_auth_key(self): """Get application api key.""" - octoprint = self._get_octoprint_client(user_input) + octoprint = self._get_octoprint_client(self._user_input) - try: - user_input[CONF_API_KEY] = await octoprint.request_app_key( - "Home Assistant", user_input[CONF_USERNAME], 300 - ) - finally: - # Continue the flow after show progress when the task is done. - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure( - flow_id=self.flow_id, user_input=user_input - ) - ) + self._user_input[CONF_API_KEY] = await octoprint.request_app_key( + "Home Assistant", self._user_input[CONF_USERNAME], 300 + ) def _get_octoprint_client(self, user_input: dict) -> OctoprintClient: """Build an octoprint client from the user_input.""" diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index e3cf45708fa..8e20983a791 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -95,8 +95,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + assert result["type"] == "progress" - assert result["type"] == "progress_done" with patch( "pyoctoprintapi.OctoprintClient.get_discovery_info", side_effect=ApiError, @@ -144,8 +145,9 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + assert result["type"] == "progress" - assert result["type"] == "progress_done" with patch( "pyoctoprintapi.OctoprintClient.get_discovery_info", side_effect=Exception, @@ -203,7 +205,7 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "progress_done" + assert result["type"] == "progress" with patch( "pyoctoprintapi.OctoprintClient.get_server_info", @@ -269,7 +271,7 @@ async def test_show_ssdp_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "progress_done" + assert result["type"] == "progress" with patch( "pyoctoprintapi.OctoprintClient.get_server_info", @@ -390,10 +392,11 @@ async def test_failed_auth(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + + assert result["type"] == "progress" - assert result["type"] == "progress_done" result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" assert result["reason"] == "auth_failed" @@ -421,10 +424,11 @@ async def test_failed_auth_unexpected_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], ) + await hass.async_block_till_done() + + assert result["type"] == "progress" - assert result["type"] == "progress_done" result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" assert result["reason"] == "auth_failed" From 5e79cd8715557b2dcbce1c796066168d8b373531 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 11:07:39 +0100 Subject: [PATCH 0605/1544] Remove file/line annotations after config has been validated (#107139) --- homeassistant/config.py | 52 +++++++++++++++++++++++++++++++++++------ homeassistant/setup.py | 5 ++-- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 949774d3361..fc2feb48065 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -14,7 +14,7 @@ from pathlib import Path import re import shutil from types import ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar from urllib.parse import urlparse from awesomeversion import AwesomeVersion @@ -67,6 +67,7 @@ from .requirements import RequirementsNotFound, async_get_integration_with_requi from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict +from .util.yaml.objects import NodeStrClass _LOGGER = logging.getLogger(__name__) @@ -146,6 +147,9 @@ class ConfigExceptionInfo: integration_link: str | None +_T = TypeVar("_T") + + @dataclass class IntegrationConfigInfo: """Configuration for an integration and exception information.""" @@ -1221,9 +1225,45 @@ async def async_process_component_and_handle_errors( integration_config_info = await async_process_component_config( hass, config, integration ) - return async_handle_component_errors( + async_handle_component_errors( hass, integration_config_info, integration, raise_on_failure ) + return async_drop_config_annotations(integration_config_info, integration) + + +@callback +def async_drop_config_annotations( + integration_config_info: IntegrationConfigInfo, + integration: Integration, +) -> ConfigType | None: + """Remove file and line annotations from str items in component configuration.""" + if (config := integration_config_info.config) is None: + return None + + def drop_config_annotations_rec(node: Any) -> Any: + if isinstance(node, dict): + # Some integrations store metadata in custom dict classes, preserve those + tmp = dict(node) + node.clear() + node.update( + (drop_config_annotations_rec(k), drop_config_annotations_rec(v)) + for k, v in tmp.items() + ) + return node + + if isinstance(node, list): + return [drop_config_annotations_rec(v) for v in node] + + if isinstance(node, NodeStrClass): + return str(node) + + return node + + # Don't drop annotations from the homeassistant integration because it may + # have configuration for other integrations as packages. + if integration.domain in config and integration.domain != CONF_CORE: + drop_config_annotations_rec(config[integration.domain]) + return config @callback @@ -1232,18 +1272,16 @@ def async_handle_component_errors( integration_config_info: IntegrationConfigInfo, integration: Integration, raise_on_failure: bool = False, -) -> ConfigType | None: +) -> None: """Handle component configuration errors from async_process_component_config. In case of errors: - Print the error messages to the log. - Raise a ConfigValidationError if raise_on_failure is set. - - Returns the integration config or `None`. """ if not (config_exception_info := integration_config_info.exception_info_list): - return integration_config_info.config + return platform_exception: ConfigExceptionInfo domain = integration.domain @@ -1261,7 +1299,7 @@ def async_handle_component_errors( ) if not raise_on_failure: - return integration_config_info.config + return if len(config_exception_info) == 1: translation_key = platform_exception.translation_key diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 7a7f4323be6..5408da20a70 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -256,8 +256,9 @@ async def _async_setup_component( integration_config_info = await conf_util.async_process_component_config( hass, config, integration ) - processed_config = conf_util.async_handle_component_errors( - hass, integration_config_info, integration + conf_util.async_handle_component_errors(hass, integration_config_info, integration) + processed_config = conf_util.async_drop_config_annotations( + integration_config_info, integration ) for platform_exception in integration_config_info.exception_info_list: if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: From 965499dd90d7eb151a5c2a5483fff497ac20acf0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 14 Jan 2024 11:12:30 +0100 Subject: [PATCH 0606/1544] Add entity translations to Glances (#107189) --- homeassistant/components/glances/sensor.py | 102 +-- homeassistant/components/glances/strings.json | 73 ++ .../glances/snapshots/test_sensor.ambr | 648 +++++++++--------- tests/components/glances/test_sensor.py | 47 -- 4 files changed, 433 insertions(+), 437 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index a3578bf6f66..2119e990e44 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -13,15 +13,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - Platform, UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +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 @@ -35,7 +32,6 @@ class GlancesSensorEntityDescriptionMixin: """Mixin for required keys.""" type: str - name_suffix: str @dataclass(frozen=True) @@ -49,7 +45,7 @@ SENSOR_TYPES = { ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", - name_suffix="used percent", + translation_key="disk_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, @@ -57,7 +53,7 @@ SENSOR_TYPES = { ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", - name_suffix="used", + translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -66,7 +62,7 @@ SENSOR_TYPES = { ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", - name_suffix="free", + translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -75,7 +71,7 @@ SENSOR_TYPES = { ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", - name_suffix="RAM used percent", + translation_key="memory_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -83,7 +79,7 @@ SENSOR_TYPES = { ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", - name_suffix="RAM used", + translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -92,7 +88,7 @@ SENSOR_TYPES = { ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", - name_suffix="RAM free", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -101,7 +97,7 @@ SENSOR_TYPES = { ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", - name_suffix="Swap used percent", + translation_key="swap_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -109,7 +105,7 @@ SENSOR_TYPES = { ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", - name_suffix="Swap used", + translation_key="swap_used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -118,7 +114,7 @@ SENSOR_TYPES = { ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", - name_suffix="Swap free", + translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -127,42 +123,42 @@ SENSOR_TYPES = { ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", - name_suffix="CPU load", + translation_key="processor_load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", - name_suffix="Running", + translation_key="process_running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", - name_suffix="Total", + translation_key="process_total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", - name_suffix="Thread", + translation_key="process_threads", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", - name_suffix="Sleeping", + translation_key="process_sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", - name_suffix="CPU used", + translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, @@ -170,7 +166,7 @@ SENSOR_TYPES = { ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", - name_suffix="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -178,7 +174,7 @@ SENSOR_TYPES = { ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", - name_suffix="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -186,7 +182,7 @@ SENSOR_TYPES = { ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", - name_suffix="Fan speed", + translation_key="fan_speed", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, @@ -194,7 +190,7 @@ SENSOR_TYPES = { ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", - name_suffix="Charge", + translation_key="charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, icon="mdi:battery", @@ -203,14 +199,14 @@ SENSOR_TYPES = { ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", - name_suffix="Containers active", + translation_key="container_active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", - name_suffix="Containers CPU used", + translation_key="container_cpu_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, @@ -218,7 +214,7 @@ SENSOR_TYPES = { ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", - name_suffix="Containers RAM used", + translation_key="container_memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:docker", @@ -227,14 +223,14 @@ SENSOR_TYPES = { ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", - name_suffix="Raid available", + translation_key="raid_available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", - name_suffix="Raid used", + translation_key="raid_used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), @@ -249,54 +245,26 @@ async def async_setup_entry( """Set up the Glances sensors.""" coordinator: GlancesDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - name = config_entry.data.get(CONF_NAME) entities = [] - @callback - def _migrate_old_unique_ids( - hass: HomeAssistant, old_unique_id: str, new_key: str - ) -> None: - """Migrate unique IDs to the new format.""" - ent_reg = er.async_get(hass) - - if entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, old_unique_id - ): - ent_reg.async_update_entity( - entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" - ) - for sensor_type, sensors in coordinator.data.items(): if sensor_type in ["fs", "sensors", "raid"]: for sensor_label, params in sensors.items(): for param in params: if sensor_description := SENSOR_TYPES.get((sensor_type, param)): - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", - f"{sensor_label}-{sensor_description.key}", - ) entities.append( GlancesSensor( coordinator, - name, - sensor_label, sensor_description, + sensor_label, ) ) else: for sensor in sensors: if sensor_description := SENSOR_TYPES.get((sensor_type, sensor)): - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {sensor_description.name_suffix}", - f"-{sensor_description.key}", - ) entities.append( GlancesSensor( coordinator, - name, - "", sensor_description, ) ) @@ -313,21 +281,23 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit def __init__( self, coordinator: GlancesDataUpdateCoordinator, - name: str | None, - sensor_name_prefix: str, description: GlancesSensorEntityDescription, + sensor_label: str = "", ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._sensor_name_prefix = sensor_name_prefix + self._sensor_label = sensor_label self.entity_description = description - self._attr_name = f"{sensor_name_prefix} {description.name_suffix}".strip() + if sensor_label: + self._attr_translation_placeholders = {"sensor_label": sensor_label} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Glances", - name=name or coordinator.host, + name=coordinator.host, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}" ) - self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property def available(self) -> bool: @@ -346,8 +316,8 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit """Return the state of the resources.""" value = self.coordinator.data[self.entity_description.type] - if isinstance(value.get(self._sensor_name_prefix), dict): + if isinstance(value.get(self._sensor_label), dict): return cast( - StateType, value[self._sensor_name_prefix][self.entity_description.key] + StateType, value[self._sensor_label][self.entity_description.key] ) return cast(StateType, value[self.entity_description.key]) diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 7e69e7f7912..972106d352f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -30,6 +30,79 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "sensor": { + "disk_usage": { + "name": "{sensor_label} disk usage" + }, + "disk_used": { + "name": "{sensor_label} disk used" + }, + "disk_free": { + "name": "{sensor_label} disk free" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_use": { + "name": "Memory use" + }, + "memory_free": { + "name": "Memory free" + }, + "swap_usage": { + "name": "Swap usage" + }, + "swap_use": { + "name": "Swap use" + }, + "swap_free": { + "name": "Swap free" + }, + "cpu_load": { + "name": "CPU load" + }, + "process_running": { + "name": "Running" + }, + "process_total": { + "name": "Total" + }, + "process_threads": { + "name": "Threads" + }, + "process_sleeping": { + "name": "Sleeping" + }, + "cpu_usage": { + "name": "CPU usage" + }, + "temperature": { + "name": "{sensor_label} temperature" + }, + "fan_speed": { + "name": "{sensor_label} fan speed" + }, + "charge": { + "name": "{sensor_label} charge" + }, + "container_active": { + "name": "Containers active" + }, + "container_cpu_usage": { + "name": "Containers CPU usage" + }, + "container_memory_used": { + "name": "Containers memory used" + }, + "raid_available": { + "name": "{sensor_label} available" + }, + "raid_used": { + "name": "{sensor_label} used" + } + } + }, "issues": { "deprecated_version": { "title": "Glances servers with version 2 is deprecated", diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index 0dbdec54714..d08064e8647 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -27,7 +27,7 @@ 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'container_active', 'unique_id': 'test--docker_active', 'unit_of_measurement': None, }) @@ -46,7 +46,7 @@ 'state': '2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_containers_cpu_used', + 'entity_id': 'sensor.0_0_0_0_containers_cpu_usage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -70,31 +70,31 @@ }), 'original_device_class': None, 'original_icon': 'mdi:docker', - 'original_name': 'Containers CPU used', + 'original_name': 'Containers CPU usage', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'container_cpu_usage', 'unique_id': 'test--docker_cpu_use', 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_used-state] +# name: test_sensor_states[sensor.0_0_0_0_containers_cpu_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 Containers CPU used', + 'friendly_name': '0.0.0.0 Containers CPU usage', 'icon': 'mdi:docker', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.0_0_0_0_containers_cpu_used', + 'entity_id': 'sensor.0_0_0_0_containers_cpu_usage', 'last_changed': , 'last_updated': , 'state': '77.2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_containers_memory_used-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +108,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_containers_ram_used', + 'entity_id': 'sensor.0_0_0_0_containers_memory_used', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -118,26 +118,26 @@ }), 'original_device_class': , 'original_icon': 'mdi:docker', - 'original_name': 'Containers RAM used', + 'original_name': 'Containers memory used', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'container_memory_used', 'unique_id': 'test--docker_memory_use', 'unit_of_measurement': , }) # --- -# name: test_sensor_states[sensor.0_0_0_0_containers_ram_used-state] +# name: test_sensor_states[sensor.0_0_0_0_containers_memory_used-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 Containers RAM used', + 'friendly_name': '0.0.0.0 Containers memory used', 'icon': 'mdi:docker', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_containers_ram_used', + 'entity_id': 'sensor.0_0_0_0_containers_memory_used', 'last_changed': , 'last_updated': , 'state': '1149.6', @@ -167,11 +167,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'cpu_thermal 1 Temperature', + 'original_name': 'cpu_thermal 1 temperature', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'temperature', 'unique_id': 'test-cpu_thermal 1-temperature_core', 'unit_of_measurement': , }) @@ -180,7 +180,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '0.0.0.0 cpu_thermal 1 Temperature', + 'friendly_name': '0.0.0.0 cpu_thermal 1 temperature', 'state_class': , 'unit_of_measurement': , }), @@ -191,6 +191,55 @@ 'state': '59', }) # --- +# name: test_sensor_states[sensor.0_0_0_0_data_size-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.0_0_0_0_data_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'Data size', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_used', + 'unique_id': 'test--memory_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_data_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Data size', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_data_size', + 'last_changed': , + 'last_updated': , + 'state': '1047.1', + }) +# --- # name: test_sensor_states[sensor.0_0_0_0_err_temp_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -215,11 +264,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'err_temp Temperature', + 'original_name': 'err_temp temperature', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'temperature', 'unique_id': 'test-err_temp-temperature_hdd', 'unit_of_measurement': , }) @@ -228,7 +277,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '0.0.0.0 err_temp Temperature', + 'friendly_name': '0.0.0.0 err_temp temperature', 'state_class': , 'unit_of_measurement': , }), @@ -239,7 +288,7 @@ 'state': 'unavailable', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-entry] +# name: test_sensor_states[sensor.0_0_0_0_md1_available-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -253,7 +302,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_md1_raid_available', + 'entity_id': 'sensor.0_0_0_0_md1_available', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -263,30 +312,30 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': 'md1 Raid available', + 'original_name': 'md1 available', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'raid_available', 'unique_id': 'test-md1-available', 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md1_raid_available-state] +# name: test_sensor_states[sensor.0_0_0_0_md1_available-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 md1 Raid available', + 'friendly_name': '0.0.0.0 md1 available', 'icon': 'mdi:harddisk', 'state_class': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_md1_raid_available', + 'entity_id': 'sensor.0_0_0_0_md1_available', 'last_changed': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_md1_used-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -300,7 +349,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_md1_raid_used', + 'entity_id': 'sensor.0_0_0_0_md1_used', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -310,30 +359,30 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': 'md1 Raid used', + 'original_name': 'md1 used', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'raid_used', 'unique_id': 'test-md1-used', 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md1_raid_used-state] +# name: test_sensor_states[sensor.0_0_0_0_md1_used-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 md1 Raid used', + 'friendly_name': '0.0.0.0 md1 used', 'icon': 'mdi:harddisk', 'state_class': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_md1_raid_used', + 'entity_id': 'sensor.0_0_0_0_md1_used', 'last_changed': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-entry] +# name: test_sensor_states[sensor.0_0_0_0_md3_available-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -347,7 +396,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_md3_raid_available', + 'entity_id': 'sensor.0_0_0_0_md3_available', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -357,30 +406,30 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': 'md3 Raid available', + 'original_name': 'md3 available', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'raid_available', 'unique_id': 'test-md3-available', 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md3_raid_available-state] +# name: test_sensor_states[sensor.0_0_0_0_md3_available-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 md3 Raid available', + 'friendly_name': '0.0.0.0 md3 available', 'icon': 'mdi:harddisk', 'state_class': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_md3_raid_available', + 'entity_id': 'sensor.0_0_0_0_md3_available', 'last_changed': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_md3_used-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -394,7 +443,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_md3_raid_used', + 'entity_id': 'sensor.0_0_0_0_md3_used', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -404,30 +453,30 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': 'md3 Raid used', + 'original_name': 'md3 used', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'raid_used', 'unique_id': 'test-md3-used', 'unit_of_measurement': None, }) # --- -# name: test_sensor_states[sensor.0_0_0_0_md3_raid_used-state] +# name: test_sensor_states[sensor.0_0_0_0_md3_used-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 md3 Raid used', + 'friendly_name': '0.0.0.0 md3 used', 'icon': 'mdi:harddisk', 'state_class': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_md3_raid_used', + 'entity_id': 'sensor.0_0_0_0_md3_used', 'last_changed': , 'last_updated': , 'state': '2', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_media_free-entry] +# name: test_sensor_states[sensor.0_0_0_0_media_disk_free-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -441,7 +490,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_media_free', + 'entity_id': 'sensor.0_0_0_0_media_disk_free', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -451,32 +500,32 @@ }), 'original_device_class': , 'original_icon': 'mdi:harddisk', - 'original_name': '/media free', + 'original_name': '/media disk free', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'disk_free', 'unique_id': 'test-/media-disk_free', 'unit_of_measurement': , }) # --- -# name: test_sensor_states[sensor.0_0_0_0_media_free-state] +# name: test_sensor_states[sensor.0_0_0_0_media_disk_free-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 /media free', + 'friendly_name': '0.0.0.0 /media disk free', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_media_free', + 'entity_id': 'sensor.0_0_0_0_media_disk_free', 'last_changed': , 'last_updated': , 'state': '426.5', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_media_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_media_disk_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -490,56 +539,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_media_used', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:harddisk', - 'original_name': '/media used', - 'platform': 'glances', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-/media-disk_use', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_media_used-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 /media used', - 'icon': 'mdi:harddisk', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.0_0_0_0_media_used', - 'last_changed': , - 'last_updated': , - 'state': '30.7', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-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.0_0_0_0_media_used_percent', + 'entity_id': 'sensor.0_0_0_0_media_disk_usage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -549,30 +549,176 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': '/media used percent', + 'original_name': '/media disk usage', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'disk_usage', 'unique_id': 'test-/media-disk_use_percent', 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_media_used_percent-state] +# name: test_sensor_states[sensor.0_0_0_0_media_disk_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 /media used percent', + 'friendly_name': '0.0.0.0 /media disk usage', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.0_0_0_0_media_used_percent', + 'entity_id': 'sensor.0_0_0_0_media_disk_usage', 'last_changed': , 'last_updated': , 'state': '6.7', }) # --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_used-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.0_0_0_0_media_disk_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/media disk used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': 'test-/media-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_media_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /media disk used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_media_disk_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_free-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.0_0_0_0_memory_free', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:memory', + 'original_name': 'Memory free', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_free', + 'unique_id': 'test--memory_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_free-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 Memory free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_memory_free', + 'last_changed': , + 'last_updated': , + 'state': '2745.0', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:memory', + 'original_name': 'Memory usage', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'memory_usage', + 'unique_id': 'test--memory_use_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '0.0.0.0 Memory usage', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_memory_usage', + 'last_changed': , + 'last_updated': , + 'state': '27.6', + }) +# --- # name: test_sensor_states[sensor.0_0_0_0_na_temp_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -597,11 +743,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'na_temp Temperature', + 'original_name': 'na_temp temperature', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'temperature', 'unique_id': 'test-na_temp-temperature_hdd', 'unit_of_measurement': , }) @@ -610,7 +756,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '0.0.0.0 na_temp Temperature', + 'friendly_name': '0.0.0.0 na_temp temperature', 'state_class': , 'unit_of_measurement': , }), @@ -621,7 +767,7 @@ 'state': 'unavailable', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_ram_free-entry] +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_free-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -635,153 +781,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_ram_free', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:memory', - 'original_name': 'RAM free', - 'platform': 'glances', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test--memory_free', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ram_free-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 RAM free', - 'icon': 'mdi:memory', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.0_0_0_0_ram_free', - 'last_changed': , - 'last_updated': , - 'state': '2745.0', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ram_used-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.0_0_0_0_ram_used', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:memory', - 'original_name': 'RAM used', - 'platform': 'glances', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test--memory_use', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ram_used-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 RAM used', - 'icon': 'mdi:memory', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.0_0_0_0_ram_used', - 'last_changed': , - 'last_updated': , - 'state': '1047.1', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-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.0_0_0_0_ram_used_percent', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:memory', - 'original_name': 'RAM used percent', - 'platform': 'glances', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test--memory_use_percent', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ram_used_percent-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 RAM used percent', - 'icon': 'mdi:memory', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.0_0_0_0_ram_used_percent', - 'last_changed': , - 'last_updated': , - 'state': '27.6', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_free-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.0_0_0_0_ssl_free', + 'entity_id': 'sensor.0_0_0_0_ssl_disk_free', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -791,32 +791,32 @@ }), 'original_device_class': , 'original_icon': 'mdi:harddisk', - 'original_name': '/ssl free', + 'original_name': '/ssl disk free', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'disk_free', 'unique_id': 'test-/ssl-disk_free', 'unit_of_measurement': , }) # --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_free-state] +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_free-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 /ssl free', + 'friendly_name': '0.0.0.0 /ssl disk free', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.0_0_0_0_ssl_free', + 'entity_id': 'sensor.0_0_0_0_ssl_disk_free', 'last_changed': , 'last_updated': , 'state': '426.5', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_used-entry] +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -830,56 +830,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.0_0_0_0_ssl_used', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:harddisk', - 'original_name': '/ssl used', - 'platform': 'glances', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-/ssl-disk_use', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_used-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': '0.0.0.0 /ssl used', - 'icon': 'mdi:harddisk', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.0_0_0_0_ssl_used', - 'last_changed': , - 'last_updated': , - 'state': '30.7', - }) -# --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-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.0_0_0_0_ssl_used_percent', + 'entity_id': 'sensor.0_0_0_0_ssl_disk_usage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -889,27 +840,76 @@ }), 'original_device_class': None, 'original_icon': 'mdi:harddisk', - 'original_name': '/ssl used percent', + 'original_name': '/ssl disk usage', 'platform': 'glances', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'disk_usage', 'unique_id': 'test-/ssl-disk_use_percent', 'unit_of_measurement': '%', }) # --- -# name: test_sensor_states[sensor.0_0_0_0_ssl_used_percent-state] +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '0.0.0.0 /ssl used percent', + 'friendly_name': '0.0.0.0 /ssl disk usage', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.0_0_0_0_ssl_used_percent', + 'entity_id': 'sensor.0_0_0_0_ssl_disk_usage', 'last_changed': , 'last_updated': , 'state': '6.7', }) # --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_used-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.0_0_0_0_ssl_disk_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:harddisk', + 'original_name': '/ssl disk used', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': 'test-/ssl-disk_use', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_ssl_disk_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '0.0.0.0 /ssl disk used', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_ssl_disk_used', + 'last_changed': , + 'last_updated': , + 'state': '30.7', + }) +# --- diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 7369bb927ff..aeef1de0b09 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,9 +1,7 @@ """Tests for glances sensors.""" -import pytest from syrupy import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -29,48 +27,3 @@ async def test_sensor_states( assert hass.states.get(entity_entry.entity_id) == snapshot( name=f"{entity_entry.entity_id}-state" ) - - -@pytest.mark.parametrize( - ("object_id", "old_unique_id", "new_unique_id"), - [ - ( - "glances_ssl_used_percent", - "0.0.0.0-Glances /ssl used percent", - "/ssl-disk_use_percent", - ), - ( - "glances_cpu_thermal_1_temperature", - "0.0.0.0-Glances cpu_thermal 1 Temperature", - "cpu_thermal 1-temperature_core", - ), - ], -) -async def test_migrate_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - object_id: str, - old_unique_id: str, - new_unique_id: str, -) -> None: - """Test unique id migration.""" - old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} - entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) - entry.add_to_hass(hass) - - entity: er.RegistryEntry = entity_registry.async_get_or_create( - suggested_object_id=object_id, - disabled_by=None, - domain=SENSOR_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=entry, - ) - assert entity.unique_id == old_unique_id - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - entity_migrated = entity_registry.async_get(entity.entity_id) - assert entity_migrated - assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" From 10d5382ae62959602de3fc2a22079058817a4587 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 14 Jan 2024 11:22:02 +0100 Subject: [PATCH 0607/1544] Dynamically adjust Netatmo polling frequency (#106742) --- .../components/netatmo/data_handler.py | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index e1d100f773e..d132fc16c7d 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -17,6 +17,7 @@ from pyatmo.modules.device_types import ( DeviceType as NetatmoDeviceType, ) +from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import ( @@ -69,6 +70,10 @@ PUBLISHERS = { } BATCH_SIZE = 3 +DEV_FACTOR = 7 +DEV_LIMIT = 400 +CLOUD_FACTOR = 2 +CLOUD_LIMIT = 150 DEFAULT_INTERVALS = { ACCOUNT: 10800, HOME: 300, @@ -126,6 +131,7 @@ class NetatmoDataHandler: """Manages the Netatmo data handling.""" account: pyatmo.AsyncAccount + _interval_factor: int def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize self.""" @@ -135,6 +141,14 @@ class NetatmoDataHandler: self.publisher: dict[str, NetatmoPublisher] = {} self._queue: deque = deque() self._webhook: bool = False + if config_entry.data["auth_implementation"] == cloud.DOMAIN: + self._interval_factor = CLOUD_FACTOR + self._rate_limit = CLOUD_LIMIT + else: + self._interval_factor = DEV_FACTOR + self._rate_limit = DEV_LIMIT + self.poll_start = time() + self.poll_count = 0 async def async_setup(self) -> None: """Set up the Netatmo data handler.""" @@ -167,16 +181,29 @@ class NetatmoDataHandler: We do up to BATCH_SIZE calls in one update in order to minimize the calls on the api service. """ - for data_class in islice(self._queue, 0, BATCH_SIZE): + for data_class in islice(self._queue, 0, BATCH_SIZE * self._interval_factor): if data_class.next_scan > time(): continue if publisher := data_class.name: - self.publisher[publisher].next_scan = time() + data_class.interval + error = await self.async_fetch_data(publisher) - await self.async_fetch_data(publisher) + if error: + self.publisher[publisher].next_scan = ( + time() + data_class.interval * 10 + ) + else: + self.publisher[publisher].next_scan = time() + data_class.interval self._queue.rotate(BATCH_SIZE) + cph = self.poll_count / (time() - self.poll_start) * 3600 + _LOGGER.debug("Calls per hour: %i", cph) + if cph > self._rate_limit: + for publisher in self.publisher.values(): + publisher.next_scan += 60 + if (time() - self.poll_start) > 3600: + self.poll_start = time() + self.poll_count = 0 @callback def async_force_update(self, signal_name: str) -> None: @@ -198,31 +225,29 @@ class NetatmoDataHandler: _LOGGER.debug("%s camera reconnected", MANUFACTURER) self.async_force_update(ACCOUNT) - async def async_fetch_data(self, signal_name: str) -> None: + async def async_fetch_data(self, signal_name: str) -> bool: """Fetch data and notify.""" + self.poll_count += 1 + has_error = False try: await getattr(self.account, self.publisher[signal_name].method)( **self.publisher[signal_name].kwargs ) - except pyatmo.NoDevice as err: + except (pyatmo.NoDevice, pyatmo.ApiError) as err: _LOGGER.debug(err) + has_error = True - except pyatmo.ApiError as err: + except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.debug(err) - - except asyncio.TimeoutError as err: - _LOGGER.debug(err) - return - - except aiohttp.ClientConnectorError as err: - _LOGGER.debug(err) - return + return True for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() + return has_error + async def subscribe( self, publisher: str, @@ -239,10 +264,11 @@ class NetatmoDataHandler: if publisher == "public": kwargs = {"area_id": self.account.register_public_weather_area(**kwargs)} + interval = int(DEFAULT_INTERVALS[publisher] / self._interval_factor) self.publisher[signal_name] = NetatmoPublisher( name=signal_name, - interval=DEFAULT_INTERVALS[publisher], - next_scan=time() + DEFAULT_INTERVALS[publisher], + interval=interval, + next_scan=time() + interval, subscriptions={update_callback}, method=PUBLISHERS[publisher], kwargs=kwargs, From b034d6d0a12331dffeb02d4398b350575498a543 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 14 Jan 2024 11:25:45 +0100 Subject: [PATCH 0608/1544] Bump plugwise to v0.36.2 (#108012) --- .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/conftest.py | 2 +- .../fixtures/m_adam_cooling/all_data.json | 52 +++++++++----- .../fixtures/m_adam_cooling/device_list.json | 2 +- .../fixtures/m_adam_heating/all_data.json | 69 ++++++++----------- .../fixtures/m_adam_heating/device_list.json | 2 +- .../{adam_jip => m_adam_jip}/all_data.json | 4 +- .../{adam_jip => m_adam_jip}/device_list.json | 0 .../notifications.json | 0 11 files changed, 72 insertions(+), 65 deletions(-) rename tests/components/plugwise/fixtures/{adam_jip => m_adam_jip}/all_data.json (98%) rename tests/components/plugwise/fixtures/{adam_jip => m_adam_jip}/device_list.json (100%) rename tests/components/plugwise/fixtures/{adam_jip => m_adam_jip}/notifications.json (100%) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 92923e98d2c..3476360082a 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.35.3"], + "requirements": ["plugwise==0.36.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3858efa640a..e9d0901fb1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1505,7 +1505,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.35.3 +plugwise==0.36.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ded65914e01..b5f161e54d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1164,7 +1164,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.35.3 +plugwise==0.36.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index a97d312cd54..4d81956eacb 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -159,7 +159,7 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: @pytest.fixture def mock_smile_adam_4() -> Generator[None, MagicMock, None]: """Create a 4th Mock Adam environment for testing exceptions.""" - chosen_env = "adam_jip" + chosen_env = "m_adam_jip" with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 7b570a6cf61..d9bf85b4701 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -13,7 +13,7 @@ "maximum_boiler_temperature": { "lower_bound": 25.0, "resolution": 0.01, - "setpoint": 60.0, + "setpoint": 50.0, "upper_bound": 95.0 }, "model": "Generic heater", @@ -37,8 +37,8 @@ "sensors": { "battery": 99, "temperature": 21.6, - "temperature_difference": 2.3, - "valve_position": 0.0 + "temperature_difference": -0.2, + "valve_position": 100 }, "temperature_offset": { "lower_bound": -2.0, @@ -47,19 +47,25 @@ "upper_bound": 2.0 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" + "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "asleep", + "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", "model": "ThermoTouch", "name": "Anna", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { @@ -79,31 +85,39 @@ "plugwise_notification": false }, "dev_class": "gateway", - "firmware": "3.6.4", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345670001", + "mac_address": "012345679891", "model": "Gateway", "name": "Adam", "regulation_modes": [ - "heating", - "off", - "bleeding_cold", "bleeding_hot", + "bleeding_cold", + "off", + "heating", "cooling" ], + "select_gateway_mode": "full", "select_regulation_mode": "cooling", "sensors": { "outdoor_temperature": 29.65 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], - "control_state": "off", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -111,10 +125,10 @@ "mode": "auto", "model": "Lisa", "name": "Lisa Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 56, + "battery": 38, "setpoint": 23.5, "temperature": 23.9 }, @@ -131,7 +145,7 @@ "upper_bound": 99.9 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" + "zigbee_mac_address": "000D6F000C869B61" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", @@ -150,7 +164,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 145, + "item_count": 147, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json index f78b4cd38a9..35fe367eb34 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -1,8 +1,8 @@ [ "da224107914542988a88561b4452b0f6", "056ee145a816487eaa69243c3280f8bf", + "e2f4322d57924fa090fbbc48b3a140dc", "ad4838d7d35c4d6ea796ee12ae5aedf8", "1772a4ea304041adb83f357b751341ff", - "e2f4322d57924fa090fbbc48b3a140dc", "e8ef2a01ed3b4139a53bf749204fe6b4" ] diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 57259047698..37fc73009d3 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,28 +1,5 @@ { "devices": { - "01234567890abcdefghijklmnopqrstu": { - "available": false, - "dev_class": "thermo_sensor", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Tom/Floor", - "name": "Tom Badkamer", - "sensors": { - "battery": 99, - "temperature": 18.6, - "temperature_difference": 2.3, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, "056ee145a816487eaa69243c3280f8bf": { "available": true, "binary_sensors": { @@ -41,7 +18,7 @@ "maximum_boiler_temperature": { "lower_bound": 25.0, "resolution": 0.01, - "setpoint": 60.0, + "setpoint": 50.0, "upper_bound": 95.0 }, "model": "Generic heater", @@ -65,8 +42,8 @@ "sensors": { "battery": 99, "temperature": 18.6, - "temperature_difference": 2.3, - "valve_position": 0.0 + "temperature_difference": -0.2, + "valve_position": 100 }, "temperature_offset": { "lower_bound": -2.0, @@ -75,19 +52,25 @@ "upper_bound": 2.0 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" + "zigbee_mac_address": "000D6F000C8FF5EE" }, "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "active_preset": "asleep", + "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", "name": "Anna", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Weekschema", "selected_schedule": "None", "sensors": { @@ -107,24 +90,32 @@ "plugwise_notification": false }, "dev_class": "gateway", - "firmware": "3.6.4", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345670001", + "mac_address": "012345679891", "model": "Gateway", "name": "Adam", - "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], + "select_gateway_mode": "full", "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": -1.25 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" + "zigbee_mac_address": "000D6F000D5A168D" }, "e2f4322d57924fa090fbbc48b3a140dc": { "active_preset": "home", "available": true, - "available_schedules": ["Weekschema", "Badkamer", "Test", "off"], + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", @@ -133,10 +124,10 @@ "mode": "auto", "model": "Lisa", "name": "Lisa Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 56, + "battery": 38, "setpoint": 15.0, "temperature": 17.9 }, @@ -153,7 +144,7 @@ "upper_bound": 99.9 }, "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" + "zigbee_mac_address": "000D6F000C869B61" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", @@ -172,7 +163,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 145, + "item_count": 147, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json index f78b4cd38a9..35fe367eb34 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -1,8 +1,8 @@ [ "da224107914542988a88561b4452b0f6", "056ee145a816487eaa69243c3280f8bf", + "e2f4322d57924fa090fbbc48b3a140dc", "ad4838d7d35c4d6ea796ee12ae5aedf8", "1772a4ea304041adb83f357b751341ff", - "e2f4322d57924fa090fbbc48b3a140dc", "e8ef2a01ed3b4139a53bf749204fe6b4" ] diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json similarity index 98% rename from tests/components/plugwise/fixtures/adam_jip/all_data.json rename to tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 37566e1d39e..915f438c105 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -193,12 +193,14 @@ }, "dev_class": "gateway", "firmware": "3.2.8", + "gateway_modes": ["away", "full", "vacation"], "hardware": "AME Smile 2.0 board", "location": "9e4433a9d69f40b3aefd15e74395eaec", "mac_address": "012345670001", "model": "Gateway", "name": "Adam", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_gateway_mode": "full", "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": 24.9 @@ -304,7 +306,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 219, + "item_count": 221, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/m_adam_jip/device_list.json similarity index 100% rename from tests/components/plugwise/fixtures/adam_jip/device_list.json rename to tests/components/plugwise/fixtures/m_adam_jip/device_list.json diff --git a/tests/components/plugwise/fixtures/adam_jip/notifications.json b/tests/components/plugwise/fixtures/m_adam_jip/notifications.json similarity index 100% rename from tests/components/plugwise/fixtures/adam_jip/notifications.json rename to tests/components/plugwise/fixtures/m_adam_jip/notifications.json From 75ba879c3405f82aa186601d3620b961143dadb2 Mon Sep 17 00:00:00 2001 From: Numa Perez <41305393+nprez83@users.noreply.github.com> Date: Sun, 14 Jan 2024 05:27:48 -0500 Subject: [PATCH 0609/1544] Fix autoChangeoverActive for lyric LCC devices (#106925) --- homeassistant/components/lyric/climate.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index e2504232c68..332ef3fec16 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -432,11 +432,23 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ) async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: + """Set hvac mode for LCC devices (e.g., T5,6).""" _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) + # Set autoChangeoverActive to True if the mode being passed is Auto + # otherwise leave unchanged. + if ( + LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL + and not self.device.changeableValues.autoChangeoverActive + ): + auto_changeover = True + else: + auto_changeover = None + await self._update_thermostat( self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode], + autoChangeoverActive=auto_changeover, ) async def async_set_preset_mode(self, preset_mode: str) -> None: From 5d8bf8627915c417fcad22aa33e4ade61fc20800 Mon Sep 17 00:00:00 2001 From: Numa Perez <41305393+nprez83@users.noreply.github.com> Date: Sun, 14 Jan 2024 05:29:03 -0500 Subject: [PATCH 0610/1544] Fix lyric TCC set temperature when in Auto mode (#106853) --- homeassistant/components/lyric/climate.py | 33 ++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 332ef3fec16..90d9e407cb2 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -182,6 +182,12 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device: LyricDevice, ) -> None: """Initialize Honeywell Lyric climate entity.""" + # Define thermostat type (TCC - e.g., Lyric round; LCC - e.g., T5,6) + if device.changeableValues.thermostatSetpointStatus: + self._attr_thermostat_type = LyricThermostatType.LCC + else: + self._attr_thermostat_type = LyricThermostatType.TCC + # Use the native temperature unit from the device settings if device.units == "Fahrenheit": self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT @@ -207,12 +213,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) # Setup supported features - if device.changeableValues.thermostatSetpointStatus: + if self._attr_thermostat_type is LyricThermostatType.LCC: self._attr_supported_features = SUPPORT_FLAGS_LCC - self._attr_thermostat_type = LyricThermostatType.LCC else: self._attr_supported_features = SUPPORT_FLAGS_TCC - self._attr_thermostat_type = LyricThermostatType.TCC # Setup supported fan modes if device_fan_modes := device.settings.attributes.get("fan", {}).get( @@ -328,20 +332,19 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if device.changeableValues.autoChangeoverActive: + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL: if target_temp_low is None or target_temp_high is None: raise HomeAssistantError( "Could not find target_temp_low and/or target_temp_high in" " arguments" ) - # If the device supports "Auto" mode, don't pass the mode when setting the - # temperature - mode = ( - None - if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL - else HVAC_MODES[device.changeableValues.heatCoolMode] - ) + # If TCC device pass the heatCoolMode value, otherwise + # if LCC device can skip the mode altogether + if self._attr_thermostat_type is LyricThermostatType.TCC: + mode = HVAC_MODES[device.changeableValues.heatCoolMode] + else: + mode = None _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: @@ -385,12 +388,12 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self.coordinator.async_refresh() async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None: + """Set hvac mode for TCC devices (e.g., Lyric round).""" if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL: # If the system is off, turn it to Heat first then to Auto, - # otherwise it turns to. - # Auto briefly and then reverts to Off (perhaps related to - # heatCoolMode). This is the behavior that happens with the - # native app as well, so likely a bug in the api itself + # otherwise it turns to Auto briefly and then reverts to Off. + # This is the behavior that happens with the native app as well, + # so likely a bug in the api itself. if HVAC_MODES[self.device.changeableValues.mode] == HVACMode.OFF: _LOGGER.debug( "HVAC mode passed to lyric: %s", From acbc2350d0f9d1862e56c4a539d4ab28005a0b27 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 14 Jan 2024 11:45:31 +0100 Subject: [PATCH 0611/1544] Update sentry-sdk to 1.39.2 (#108010) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 2af110564e7..3c3eaeb78e3 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.37.1"] + "requirements": ["sentry-sdk==1.39.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9d0901fb1d..ab4e508862d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2476,7 +2476,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.6.2 # homeassistant.components.sentry -sentry-sdk==1.37.1 +sentry-sdk==1.39.2 # homeassistant.components.sfr_box sfrbox-api==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5f161e54d2..d828a65d21c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1871,7 +1871,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.6.2 # homeassistant.components.sentry -sentry-sdk==1.37.1 +sentry-sdk==1.39.2 # homeassistant.components.sfr_box sfrbox-api==0.0.8 From f808c2ff14d953adbe38f76c2dbe92e8cc41e7ae Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 14 Jan 2024 11:47:20 +0100 Subject: [PATCH 0612/1544] Add Netatmo fan platform (#107989) * Add fan platform to support NLLF centralized ventilation devices * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker * Update tests/components/netatmo/test_fan.py Co-authored-by: Joost Lekkerkerker * add snapshots * update snapshot * fix docstring * address comment --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/netatmo/const.py | 1 + .../components/netatmo/data_handler.py | 2 + homeassistant/components/netatmo/fan.py | 87 +++++++++++++++++++ .../components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../netatmo/fixtures/homesdata.json | 24 ++++- .../homestatus_91763b24c43d3e344f424e8b.json | 22 +++++ .../netatmo/snapshots/test_cover.ambr | 47 ++++++++++ .../netatmo/snapshots/test_diagnostics.ambr | 19 ++++ .../netatmo/snapshots/test_fan.ambr | 56 ++++++++++++ .../netatmo/snapshots/test_init.ambr | 28 ++++++ tests/components/netatmo/test_fan.py | 70 +++++++++++++++ 13 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/netatmo/fan.py create mode 100644 tests/components/netatmo/snapshots/test_fan.ambr create mode 100644 tests/components/netatmo/test_fan.py diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 3fe456dd657..416c5668eae 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -42,6 +42,7 @@ NETATMO_CREATE_CAMERA = "netatmo_create_camera" NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" NETATMO_CREATE_CLIMATE = "netatmo_create_climate" NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_FAN = "netatmo_create_fan" NETATMO_CREATE_LIGHT = "netatmo_create_light" NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" NETATMO_CREATE_SELECT = "netatmo_create_select" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index d132fc16c7d..bfc77a09548 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -37,6 +37,7 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_CLIMATE, NETATMO_CREATE_COVER, + NETATMO_CREATE_FAN, NETATMO_CREATE_LIGHT, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SELECT, @@ -356,6 +357,7 @@ class NetatmoDataHandler: NETATMO_CREATE_SENSOR, ], NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR], + NetatmoDeviceCategory.fan: [NETATMO_CREATE_FAN], } for module in home.modules.values(): if not module.device_category: diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py new file mode 100644 index 00000000000..8f22861a249 --- /dev/null +++ b/homeassistant/components/netatmo/fan.py @@ -0,0 +1,87 @@ +"""Support for Netatmo/Bubendorff fans.""" +from __future__ import annotations + +import logging +from typing import Final, cast + +from pyatmo import modules as NaModules + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .entity import NetatmoBaseEntity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PERCENTAGE: Final = 50 + +PRESET_MAPPING = {"slow": 1, "fast": 2} +PRESETS = {v: k for k, v in PRESET_MAPPING.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo fan platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoFan(netatmo_device) + _LOGGER.debug("Adding cover %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_FAN, _create_entity) + ) + + +class NetatmoFan(NetatmoBaseEntity, FanEntity): + """Representation of a Netatmo fan.""" + + _attr_preset_modes = ["slow", "fast"] + _attr_supported_features = FanEntityFeature.PRESET_MODE + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize of Netatmo fan.""" + super().__init__(netatmo_device.data_handler) + + self._fan = cast(NaModules.Fan, netatmo_device.device) + + self._id = self._fan.entity_id + self._attr_name = self._device_name = self._fan.name + self._model = self._fan.device_type + self._config_url = CONF_URL_CONTROL + + self._home_id = self._fan.home.entity_id + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + + self._attr_unique_id = f"{self._id}-{self._model}" + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._fan.async_set_fan_speed(PRESET_MAPPING[preset_mode]) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if self._fan.fan_speed is None: + self._attr_preset_mode = None + return + self._attr_preset_mode = PRESETS.get(self._fan.fan_speed) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index aee63e60016..98734bcb742 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.2"] + "requirements": ["pyatmo==8.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab4e508862d..16d382a2b28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1654,7 +1654,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d828a65d21c..28da76730a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1274,7 +1274,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.2 +pyatmo==8.0.3 # homeassistant.components.apple_tv pyatv==0.14.3 diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index 6b24a7f8f9d..ccc71dc6b41 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -23,7 +23,8 @@ "12:34:56:00:f1:62", "12:34:56:10:f1:66", "12:34:56:00:e3:9b", - "0009999992" + "0009999992", + "0009999993" ] }, { @@ -174,7 +175,7 @@ "name": "module iDiamant", "setup_date": 1562262465, "room_id": "222452125", - "modules_bridged": ["0009999992"] + "modules_bridged": ["0009999992", "0009999993"] }, { "id": "0009999992", @@ -184,6 +185,14 @@ "room_id": "3688132631", "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "name": "Bubendorff blind", + "setup_date": 1594132017, + "room_id": "3688132631", + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:80:bb:26", "type": "NAMain", @@ -310,7 +319,8 @@ "12:34:56:80:00:c3:69:3c", "12:34:56:00:00:a1:4c:da", "12:34:56:00:01:01:01:a1", - "00:11:22:33:00:11:45:fe" + "00:11:22:33:00:11:45:fe", + "12:34:56:00:01:01:01:b1" ] }, { @@ -466,6 +476,14 @@ "setup_date": 1598367404, "room_id": "1002003001", "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "name": "Centralized ventilation controler", + "setup_date": 1598367504, + "room_id": "1002003001", + "bridge": "12:34:56:80:60:40" } ], "schedules": [ diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 736d70be11c..998cd7155b3 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -139,6 +139,18 @@ "reachable": true, "bridge": "12:34:56:30:d5:d4" }, + { + "id": "0009999993", + "type": "NBO", + "current_position": 0, + "target_position": 0, + "target_position:step": 100, + "firmware_revision": 22, + "rf_strength": 0, + "last_seen": 1671395511, + "reachable": true, + "bridge": "12:34:56:30:d5:d4" + }, { "id": "12:34:56:00:86:99", "type": "NACamDoorTag", @@ -276,6 +288,16 @@ "power": 0, "reachable": true, "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:b1", + "type": "NLLF", + "firmware_revision": 60, + "last_seen": 1657086949, + "power": 11, + "reachable": true, + "bridge": "12:34:56:80:60:40", + "fan_speed": 1 } ], "rooms": [ diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr index 58871b397e2..c83ae61b4c2 100644 --- a/tests/components/netatmo/snapshots/test_cover.ambr +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_entity[cover.bubendorff_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.bubendorff_blind', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bubendorff blind', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009999993-DeviceType.NBO', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[cover.bubendorff_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'current_position': 0, + 'device_class': 'shutter', + 'friendly_name': 'Bubendorff blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bubendorff_blind', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_entity[cover.entrance_blinds-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index f1c54901445..8ce00279b83 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -111,6 +111,7 @@ 'id': '12:34:56:30:d5:d4', 'modules_bridged': list([ '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'room_id': '222452125', @@ -125,6 +126,14 @@ 'setup_date': 1578551339, 'type': 'NBR', }), + dict({ + 'bridge': '12:34:56:30:d5:d4', + 'id': '0009999993', + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1594132017, + 'type': 'NBO', + }), dict({ 'alarm_config': dict({ 'default_alarm': list([ @@ -248,6 +257,7 @@ '12:34:56:00:00:a1:4c:da', '12:34:56:00:01:01:01:a1', '00:11:22:33:00:11:45:fe', + '12:34:56:00:01:01:01:b1', ]), 'name': '**REDACTED**', 'room_id': '1310352496', @@ -408,6 +418,14 @@ 'setup_date': 1598367404, 'type': 'NLFN', }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:01:01:01:b1', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1598367504, + 'type': 'NLLF', + }), ]), 'name': '**REDACTED**', 'persons': list([ @@ -443,6 +461,7 @@ '12:34:56:10:f1:66', '12:34:56:00:e3:9b', '0009999992', + '0009999993', ]), 'name': '**REDACTED**', 'type': 'custom', diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr new file mode 100644 index 00000000000..3b94257d983 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_entity[fan.centralized_ventilation_controler-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'slow', + 'fast', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.centralized_ventilation_controler', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Centralized ventilation controler', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12:34:56:00:01:01:01:b1-DeviceType.NLLF', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.centralized_ventilation_controler-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'friendly_name': 'Centralized ventilation controler', + 'preset_mode': 'slow', + 'preset_modes': list([ + 'slow', + 'fast', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.centralized_ventilation_controler', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 0c9e2d00f55..589d888936b 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -27,6 +27,34 @@ 'via_device_id': None, }) # --- +# name: test_devices[netatmo-0009999993] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://home.netatmo.com/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'netatmo', + '0009999993', + ), + }), + 'is_new': False, + 'manufacturer': 'Bubbendorf', + 'model': 'Orientable Shutter', + 'name': 'Bubendorff blind', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py new file mode 100644 index 00000000000..72dd579af67 --- /dev/null +++ b/tests/components/netatmo/test_fan.py @@ -0,0 +1,70 @@ +"""The tests for Netatmo fan.""" +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .common import selected_platforms, snapshot_platform_entities + +from tests.common import MockConfigEntry + + +async def test_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + netatmo_auth: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform_entities( + hass, + config_entry, + Platform.FAN, + entity_registry, + snapshot, + ) + + +async def test_switch_setup_and_services( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: + """Test setup and services.""" + with selected_platforms([Platform.FAN]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + fan_entity = "fan.centralized_ventilation_controler" + + assert hass.states.get(fan_entity).state == "on" + assert hass.states.get(fan_entity).attributes[ATTR_PRESET_MODE] == "slow" + + # Test turning switch on + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity, ATTR_PRESET_MODE: "fast"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:00:01:01:01:b1", + "fan_speed": 2, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) From 7c848d78abc8e65df35d6c9af37a59f6ada6a020 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 14 Jan 2024 11:50:12 +0100 Subject: [PATCH 0613/1544] Remove deprecated services from Litterrobot (#107882) --- .../components/litterrobot/strings.json | 24 ------- .../components/litterrobot/vacuum.py | 48 +------------- tests/components/litterrobot/test_vacuum.py | 62 ------------------- 3 files changed, 2 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 85d75e13dd2..7acfad69735 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -25,30 +25,6 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, - "issues": { - "service_deprecation_turn_off": { - "title": "Litter-Robot vaccum support for {old_service} is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "description": "Litter-Robot vaccum support for the {old_service} service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call {new_service} and select submit below to mark this issue as resolved." - } - } - } - }, - "service_deprecation_turn_on": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", - "description": "[%key:component::litterrobot::issues::service_deprecation_turn_off::fix_flow::step::confirm::description%]" - } - } - } - } - }, "entity": { "binary_sensor": { "sleeping": { diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index a86f1e4be00..681af81481d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -20,11 +20,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -79,11 +75,7 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): """Litter-Robot "Vacuum" Cleaner.""" _attr_supported_features = ( - VacuumEntityFeature.START - | VacuumEntityFeature.STATE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.TURN_ON + VacuumEntityFeature.START | VacuumEntityFeature.STATE | VacuumEntityFeature.STOP ) @property @@ -98,42 +90,6 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): f"{self.robot.status.text}{' (Sleeping)' if self.robot.is_sleeping else ''}" ) - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the cleaner on, starting a clean cycle.""" - await self.robot.set_power_status(True) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_on", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_on", - translation_placeholders={ - "old_service": "vacuum.turn_on", - "new_service": "vacuum.start", - }, - ) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the unit off, stopping any cleaning in progress as is.""" - await self.robot.set_power_status(False) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_off", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_off", - translation_placeholders={ - "old_service": "vacuum.turn_off", - "new_service": "vacuum.stop", - }, - ) - async def async_start(self) -> None: """Start a clean cycle.""" await self.robot.set_power_status(True) diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index fe77119ca5e..c2df2bc5095 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -13,8 +13,6 @@ from homeassistant.components.vacuum import ( DOMAIN as PLATFORM_DOMAIN, SERVICE_START, SERVICE_STOP, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, STATE_DOCKED, STATE_ERROR, ) @@ -102,16 +100,6 @@ async def test_vacuum_with_error( [ (SERVICE_START, "start_cleaning", None), (SERVICE_STOP, "set_power_status", None), - ( - SERVICE_TURN_OFF, - "set_power_status", - {"issues": {(DOMAIN, "service_deprecation_turn_off")}}, - ), - ( - SERVICE_TURN_ON, - "set_power_status", - {"issues": {(DOMAIN, "service_deprecation_turn_on")}}, - ), ( SERVICE_SET_SLEEP_MODE, "set_sleep_mode", @@ -150,53 +138,3 @@ async def test_commands( issue_registry = ir.async_get(hass) assert set(issue_registry.issues.keys()) == issues - - -@pytest.mark.parametrize( - ("service", "issue_id", "placeholders"), - [ - ( - SERVICE_TURN_OFF, - "service_deprecation_turn_off", - { - "old_service": "vacuum.turn_off", - "new_service": "vacuum.stop", - }, - ), - ( - SERVICE_TURN_ON, - "service_deprecation_turn_on", - { - "old_service": "vacuum.turn_on", - "new_service": "vacuum.start", - }, - ), - ], -) -async def test_issues( - hass: HomeAssistant, - mock_account: MagicMock, - caplog: pytest.LogCaptureFixture, - service: str, - issue_id: str, - placeholders: dict[str, str], -) -> None: - """Test issues raised by calling deprecated services.""" - await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.state == STATE_DOCKED - - await hass.services.async_call( - PLATFORM_DOMAIN, - service, - {ATTR_ENTITY_ID: VACUUM_ENTITY_ID}, - blocking=True, - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue.is_fixable is True - assert issue.is_persistent is True - assert issue.translation_placeholders == placeholders From 1cdfb06d7719175bbf5c90c6787c2596afe9c4e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Jan 2024 00:50:40 -1000 Subject: [PATCH 0614/1544] Add cached_property to State.name (#108011) --- homeassistant/components/template/template_entity.py | 9 +++++++-- homeassistant/core.py | 8 +++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f9c61850e58..9d08980da32 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Mapping import contextlib import itertools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -56,6 +56,11 @@ from .const import ( CONF_PICTURE, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( @@ -294,7 +299,7 @@ class TemplateEntity(Entity): super().__init__("unknown.unknown", STATE_UNKNOWN) self.entity_id = None # type: ignore[assignment] - @property + @cached_property def name(self) -> str: """Name of this state.""" return "" diff --git a/homeassistant/core.py b/homeassistant/core.py index bb84e7597b6..ebd40330d13 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -46,7 +46,6 @@ import voluptuous as vol import yarl from . import block_async_io, util -from .backports.functools import cached_property from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -108,11 +107,14 @@ from .util.unit_system import ( # Typing imports that create a circular dependency if TYPE_CHECKING: + from functools import cached_property + from .auth import AuthManager from .components.http import ApiConfig, HomeAssistantHTTP from .config_entries import ConfigEntries from .helpers.entity import StateInfo - +else: + from .backports.functools import cached_property STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 STOP_STAGE_SHUTDOWN_TIMEOUT = 100 @@ -1436,7 +1438,7 @@ class State: self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) - @property + @cached_property def name(self) -> str: """Name of this state.""" return self.attributes.get(ATTR_FRIENDLY_NAME) or self.object_id.replace( From d94421e1a4bae6ecc9663929482553536b284aad Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sun, 14 Jan 2024 15:19:43 +0100 Subject: [PATCH 0615/1544] Reset UniFi bandwidth sensor when client misses heartbeat (#104522) * Reset UniFi bandwidth sensor when client misses heartbeat * Fix initialization sequence * Code simplification: remove heartbeat_timedelta, unique_id and tracker logic * Add unit tests * Remove unused _is_connected attribute * Remove redundant async_initiate_state * Make is_connected_fn optional, heartbeat detection will only happen if not None * Add checks on is_connected_fn --- homeassistant/components/unifi/sensor.py | 61 +++++++++++++++++++++++- tests/components/unifi/test_sensor.py | 28 ++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index c7b851a8fbb..ef158b99e4e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -32,7 +32,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event as core_Event, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -132,6 +133,20 @@ def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) - return controller.api.devices[obj_id].outlet_ac_power_budget is not None +@callback +def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client was last seen recently.""" + client = controller.api.clients[obj_id] + + if ( + dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + > controller.option_detection_time + ): + return False + + return True + + @dataclass(frozen=True) class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -153,6 +168,8 @@ class UnifiSensorEntityDescription( ): """Class describing UniFi sensor entity.""" + is_connected_fn: Callable[[UniFiController, str], bool] | None = None + ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( @@ -169,6 +186,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, + is_connected_fn=async_client_is_connected_fn, name_fn=lambda _: "RX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, @@ -190,6 +208,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, + is_connected_fn=async_client_is_connected_fn, name_fn=lambda _: "TX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, @@ -388,6 +407,16 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] + @callback + def _make_disconnected(self, *_: core_Event) -> None: + """No heart beat by device. + + Reset sensor value to 0 when client device is disconnected + """ + if self._attr_native_value != 0: + self._attr_native_value = 0 + self.async_write_ha_state() + @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: """Update entity state. @@ -398,3 +427,33 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): obj = description.object_fn(self.controller.api, self._obj_id) if (value := description.value_fn(self.controller, obj)) != self.native_value: self._attr_native_value = value + + if description.is_connected_fn is not None: + # Send heartbeat if client is connected + if description.is_connected_fn(self.controller, self._obj_id): + self.controller.async_heartbeat( + self._attr_unique_id, + dt_util.utcnow() + self.controller.option_detection_time, + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if self.entity_description.is_connected_fn is not None: + # Register callback for missed heartbeat + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", + self._make_disconnected, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect object when removed.""" + await super().async_will_remove_from_hass() + + if self.entity_description.is_connected_fn is not None: + # Remove heartbeat registration + self.controller.async_heartbeat(self._attr_unique_id) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 6eb6c05209c..1a3c81ec4c4 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -22,6 +22,7 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, DEVICE_STATES, + DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -393,6 +394,31 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" assert hass.states.get("sensor.wireless_client_tx").state == "7891.0" + # Verify reset sensor after heartbeat expires + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + new_time = dt_util.utcnow() + wireless_client["last_seen"] = dt_util.as_timestamp(new_time) + + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) + await hass.async_block_till_done() + + with freeze_time(new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" + assert hass.states.get("sensor.wireless_client_tx").state == "7891.0" + + new_time = new_time + controller.option_detection_time + timedelta(seconds=1) + + with freeze_time(new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wireless_client_rx").state == "0" + assert hass.states.get("sensor.wireless_client_tx").state == "0" + # Disable option options[CONF_ALLOW_BANDWIDTH_SENSORS] = False From 7a6dca098782c03b829695721ed49b3b1fac39e5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 14 Jan 2024 15:34:14 +0100 Subject: [PATCH 0616/1544] Sensibo include mac in diagnostics redact filter (#107986) * Add mac to redaction in Sensibo diagnostics * Add full snapshot * use constant --- .../components/sensibo/diagnostics.py | 1 + .../sensibo/snapshots/test_diagnostics.ambr | 1429 +++++++++++++++++ tests/components/sensibo/test_diagnostics.py | 8 + 3 files changed, 1438 insertions(+) diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index 9d998e739f0..32ad07871a3 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -14,6 +14,7 @@ TO_REDACT = { "location", "ssid", "id", + "mac", "macAddress", "parentDeviceUid", "qrId", diff --git a/tests/components/sensibo/snapshots/test_diagnostics.ambr b/tests/components/sensibo/snapshots/test_diagnostics.ambr index b1cda16fb4d..c911a7629be 100644 --- a/tests/components/sensibo/snapshots/test_diagnostics.ambr +++ b/tests/components/sensibo/snapshots/test_diagnostics.ambr @@ -240,3 +240,1432 @@ dict({ }) # --- +# name: test_diagnostics[full_snapshot] + dict({ + 'AAZZAAZZ': dict({ + 'ac_states': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + 'mode', + 'fanLevel', + 'light', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.0, + 'co2': None, + 'device_on': False, + 'etoh': None, + 'fan_mode': 'low', + 'fan_modes': list([ + 'low', + 'high', + ]), + 'fan_modes_translated': dict({ + 'high': 'high', + 'low': 'low', + }), + 'feelslike': None, + 'filter_clean': False, + 'filter_last_reset': '2022-04-23T15:58:45+00:00', + 'full_capabilities': dict({ + 'modes': dict({ + 'fan': dict({ + 'fanLevels': list([ + 'low', + 'high', + ]), + 'light': list([ + 'on', + 'dim', + 'off', + ]), + 'temperatures': dict({ + }), + }), + }), + }), + 'fw_type': 'pure-esp32', + 'fw_ver': 'PUR00111', + 'fw_ver_available': 'PUR00111', + 'horizontal_swing_mode': None, + 'horizontal_swing_modes': None, + 'horizontal_swing_modes_translated': None, + 'humidity': None, + 'hvac_mode': 'fan', + 'hvac_modes': list([ + 'fan', + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': 'on', + 'light_modes': list([ + 'on', + 'dim', + 'off', + ]), + 'light_modes_translated': dict({ + 'dim': 'dim', + 'off': 'off', + 'on': 'on', + }), + 'location_id': 'ZZZZZZZZZZZZ', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'pure', + 'motion_sensors': dict({ + }), + 'name': 'Kitchen', + 'pm25': 1, + 'pure_ac_integration': False, + 'pure_boost_enabled': False, + 'pure_conf': dict({ + 'ac_integration': False, + 'enabled': False, + 'geo_integration': False, + 'measurements_integration': True, + 'prime_integration': False, + 'sensitivity': 'N', + }), + 'pure_geo_integration': False, + 'pure_measure_integration': True, + 'pure_prime_integration': False, + 'pure_sensitivity': 'n', + 'rcda': None, + 'room_occupied': None, + 'schedules': dict({ + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + }), + 'smart_high_temp_threshold': None, + 'smart_low_state': dict({ + }), + 'smart_low_temp_threshold': None, + 'smart_on': None, + 'smart_type': None, + 'state': 'fan', + 'swing_mode': None, + 'swing_modes': None, + 'swing_modes_translated': None, + 'target_temp': None, + 'temp': None, + 'temp_list': list([ + 0, + 1, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': None, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': False, + }), + 'ABC999111': dict({ + 'ac_states': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 25, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.019722Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + 'mode', + 'fanLevel', + 'targetTemperature', + 'swing', + 'horizontalSwing', + 'light', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.1, + 'co2': None, + 'device_on': True, + 'etoh': None, + 'fan_mode': 'high', + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'fan_modes_translated': dict({ + 'low': 'low', + 'medium': 'medium', + 'quiet': 'quiet', + }), + 'feelslike': 21.2, + 'filter_clean': True, + 'filter_last_reset': '2022-03-12T15:24:26+00:00', + 'full_capabilities': dict({ + 'modes': dict({ + 'auto': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'cool': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'dry': dict({ + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'fan': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + }), + }), + 'heat': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 63, + 64, + 66, + ]), + }), + }), + }), + }), + }), + 'fw_type': 'esp8266ex', + 'fw_ver': 'SKY30046', + 'fw_ver_available': 'SKY30048', + 'horizontal_swing_mode': 'stopped', + 'horizontal_swing_modes': list([ + 'stopped', + 'fixedleft', + 'fixedcenterleft', + ]), + 'horizontal_swing_modes_translated': dict({ + 'fixedcenterleft': 'fixedCenterLeft', + 'fixedleft': 'fixedLeft', + 'stopped': 'stopped', + }), + 'humidity': 32.9, + 'hvac_mode': 'heat', + 'hvac_modes': list([ + 'cool', + 'heat', + 'dry', + 'auto', + 'fan', + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': 'on', + 'light_modes': list([ + 'on', + 'off', + ]), + 'light_modes_translated': dict({ + 'off': 'off', + 'on': 'on', + }), + 'location_id': 'ZZZZZZZZZZZZ', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'skyv2', + 'motion_sensors': dict({ + 'AABBCC': dict({ + '__type': "", + 'repr': "MotionSensor(id='AABBCC', alive=True, motion=True, fw_ver='V17', fw_type='nrf52', is_main_sensor=True, battery_voltage=3000, humidity=57, temperature=23.9, model='motion_sensor', rssi=-72)", + }), + }), + 'name': 'Hallway', + 'pm25': None, + 'pure_ac_integration': None, + 'pure_boost_enabled': None, + 'pure_conf': dict({ + }), + 'pure_geo_integration': None, + 'pure_measure_integration': None, + 'pure_prime_integration': None, + 'pure_sensitivity': None, + 'rcda': None, + 'room_occupied': True, + 'schedules': dict({ + '11': dict({ + '__type': "", + 'repr': "Schedules(id='11', enabled=False, name=None, state_on=False, state_full={'on': False, 'targettemperature': 21, 'temperatureunit': 'c', 'mode': 'heat', 'fanlevel': 'low', 'swing': 'stopped', 'extra': {'scheduler': {'climate_react': None, 'motion': None, 'on': False, 'climate_react_settings': None, 'pure_boost': None}}, 'horizontalswing': 'stopped', 'light': 'on'}, days=['wednesday', 'thursday'], time='17:40', next_utc=datetime.datetime(2022, 5, 4, 15, 40, tzinfo=datetime.timezone.utc))", + }), + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + 'fanlevel': 'high', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }), + 'smart_high_temp_threshold': 27.5, + 'smart_low_state': dict({ + 'fanlevel': 'low', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }), + 'smart_low_temp_threshold': 0.0, + 'smart_on': False, + 'smart_type': 'temperature', + 'state': 'heat', + 'swing_mode': 'stopped', + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'swing_modes_translated': dict({ + 'fixedmiddletop': 'fixedMiddleTop', + 'fixedtop': 'fixedTop', + 'stopped': 'stopped', + }), + 'target_temp': 25, + 'temp': 21.2, + 'temp_list': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': False, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': True, + }), + 'BBZZBBZZ': dict({ + 'ac_states': dict({ + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'active_features': list([ + 'timestamp', + 'on', + ]), + 'anti_mold_enabled': None, + 'anti_mold_fan_time': None, + 'anti_mold_running': None, + 'auto_off': False, + 'auto_off_minutes': None, + 'available': True, + 'calibration_hum': 0.0, + 'calibration_temp': 0.0, + 'co2': None, + 'device_on': False, + 'etoh': None, + 'fan_mode': None, + 'fan_modes': None, + 'fan_modes_translated': None, + 'feelslike': None, + 'filter_clean': False, + 'filter_last_reset': '2022-04-23T15:58:45+00:00', + 'full_capabilities': dict({ + }), + 'fw_type': 'pure-esp32', + 'fw_ver': 'PUR00111', + 'fw_ver_available': 'PUR00111', + 'horizontal_swing_mode': None, + 'horizontal_swing_modes': None, + 'horizontal_swing_modes_translated': None, + 'humidity': None, + 'hvac_mode': None, + 'hvac_modes': list([ + 'off', + ]), + 'iaq': None, + 'id': '**REDACTED**', + 'light_mode': None, + 'light_modes': None, + 'light_modes_translated': None, + 'location_id': 'ZZZZZZZZZZYY', + 'location_name': 'Home', + 'mac': '**REDACTED**', + 'model': 'pure', + 'motion_sensors': dict({ + }), + 'name': 'Bedroom', + 'pm25': 1, + 'pure_ac_integration': False, + 'pure_boost_enabled': False, + 'pure_conf': dict({ + }), + 'pure_geo_integration': False, + 'pure_measure_integration': False, + 'pure_prime_integration': False, + 'pure_sensitivity': 'n', + 'rcda': None, + 'room_occupied': None, + 'schedules': dict({ + }), + 'serial': '**REDACTED**', + 'smart_high_state': dict({ + }), + 'smart_high_temp_threshold': None, + 'smart_low_state': dict({ + }), + 'smart_low_temp_threshold': None, + 'smart_on': None, + 'smart_type': None, + 'state': 'off', + 'swing_mode': None, + 'swing_modes': None, + 'swing_modes_translated': None, + 'target_temp': None, + 'temp': None, + 'temp_list': list([ + 0, + 1, + ]), + 'temp_step': 1, + 'temp_unit': 'C', + 'timer_id': None, + 'timer_on': None, + 'timer_state_on': None, + 'timer_time': None, + 'tvoc': None, + 'update_available': False, + }), + 'raw': dict({ + 'result': list([ + dict({ + 'acState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 25, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.019722Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 32, + 'time': '2022-04-30T11:22:57.894846Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'SKY30048', + 'features': list([ + 'softShowPlus', + 'optimusTrial', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 667991, + 'filtersCleanSecondsThreshold': 1080000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 4219143, + 'time': '2022-03-12T15:24:26Z', + }), + 'shouldCleanFilters': True, + }), + 'firmwareType': 'esp8266ex', + 'firmwareVersion': 'SKY30046', + 'homekitSupported': False, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'swing': 'stopped', + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.062645Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'swing': 'stopped', + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.062688Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 120, + 'time': '2022-04-30T11:21:29Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 119, + 'time': '2022-04-30T11:21:30Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 181, + 'time': '2022-04-30T11:20:28Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': dict({ + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'firmwareType': 'nrf52', + 'firmwareVersion': 'V17', + 'id': '**REDACTED**', + 'isMainSensor': True, + 'macAddress': '**REDACTED**', + 'measurements': dict({ + 'batteryVoltage': 3000, + 'humidity': 32.9, + 'motion': False, + 'rssi': -72, + 'temperature': 21.2, + 'time': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'parentDeviceUid': '**REDACTED**', + 'productModel': 'motion_sensor', + 'qrId': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'measurements': dict({ + 'feelsLike': 21.2, + 'humidity': 32.9, + 'motion': True, + 'roomIsOccupied': True, + 'rssi': -45, + 'temperature': 21.2, + 'time': dict({ + 'secondsAgo': 32, + 'time': '2022-04-30T11:22:57.894846Z', + }), + }), + 'motionConfig': dict({ + 'enabled': True, + 'onEnterACChange': False, + 'onEnterACState': None, + 'onEnterCRChange': True, + 'onExitACChange': True, + 'onExitACState': None, + 'onExitCRChange': True, + 'onExitDelayMinutes': 20, + }), + 'motionSensors': list([ + dict({ + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'firmwareType': 'nrf52', + 'firmwareVersion': 'V17', + 'id': '**REDACTED**', + 'isMainSensor': True, + 'macAddress': '**REDACTED**', + 'measurements': dict({ + 'batteryVoltage': 3000, + 'humidity': 57, + 'motion': True, + 'rssi': -72, + 'temperature': 23.9, + 'time': dict({ + 'secondsAgo': 86, + 'time': '2022-01-01T18:59:48.665878Z', + }), + }), + 'parentDeviceUid': '**REDACTED**', + 'productModel': 'motion_sensor', + 'qrId': '**REDACTED**', + 'serial': '**REDACTED**', + }), + ]), + 'productModel': 'skyv2', + 'pureBoostConfig': None, + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + '_mitsubishi2_night_heat', + ]), + 'remoteCapabilities': dict({ + 'modes': dict({ + 'auto': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'cool': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'dry': dict({ + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'fan': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + }), + }), + 'heat': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 63, + 64, + 66, + ]), + }), + }), + }), + }), + }), + 'remoteFlavor': 'Curious Sea Cucumber', + 'room': dict({ + 'icon': 'Lounge', + 'name': 'Hallway', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': True, + 'runningHealthcheck': None, + 'schedules': list([ + dict({ + 'acState': dict({ + 'extra': dict({ + 'scheduler': dict({ + 'climate_react': None, + 'climate_react_settings': None, + 'motion': None, + 'on': False, + 'pure_boost': None, + }), + }), + 'fanLevel': 'low', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': False, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'createTime': '2022-04-17T15:41:05', + 'createTimeSecondsAgo': 1107745, + 'id': '**REDACTED**', + 'isEnabled': False, + 'name': None, + 'nextTime': '2022-05-04T15:40:00', + 'nextTimeSecondsFromNow': 360989, + 'podUid': '**REDACTED**', + 'recurringDays': list([ + 'Wednesday', + 'Thursday', + ]), + 'targetTimeLocal': '17:40', + 'timezone': 'Europe/Stockholm', + }), + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.1, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': dict({ + 'deviceUid': '**REDACTED**', + 'enabled': False, + 'highTemperatureState': dict({ + 'fanLevel': 'high', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'highTemperatureThreshold': 27.5, + 'highTemperatureWebhook': None, + 'lowTemperatureState': dict({ + 'fanLevel': 'low', + 'horizontalSwing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targetTemperature': 21, + 'temperatureUnit': 'C', + }), + 'lowTemperatureThreshold': 0.0, + 'lowTemperatureWebhook': None, + 'type': 'temperature', + }), + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 64093221, + 'time': '2020-04-18T15:43:08Z', + }), + }), + dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'PUR00111', + 'features': list([ + 'optimusTrial', + 'softShowPlus', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 415560, + 'filtersCleanSecondsThreshold': 14256000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 588284, + 'time': '2022-04-23T15:58:45Z', + }), + 'shouldCleanFilters': False, + }), + 'firmwareType': 'pure-esp32', + 'firmwareVersion': 'PUR00111', + 'homekitSupported': True, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090144Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090185Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 108, + 'time': '2022-04-30T11:21:41Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 6003, + 'time': '2022-04-30T09:43:26Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': None, + 'measurements': dict({ + 'motion': False, + 'pm25': 1, + 'roomIsOccupied': None, + 'rssi': -58, + 'time': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'motionConfig': None, + 'motionSensors': list([ + ]), + 'productModel': 'pure', + 'pureBoostConfig': dict({ + 'ac_integration': False, + 'enabled': False, + 'geo_integration': False, + 'measurements_integration': True, + 'prime_integration': False, + 'sensitivity': 'N', + }), + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + ]), + 'remoteCapabilities': dict({ + 'modes': dict({ + 'fan': dict({ + 'fanLevels': list([ + 'low', + 'high', + ]), + 'light': list([ + 'on', + 'dim', + 'off', + ]), + 'temperatures': dict({ + }), + }), + }), + }), + 'remoteFlavor': 'Eccentric Eagle', + 'room': dict({ + 'icon': 'Diningroom', + 'name': 'Kitchen', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': None, + 'runningHealthcheck': None, + 'schedules': list([ + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.0, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': None, + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 1733071, + 'time': '2022-04-10T09:58:58Z', + }), + }), + dict({ + 'acState': dict({ + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.067312Z', + }), + }), + 'accessPoint': dict({ + 'password': None, + 'ssid': '**REDACTED**', + }), + 'antiMoldConfig': None, + 'antiMoldTimer': None, + 'autoOffEnabled': False, + 'autoOffMinutes': None, + 'cleanFiltersNotificationEnabled': False, + 'configGroup': 'stable', + 'connectionStatus': dict({ + 'isAlive': True, + 'lastSeen': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'currentlyAvailableFirmwareVersion': 'PUR00111', + 'features': list([ + 'optimusTrial', + 'softShowPlus', + ]), + 'filtersCleaning': dict({ + 'acOnSecondsSinceLastFiltersClean': 415560, + 'filtersCleanSecondsThreshold': 14256000, + 'lastFiltersCleanTime': dict({ + 'secondsAgo': 588284, + 'time': '2022-04-23T15:58:45Z', + }), + 'shouldCleanFilters': False, + }), + 'firmwareType': 'pure-esp32', + 'firmwareVersion': 'PUR00111', + 'homekitSupported': True, + 'id': '**REDACTED**', + 'isClimateReactGeofenceOnEnterEnabledForThisUser': False, + 'isClimateReactGeofenceOnExitEnabled': False, + 'isGeofenceOnEnterEnabledForThisUser': False, + 'isGeofenceOnExitEnabled': False, + 'isMotionGeofenceOnEnterEnabled': False, + 'isMotionGeofenceOnExitEnabled': False, + 'isOwner': True, + 'lastACStateChange': dict({ + 'acState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090144Z', + }), + }), + 'causedByScheduleId': None, + 'causedByScheduleType': None, + 'changedProperties': list([ + 'on', + ]), + 'failureReason': None, + 'id': '**REDACTED**', + 'reason': 'UserRequest', + 'resolveTime': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'resultingAcState': dict({ + 'fanLevel': 'low', + 'light': 'on', + 'mode': 'fan', + 'on': False, + 'timestamp': dict({ + 'secondsAgo': -1, + 'time': '2022-04-30T11:23:30.090185Z', + }), + }), + 'status': 'Success', + 'time': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + }), + 'lastHealthcheck': None, + 'lastStateChange': dict({ + 'secondsAgo': 108, + 'time': '2022-04-30T11:21:41Z', + }), + 'lastStateChangeToOff': dict({ + 'secondsAgo': 112, + 'time': '2022-04-30T11:21:37Z', + }), + 'lastStateChangeToOn': dict({ + 'secondsAgo': 6003, + 'time': '2022-04-30T09:43:26Z', + }), + 'location': '**REDACTED**', + 'macAddress': '**REDACTED**', + 'mainMeasurementsSensor': None, + 'measurements': dict({ + 'motion': False, + 'pm25': 1, + 'roomIsOccupied': None, + 'rssi': -58, + 'time': dict({ + 'secondsAgo': 9, + 'time': '2022-04-30T11:23:20.642798Z', + }), + }), + 'motionConfig': None, + 'motionSensors': list([ + ]), + 'productModel': 'pure', + 'pureBoostConfig': None, + 'qrId': '**REDACTED**', + 'remote': dict({ + 'toggle': False, + 'window': False, + }), + 'remoteAlternatives': list([ + ]), + 'remoteCapabilities': None, + 'remoteFlavor': 'Eccentric Eagle', + 'room': dict({ + 'icon': 'Diningroom', + 'name': 'Bedroom', + 'uid': '**REDACTED**', + }), + 'roomIsOccupied': None, + 'runningHealthcheck': None, + 'schedules': list([ + ]), + 'sensorsCalibration': dict({ + 'humidity': 0.0, + 'temperature': 0.0, + }), + 'serial': '**REDACTED**', + 'serviceSubscriptions': list([ + ]), + 'shouldShowFilterCleaningNotification': False, + 'smartMode': None, + 'tags': list([ + ]), + 'temperatureUnit': 'C', + 'timer': None, + 'warrantyEligible': 'no', + 'warrantyEligibleUntil': dict({ + 'secondsAgo': 1733071, + 'time': '2022-04-10T09:58:58Z', + }), + }), + ]), + 'status': 'success', + }), + }) +# --- diff --git a/tests/components/sensibo/test_diagnostics.py b/tests/components/sensibo/test_diagnostics.py index bc35b7fdd57..320125e6403 100644 --- a/tests/components/sensibo/test_diagnostics.py +++ b/tests/components/sensibo/test_diagnostics.py @@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +EXCLUDE_ATTRIBUTES = {"full_features"} + async def test_diagnostics( hass: HomeAssistant, @@ -28,3 +30,9 @@ async def test_diagnostics( assert diag["ABC999111"]["smart_low_state"] == snapshot assert diag["ABC999111"]["smart_high_state"] == snapshot assert diag["ABC999111"]["pure_conf"] == snapshot + + def limit_attrs(prop, path): + exclude_attrs = EXCLUDE_ATTRIBUTES + return prop in exclude_attrs + + assert diag == snapshot(name="full_snapshot", exclude=limit_attrs) From 00165fef5bc8d34a90e1882d88131d64c1321ea2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 14 Jan 2024 18:39:45 +0100 Subject: [PATCH 0617/1544] Improve the test class used for testing FlowManager.async_show_progress (#107786) * Improve the test class used for testing FlowManager.async_show_progress * Address review comments --- tests/test_data_entry_flow.py | 37 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 744ae4dc007..130f4829ca2 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -358,30 +358,39 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: VERSION = 5 data = None start_task_two = False - progress_task: asyncio.Task[None] | None = None + task_one: asyncio.Task[None] | None = None + task_two: asyncio.Task[None] | None = None async def async_step_init(self, user_input=None): - async def long_running_task_one() -> None: + async def long_running_job_one() -> None: await task_one_evt.wait() - self.start_task_two = True - async def long_running_task_two() -> None: + async def long_running_job_two() -> None: await task_two_evt.wait() self.data = {"title": "Hello"} - if not task_one_evt.is_set(): + uncompleted_task: asyncio.Task[None] | None = None + if not self.task_one: + self.task_one = hass.async_create_task(long_running_job_one()) + + progress_action = None + if not self.task_one.done(): progress_action = "task_one" - if not self.progress_task: - self.progress_task = hass.async_create_task(long_running_task_one()) - elif not task_two_evt.is_set(): - progress_action = "task_two" - if self.start_task_two: - self.progress_task = hass.async_create_task(long_running_task_two()) - self.start_task_two = False - if not task_one_evt.is_set() or not task_two_evt.is_set(): + uncompleted_task = self.task_one + + if not uncompleted_task: + if not self.task_two: + self.task_two = hass.async_create_task(long_running_job_two()) + + if not self.task_two.done(): + progress_action = "task_two" + uncompleted_task = self.task_two + + if uncompleted_task: + assert progress_action return self.async_show_progress( progress_action=progress_action, - progress_task=self.progress_task, + progress_task=uncompleted_task, ) return self.async_show_progress_done(next_step_id="finish") From c4fd45ef97bd8fad714618ceb80b0604e051ed01 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sun, 14 Jan 2024 13:19:36 -0600 Subject: [PATCH 0618/1544] Bump SoCo to 0.30.2 (#108033) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 8ad6bf322bf..58a0ec3b7ee 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.0", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.2", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 16d382a2b28..c402377c4de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2518,7 +2518,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.30.0 +soco==0.30.2 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28da76730a3..8ba2348dacd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1901,7 +1901,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.30.0 +soco==0.30.2 # homeassistant.components.solaredge solaredge==0.0.2 From b47861d9738915240f39bf579c31e92c51f08979 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Jan 2024 09:44:42 -1000 Subject: [PATCH 0619/1544] Update shelly bluetooth scanner to version 2.0 (#107917) --- .../components/shelly/bluetooth/__init__.py | 16 ++----- .../components/shelly/bluetooth/scanner.py | 48 ------------------- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../shelly/bluetooth/test_scanner.py | 46 +++++++++++++++++- 6 files changed, 50 insertions(+), 66 deletions(-) delete mode 100644 homeassistant/components/shelly/bluetooth/scanner.py diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 2f9019ba5e6..5432ceb3a12 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from aioshelly.ble import async_start_scanner +from aioshelly.ble import async_start_scanner, create_scanner from aioshelly.ble.const import ( BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION, @@ -12,15 +12,11 @@ from aioshelly.ble.const import ( DEFAULT_WINDOW_MS, ) -from homeassistant.components.bluetooth import ( - HaBluetoothConnector, - async_register_scanner, -) +from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from homeassistant.helpers.device_registry import format_mac from ..const import BLEScannerMode -from .scanner import ShellyBLEScanner if TYPE_CHECKING: from ..coordinator import ShellyRpcCoordinator @@ -35,13 +31,7 @@ async def async_connect_scanner( device = coordinator.device entry = coordinator.entry source = format_mac(coordinator.mac).upper() - connector = HaBluetoothConnector( - # no active connections to shelly yet - client=None, # type: ignore[arg-type] - source=source, - can_connect=lambda: False, - ) - scanner = ShellyBLEScanner(source, entry.title, connector, False) + scanner = create_scanner(source, entry.title) unload_callbacks = [ async_register_scanner(hass, scanner), scanner.async_setup(), diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py deleted file mode 100644 index 7c0dc3c792a..00000000000 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Bluetooth scanner for shelly.""" -from __future__ import annotations - -from typing import Any - -from aioshelly.ble import parse_ble_scan_result_event -from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION - -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner -from homeassistant.core import callback - -from ..const import LOGGER - - -class ShellyBLEScanner(BaseHaRemoteScanner): - """Scanner for shelly.""" - - @callback - def async_on_event(self, event: dict[str, Any]) -> None: - """Process an event from the shelly and ignore if its not a ble.scan_result.""" - if event.get("event") != BLE_SCAN_RESULT_EVENT: - return - - data = event["data"] - - if data[0] != BLE_SCAN_RESULT_VERSION: - LOGGER.warning("Unsupported BLE scan result version: %s", data[0]) - return - - try: - address, rssi, parsed = parse_ble_scan_result_event(data) - except Exception as err: # pylint: disable=broad-except - # Broad exception catch because we have no - # control over the data that is coming in. - LOGGER.error("Failed to parse BLE event: %s", err, exc_info=True) - return - - self._async_on_advertisement( - address, - rssi, - parsed.local_name, - parsed.service_uuids, - parsed.service_data, - parsed.manufacturer_data, - parsed.tx_power, - {}, - MONOTONIC_TIME(), - ) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 82833bf34af..94e2e9d70f0 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==7.1.0"], + "requirements": ["aioshelly==8.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c402377c4de..d59389e7aa8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.1.0 +aioshelly==8.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ba2348dacd..bc8483c42e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -332,7 +332,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.1.0 +aioshelly==8.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index 9fe5f77f00c..d9ec0064606 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from .. import init_integration, inject_rpc_device_event -async def test_scanner(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: - """Test injecting data into the scanner.""" +async def test_scanner_v1(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test injecting data into the scanner v1.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} ) @@ -49,6 +49,48 @@ async def test_scanner(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> Non assert ble_device is None +async def test_scanner_v2(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test injecting data into the scanner v2.""" + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert mock_rpc_device.initialized is True + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "component": "script:1", + "data": [ + 2, + [ + [ + "aa:bb:cc:dd:ee:ff", + -62, + "AgEGCf9ZANH7O3TIkA==", + "EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==", + ] + ], + ], + "event": BLE_SCAN_RESULT_EVENT, + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=False + ) + assert ble_device is not None + ble_device = bluetooth.async_ble_device_from_address( + hass, "AA:BB:CC:DD:EE:FF", connectable=True + ) + assert ble_device is None + + async def test_scanner_ignores_non_ble_events( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: From 76a1e979476822ac2abef168cb60b7dddb86b291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Sun, 14 Jan 2024 21:22:27 +0100 Subject: [PATCH 0620/1544] Update framework for Airthings cloud (#107653) * Upgrade framework * Improve code for model name --- homeassistant/components/airthings/manifest.json | 2 +- homeassistant/components/airthings/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json index f8dad08c5d1..67057ff09f5 100644 --- a/homeassistant/components/airthings/manifest.json +++ b/homeassistant/components/airthings/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airthings", "iot_class": "cloud_polling", "loggers": ["airthings"], - "requirements": ["airthings-cloud==0.1.0"] + "requirements": ["airthings-cloud==0.2.0"] } diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 3802a735a99..9d772d11996 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -149,7 +149,7 @@ class AirthingsHeaterEnergySensor( identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", - model=airthings_device.device_type.replace("_", " ").lower().title(), + model=airthings_device.product_name, ) @property diff --git a/requirements_all.txt b/requirements_all.txt index d59389e7aa8..aa9200ac507 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -410,7 +410,7 @@ airly==1.1.0 airthings-ble==0.5.6-2 # homeassistant.components.airthings -airthings-cloud==0.1.0 +airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc8483c42e9..7a9d74a643b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,7 +383,7 @@ airly==1.1.0 airthings-ble==0.5.6-2 # homeassistant.components.airthings -airthings-cloud==0.1.0 +airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 From 9d47e1983e0a7247790eda78ffaef14b80051bc7 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Mon, 15 Jan 2024 01:55:02 -0500 Subject: [PATCH 0621/1544] Update asyncsleepiq to 1.4.2 (#108054) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 62bd3930c77..cac696dc5af 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.4.1"] + "requirements": ["asyncsleepiq==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa9200ac507..d3f6bbd739b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -481,7 +481,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.4.1 +asyncsleepiq==1.4.2 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a9d74a643b..f13f9b63300 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -427,7 +427,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.38.0 # homeassistant.components.sleepiq -asyncsleepiq==1.4.1 +asyncsleepiq==1.4.2 # homeassistant.components.aurora auroranoaa==0.0.3 From 45acd56861f3e498d919b751534b12005d0256ca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 15 Jan 2024 09:00:59 +0100 Subject: [PATCH 0622/1544] Remove YAML auth setup support from home_connect (#108072) --- .../components/home_connect/__init__.py | 61 +------------------ .../home_connect/test_config_flow.py | 19 +++--- 2 files changed, 11 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 7377c4b60d0..79303725249 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -7,25 +7,14 @@ import logging from requests import HTTPError import voluptuous as vol -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_DEVICE, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import ATTR_DEVICE_ID, CONF_DEVICE, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -51,20 +40,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=1) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) SERVICE_SETTING_SCHEMA = vol.Schema( { @@ -118,37 +94,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} - if DOMAIN in config: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Home Connect integration in YAML is deprecated and " - "will be removed in a future release; Your existing OAuth " - "Application Credentials have been imported into the UI " - "automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Home Connect", - }, - ) - async def _async_service_program(call, method): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 9cd45f18270..209100c71b2 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,12 +3,15 @@ from http import HTTPStatus from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.home_connect.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -26,16 +29,10 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "home_connect", - { - "home_connect": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - }, - "http": {"base_url": "https://example.com"}, - }, + assert await setup.async_setup_component(hass, "home_connect", {}) + + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( From 5bde00704886833abfb95b6c1c6a47209fb75b3f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:07:12 +0100 Subject: [PATCH 0623/1544] Enable strict typing for prometheus (#108025) --- .strict-typing | 1 + .../components/prometheus/__init__.py | 159 +++++++++++------- mypy.ini | 10 ++ 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/.strict-typing b/.strict-typing index af4bd4a9cf4..035218d8024 100644 --- a/.strict-typing +++ b/.strict-typing @@ -320,6 +320,7 @@ homeassistant.components.plugwise.* homeassistant.components.poolsense.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* +homeassistant.components.prometheus.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 308bbb599ea..e17ae1190a4 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -1,10 +1,15 @@ """Support for Prometheus metrics export.""" +from __future__ import annotations + +from collections.abc import Callable from contextlib import suppress import logging import string +from typing import Any, TypeVar, cast from aiohttp import web import prometheus_client +from prometheus_client.metrics import MetricWrapperBase import voluptuous as vol from homeassistant import core as hacore @@ -40,15 +45,20 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entityfilter, state as state_helper import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.entity_registry import ( + EVENT_ENTITY_REGISTRY_UPDATED, + EventEntityRegistryUpdatedData, +) from homeassistant.helpers.entity_values import EntityValues -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter +_MetricBaseT = TypeVar("_MetricBaseT", bound=MetricWrapperBase) _LOGGER = logging.getLogger(__name__) API_ENDPOINT = "/api/prometheus" @@ -97,12 +107,12 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Prometheus component.""" hass.http.register_view(PrometheusView(config[DOMAIN][CONF_REQUIRES_AUTH])) - conf = config[DOMAIN] - entity_filter = conf[CONF_FILTER] - namespace = conf.get(CONF_PROM_NAMESPACE) + conf: dict[str, Any] = config[DOMAIN] + entity_filter: entityfilter.EntityFilter = conf[CONF_FILTER] + namespace: str = conf[CONF_PROM_NAMESPACE] climate_units = hass.config.units.temperature_unit - override_metric = conf.get(CONF_OVERRIDE_METRIC) - default_metric = conf.get(CONF_DEFAULT_METRIC) + override_metric: str | None = conf.get(CONF_OVERRIDE_METRIC) + default_metric: str | None = conf.get(CONF_DEFAULT_METRIC) component_config = EntityValues( conf[CONF_COMPONENT_CONFIG], conf[CONF_COMPONENT_CONFIG_DOMAIN], @@ -118,9 +128,10 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) # type: ignore[arg-type] hass.bus.listen( - EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated + EVENT_ENTITY_REGISTRY_UPDATED, + metrics.handle_entity_registry_updated, # type: ignore[arg-type] ) for state in hass.states.all(): @@ -135,19 +146,21 @@ class PrometheusMetrics: def __init__( self, - entity_filter, - namespace, - climate_units, - component_config, - override_metric, - default_metric, - ): + entity_filter: entityfilter.EntityFilter, + namespace: str, + climate_units: UnitOfTemperature, + component_config: EntityValues, + override_metric: str | None, + default_metric: str | None, + ) -> None: """Initialize Prometheus Metrics.""" self._component_config = component_config self._override_metric = override_metric self._default_metric = default_metric self._filter = entity_filter - self._sensor_metric_handlers = [ + self._sensor_metric_handlers: list[ + Callable[[State, str | None], str | None] + ] = [ self._sensor_override_component_metric, self._sensor_override_metric, self._sensor_timestamp_metric, @@ -160,10 +173,12 @@ class PrometheusMetrics: self.metrics_prefix = f"{namespace}_" else: self.metrics_prefix = "" - self._metrics = {} + self._metrics: dict[str, MetricWrapperBase] = {} self._climate_units = climate_units - def handle_state_changed_event(self, event): + def handle_state_changed_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return @@ -179,7 +194,7 @@ class PrometheusMetrics: self.handle_state(state) - def handle_state(self, state): + def handle_state(self, state: State) -> None: """Add/update a state in Prometheus.""" entity_id = state.entity_id _LOGGER.debug("Handling state update for %s", entity_id) @@ -212,20 +227,22 @@ class PrometheusMetrics: ) last_updated_time_seconds.labels(**labels).set(state.last_updated.timestamp()) - def handle_entity_registry_updated(self, event): + def handle_entity_registry_updated( + self, event: EventType[EventEntityRegistryUpdatedData] + ) -> None: """Listen for deleted, disabled or renamed entities and remove them from the Prometheus Registry.""" - if (action := event.data.get("action")) in (None, "create"): + if event.data["action"] in (None, "create"): return entity_id = event.data.get("entity_id") _LOGGER.debug("Handling entity update for %s", entity_id) - metrics_entity_id = None + metrics_entity_id: str | None = None - if action == "remove": + if event.data["action"] == "remove": metrics_entity_id = entity_id - elif action == "update": - changes = event.data.get("changes") + elif event.data["action"] == "update": + changes = event.data["changes"] if "entity_id" in changes: metrics_entity_id = changes["entity_id"] @@ -235,10 +252,14 @@ class PrometheusMetrics: if metrics_entity_id: self._remove_labelsets(metrics_entity_id) - def _remove_labelsets(self, entity_id, friendly_name=None): + def _remove_labelsets( + self, entity_id: str, friendly_name: str | None = None + ) -> None: """Remove labelsets matching the given entity id from all metrics.""" for _, metric in self._metrics.items(): - for sample in metric.collect()[0].samples: + for sample in cast(list[prometheus_client.Metric], metric.collect())[ + 0 + ].samples: if sample.labels["entity"] == entity_id and ( not friendly_name or sample.labels["friendly_name"] == friendly_name ): @@ -250,7 +271,7 @@ class PrometheusMetrics: with suppress(KeyError): metric.remove(*sample.labels.values()) - def _handle_attributes(self, state): + def _handle_attributes(self, state: State) -> None: for key, value in state.attributes.items(): metric = self._metric( f"{state.domain}_attr_{key.lower()}", @@ -264,13 +285,19 @@ class PrometheusMetrics: except (ValueError, TypeError): pass - def _metric(self, metric, factory, documentation, extra_labels=None): + def _metric( + self, + metric: str, + factory: type[_MetricBaseT], + documentation: str, + extra_labels: list[str] | None = None, + ) -> _MetricBaseT: labels = ["entity", "friendly_name", "domain"] if extra_labels is not None: labels.extend(extra_labels) try: - return self._metrics[metric] + return cast(_MetricBaseT, self._metrics[metric]) except KeyError: full_metric_name = self._sanitize_metric_name( f"{self.metrics_prefix}{metric}" @@ -281,7 +308,7 @@ class PrometheusMetrics: labels, registry=prometheus_client.REGISTRY, ) - return self._metrics[metric] + return cast(_MetricBaseT, self._metrics[metric]) @staticmethod def _sanitize_metric_name(metric: str) -> str: @@ -298,7 +325,7 @@ class PrometheusMetrics: ) @staticmethod - def state_as_number(state): + def state_as_number(state: State) -> float: """Return a state casted to a float.""" try: if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: @@ -311,14 +338,14 @@ class PrometheusMetrics: return value @staticmethod - def _labels(state): + def _labels(state: State) -> dict[str, Any]: return { "entity": state.entity_id, "domain": state.domain, "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), } - def _battery(self, state): + def _battery(self, state: State) -> None: if (battery_level := state.attributes.get(ATTR_BATTERY_LEVEL)) is not None: metric = self._metric( "battery_level_percent", @@ -331,7 +358,7 @@ class PrometheusMetrics: except ValueError: pass - def _handle_binary_sensor(self, state): + def _handle_binary_sensor(self, state: State) -> None: metric = self._metric( "binary_sensor_state", prometheus_client.Gauge, @@ -340,7 +367,7 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_input_boolean(self, state): + def _handle_input_boolean(self, state: State) -> None: metric = self._metric( "input_boolean_state", prometheus_client.Gauge, @@ -349,7 +376,7 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _numeric_handler(self, state, domain, title): + def _numeric_handler(self, state: State, domain: str, title: str) -> None: if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): metric = self._metric( f"{domain}_state_{unit}", @@ -374,13 +401,13 @@ class PrometheusMetrics: ) metric.labels(**self._labels(state)).set(value) - def _handle_input_number(self, state): + def _handle_input_number(self, state: State) -> None: self._numeric_handler(state, "input_number", "input number") - def _handle_number(self, state): + def _handle_number(self, state: State) -> None: self._numeric_handler(state, "number", "number") - def _handle_device_tracker(self, state): + def _handle_device_tracker(self, state: State) -> None: metric = self._metric( "device_tracker_state", prometheus_client.Gauge, @@ -389,14 +416,14 @@ class PrometheusMetrics: value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_person(self, state): + def _handle_person(self, state: State) -> None: metric = self._metric( "person_state", prometheus_client.Gauge, "State of the person (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_cover(self, state): + def _handle_cover(self, state: State) -> None: metric = self._metric( "cover_state", prometheus_client.Gauge, @@ -428,7 +455,7 @@ class PrometheusMetrics: ) tilt_position_metric.labels(**self._labels(state)).set(float(tilt_position)) - def _handle_light(self, state): + def _handle_light(self, state: State) -> None: metric = self._metric( "light_brightness_percent", prometheus_client.Gauge, @@ -446,14 +473,16 @@ class PrometheusMetrics: except ValueError: pass - def _handle_lock(self, state): + def _handle_lock(self, state: State) -> None: metric = self._metric( "lock_state", prometheus_client.Gauge, "State of the lock (0/1)" ) value = self.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_climate_temp(self, state, attr, metric_name, metric_description): + def _handle_climate_temp( + self, state: State, attr: str, metric_name: str, metric_description: str + ) -> None: if (temp := state.attributes.get(attr)) is not None: if self._climate_units == UnitOfTemperature.FAHRENHEIT: temp = TemperatureConverter.convert( @@ -466,7 +495,7 @@ class PrometheusMetrics: ) metric.labels(**self._labels(state)).set(temp) - def _handle_climate(self, state): + def _handle_climate(self, state: State) -> None: self._handle_climate_temp( state, ATTR_TEMPERATURE, @@ -518,7 +547,7 @@ class PrometheusMetrics: float(mode == current_mode) ) - def _handle_humidifier(self, state): + def _handle_humidifier(self, state: State) -> None: humidifier_target_humidity_percent = state.attributes.get(ATTR_HUMIDITY) if humidifier_target_humidity_percent: metric = self._metric( @@ -553,7 +582,7 @@ class PrometheusMetrics: float(mode == current_mode) ) - def _handle_sensor(self, state): + def _handle_sensor(self, state: State) -> None: unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) for metric_handler in self._sensor_metric_handlers: @@ -583,12 +612,12 @@ class PrometheusMetrics: self._battery(state) - def _sensor_default_metric(self, state, unit): + def _sensor_default_metric(self, state: State, unit: str | None) -> str | None: """Get default metric.""" return self._default_metric @staticmethod - def _sensor_attribute_metric(state, unit): + def _sensor_attribute_metric(state: State, unit: str | None) -> str | None: """Get metric based on device class attribute.""" metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric is not None: @@ -596,25 +625,27 @@ class PrometheusMetrics: return None @staticmethod - def _sensor_timestamp_metric(state, unit): + def _sensor_timestamp_metric(state: State, unit: str | None) -> str | None: """Get metric for timestamp sensors, which have no unit of measurement attribute.""" metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" return None - def _sensor_override_metric(self, state, unit): + def _sensor_override_metric(self, state: State, unit: str | None) -> str | None: """Get metric from override in configuration.""" if self._override_metric: return self._override_metric return None - def _sensor_override_component_metric(self, state, unit): + def _sensor_override_component_metric( + self, state: State, unit: str | None + ) -> str | None: """Get metric from override in component confioguration.""" return self._component_config.get(state.entity_id).get(CONF_OVERRIDE_METRIC) @staticmethod - def _sensor_fallback_metric(state, unit): + def _sensor_fallback_metric(state: State, unit: str | None) -> str | None: """Get metric from fallback logic for compatibility.""" if unit in (None, ""): try: @@ -626,10 +657,10 @@ class PrometheusMetrics: return f"sensor_unit_{unit}" @staticmethod - def _unit_string(unit): + def _unit_string(unit: str | None) -> str | None: """Get a formatted string of the unit.""" if unit is None: - return + return None units = { UnitOfTemperature.CELSIUS: "celsius", @@ -640,7 +671,7 @@ class PrometheusMetrics: default = default.lower() return units.get(unit, default) - def _handle_switch(self, state): + def _handle_switch(self, state: State) -> None: metric = self._metric( "switch_state", prometheus_client.Gauge, "State of the switch (0/1)" ) @@ -653,10 +684,10 @@ class PrometheusMetrics: self._handle_attributes(state) - def _handle_zwave(self, state): + def _handle_zwave(self, state: State) -> None: self._battery(state) - def _handle_automation(self, state): + def _handle_automation(self, state: State) -> None: metric = self._metric( "automation_triggered_count", prometheus_client.Counter, @@ -665,7 +696,7 @@ class PrometheusMetrics: metric.labels(**self._labels(state)).inc() - def _handle_counter(self, state): + def _handle_counter(self, state: State) -> None: metric = self._metric( "counter_value", prometheus_client.Gauge, @@ -674,7 +705,7 @@ class PrometheusMetrics: metric.labels(**self._labels(state)).set(self.state_as_number(state)) - def _handle_update(self, state): + def _handle_update(self, state: State) -> None: metric = self._metric( "update_state", prometheus_client.Gauge, @@ -694,7 +725,7 @@ class PrometheusView(HomeAssistantView): """Initialize Prometheus view.""" self.requires_auth = requires_auth - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Handle request for Prometheus metrics.""" _LOGGER.debug("Received Prometheus metrics request") diff --git a/mypy.ini b/mypy.ini index ce0b4a3575c..4b74ad0608f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2961,6 +2961,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.prometheus.*] +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.proximity.*] check_untyped_defs = true disallow_incomplete_defs = true From 16f110658c3b3dc218983acc589d63cd37cd5a6b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:09:57 +0100 Subject: [PATCH 0624/1544] Enable strict typing for duckdns (#108022) --- .strict-typing | 1 + homeassistant/components/duckdns/__init__.py | 28 +++++++++++++------- mypy.ini | 10 +++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.strict-typing b/.strict-typing index 035218d8024..0e7c6a8205d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -146,6 +146,7 @@ homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* homeassistant.components.dsmr.* +homeassistant.components.duckdns.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index d477bd41a26..c0c3b14566c 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,9 +1,12 @@ """Integrate with DuckDNS.""" -from collections.abc import Callable, Coroutine +from __future__ import annotations + +from collections.abc import Callable, Coroutine, Sequence from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, cast +from aiohttp import ClientSession import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN @@ -50,11 +53,11 @@ SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the DuckDNS component.""" - domain = config[DOMAIN][CONF_DOMAIN] - token = config[DOMAIN][CONF_ACCESS_TOKEN] + domain: str = config[DOMAIN][CONF_DOMAIN] + token: str = config[DOMAIN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) - async def update_domain_interval(_now): + async def update_domain_interval(_now: datetime) -> bool: """Update the DuckDNS entry.""" return await _update_duckdns(session, domain, token) @@ -81,7 +84,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _SENTINEL = object() -async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): +async def _update_duckdns( + session: ClientSession, + domain: str, + token: str, + *, + txt: str | None | object = _SENTINEL, + clear: bool = False, +) -> bool: """Update DuckDNS.""" params = {"domains": domain, "token": token} @@ -91,7 +101,7 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) params["txt"] = "" clear = True else: - params["txt"] = txt + params["txt"] = cast(str, txt) if clear: params["clear"] = "true" @@ -111,11 +121,9 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) def async_track_time_interval_backoff( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, bool]], - intervals, + intervals: Sequence[timedelta], ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" - if not isinstance(intervals, (list, tuple)): - intervals = (intervals,) remove: CALLBACK_TYPE | None = None failed = 0 diff --git a/mypy.ini b/mypy.ini index 4b74ad0608f..f90dbeffd12 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1221,6 +1221,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.duckdns.*] +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.dunehd.*] check_untyped_defs = true disallow_incomplete_defs = true From aa3e172a65fcdf119ffc4a2eadc1bfe0223086ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 15 Jan 2024 09:10:09 +0100 Subject: [PATCH 0625/1544] Bump pychromecast to 13.1.0 (#108073) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 5035b3c6620..ae049fefef6 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.0.8"], + "requirements": ["PyChromecast==13.1.0"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d3f6bbd739b..2279b93e70f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==13.0.8 +PyChromecast==13.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f13f9b63300..7fbc37f8323 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ PlexAPI==4.15.7 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==13.0.8 +PyChromecast==13.1.0 # homeassistant.components.flick_electric PyFlick==0.0.2 From 9bca09a5131097202e19e6b5ba9a77590e6b372d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:17:05 +0100 Subject: [PATCH 0626/1544] Remove obsolete .txt extension from diagnostics download (#108028) --- homeassistant/components/diagnostics/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 2ff220b9096..939bd5f5000 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -197,7 +197,7 @@ async def _async_get_json_file_response( return web.Response( body=json_data, content_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{filename}.json.txt"'}, + headers={"Content-Disposition": f'attachment; filename="{filename}.json"'}, ) From 84038fb119cd6ccdd7683d84ae95d0817ff76f79 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:19:50 +0100 Subject: [PATCH 0627/1544] Enable strict typing for generic_thermostat (#108024) --- .strict-typing | 1 + .../components/generic_thermostat/climate.py | 106 +++++++++--------- mypy.ini | 10 ++ 3 files changed, 67 insertions(+), 50 deletions(-) diff --git a/.strict-typing b/.strict-typing index 0e7c6a8205d..f128385834b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -183,6 +183,7 @@ homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fully_kiosk.* homeassistant.components.generic_hygrostat.* +homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 03a98401668..7bc6c63697c 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timedelta import logging import math from typing import Any @@ -36,10 +37,12 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfTemperature, ) from homeassistant.core import ( DOMAIN as HA_DOMAIN, CoreState, + Event, HomeAssistant, State, callback, @@ -126,25 +129,25 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name = config.get(CONF_NAME) - heater_entity_id = config.get(CONF_HEATER) - sensor_entity_id = config.get(CONF_SENSOR) - min_temp = config.get(CONF_MIN_TEMP) - max_temp = config.get(CONF_MAX_TEMP) - target_temp = config.get(CONF_TARGET_TEMP) - ac_mode = config.get(CONF_AC_MODE) - min_cycle_duration = config.get(CONF_MIN_DUR) - cold_tolerance = config.get(CONF_COLD_TOLERANCE) - hot_tolerance = config.get(CONF_HOT_TOLERANCE) - keep_alive = config.get(CONF_KEEP_ALIVE) - initial_hvac_mode = config.get(CONF_INITIAL_HVAC_MODE) - presets = { + name: str = config[CONF_NAME] + heater_entity_id: str = config[CONF_HEATER] + sensor_entity_id: str = config[CONF_SENSOR] + min_temp: float | None = config.get(CONF_MIN_TEMP) + max_temp: float | None = config.get(CONF_MAX_TEMP) + target_temp: float | None = config.get(CONF_TARGET_TEMP) + ac_mode: bool | None = config.get(CONF_AC_MODE) + min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR) + cold_tolerance: float = config[CONF_COLD_TOLERANCE] + hot_tolerance: float = config[CONF_HOT_TOLERANCE] + keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE) + initial_hvac_mode: HVACMode | None = config.get(CONF_INITIAL_HVAC_MODE) + presets: dict[str, float] = { key: config[value] for key, value in CONF_PRESETS.items() if value in config } - precision = config.get(CONF_PRECISION) - target_temperature_step = config.get(CONF_TEMP_STEP) + precision: float | None = config.get(CONF_PRECISION) + target_temperature_step: float | None = config.get(CONF_TEMP_STEP) unit = hass.config.units.temperature_unit - unique_id = config.get(CONF_UNIQUE_ID) + unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -178,24 +181,24 @@ class GenericThermostat(ClimateEntity, RestoreEntity): def __init__( self, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, - ): + name: str, + heater_entity_id: str, + sensor_entity_id: str, + min_temp: float | None, + max_temp: float | None, + target_temp: float | None, + ac_mode: bool | None, + min_cycle_duration: timedelta | None, + cold_tolerance: float, + hot_tolerance: float, + keep_alive: timedelta | None, + initial_hvac_mode: HVACMode | None, + presets: dict[str, float], + precision: float | None, + target_temperature_step: float | None, + unit: UnitOfTemperature, + unique_id: str | None, + ) -> None: """Initialize the thermostat.""" self._attr_name = name self.heater_entity_id = heater_entity_id @@ -214,7 +217,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): else: self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] self._active = False - self._cur_temp = None + self._cur_temp: float | None = None self._temp_lock = asyncio.Lock() self._min_temp = min_temp self._max_temp = max_temp @@ -254,7 +257,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ) @callback - def _async_startup(*_): + def _async_startup(_: Event | None = None) -> None: """Init on startup.""" sensor_state = self.hass.states.get(self.sensor_entity_id) if sensor_state and sensor_state.state not in ( @@ -297,7 +300,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): ): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if not self._hvac_mode and old_state.state: - self._hvac_mode = old_state.state + self._hvac_mode = HVACMode(old_state.state) else: # No previous state, try and restore defaults @@ -315,14 +318,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._hvac_mode = HVACMode.OFF @property - def precision(self): + def precision(self) -> float: """Return the precision of the system.""" if self._temp_precision is not None: return self._temp_precision return super().precision @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" if self._temp_target_temperature_step is not None: return self._temp_target_temperature_step @@ -330,17 +333,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return self.precision @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the sensor temperature.""" return self._cur_temp @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode | None: """Return current operation.""" return self._hvac_mode @property - def hvac_action(self): + def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. Need to be one of CURRENT_HVAC_*. @@ -354,7 +357,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return HVACAction.HEATING @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temp @@ -385,7 +388,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.async_write_ha_state() @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" if self._min_temp is not None: return self._min_temp @@ -394,7 +397,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): return super().min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" if self._max_temp is not None: return self._max_temp @@ -414,7 +417,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_control_heating() self.async_write_ha_state() - async def _check_switch_initial_state(self): + async def _check_switch_initial_state(self) -> None: """Prevent the device from keep running if HVACMode.OFF.""" if self._hvac_mode == HVACMode.OFF and self._is_device_active: _LOGGER.warning( @@ -448,7 +451,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity): except ValueError as ex: _LOGGER.error("Unable to update from sensor: %s", ex) - async def _async_control_heating(self, time=None, force=False): + async def _async_control_heating( + self, time: datetime | None = None, force: bool = False + ) -> None: """Check if we need to turn heating on or off.""" async with self._temp_lock: if not self._active and None not in ( @@ -490,6 +495,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): if not long_enough: return + assert self._cur_temp is not None and self._target_temp is not None too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance if self._is_device_active: @@ -514,21 +520,21 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_heater_turn_off() @property - def _is_device_active(self): + def _is_device_active(self) -> bool | None: """If the toggleable device is currently active.""" if not self.hass.states.get(self.heater_entity_id): return None return self.hass.states.is_state(self.heater_entity_id, STATE_ON) - async def _async_heater_turn_on(self): + async def _async_heater_turn_on(self) -> None: """Turn heater toggleable device on.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await self.hass.services.async_call( HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context ) - async def _async_heater_turn_off(self): + async def _async_heater_turn_off(self) -> None: """Turn heater toggleable device off.""" data = {ATTR_ENTITY_ID: self.heater_entity_id} await self.hass.services.async_call( diff --git a/mypy.ini b/mypy.ini index f90dbeffd12..a47452f6012 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1591,6 +1591,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.generic_thermostat.*] +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.geo_location.*] check_untyped_defs = true disallow_incomplete_defs = true From f968b43f6a197afee02e3fee9bc1fba2dc9007cc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 09:32:30 +0100 Subject: [PATCH 0628/1544] Improve gdacs typing (#108040) --- homeassistant/components/gdacs/__init__.py | 41 +++++++++++-------- homeassistant/components/gdacs/config_flow.py | 8 +++- .../components/gdacs/geo_location.py | 33 ++++++++------- homeassistant/components/gdacs/sensor.py | 37 ++++++++++------- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 1530e4712d8..0ec582f8d06 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -1,8 +1,13 @@ """The Global Disaster Alert and Coordination System (GDACS) integration.""" -from datetime import timedelta +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta import logging +from aio_georss_client.status_update import StatusUpdate from aio_georss_gdacs import GdacsFeedManager +from aio_georss_gdacs.feed_entry import FeedEntry from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -50,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an GDACS component config entry.""" - manager = hass.data[DOMAIN][FEED].pop(entry.entry_id) + manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED].pop(entry.entry_id) await manager.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -58,7 +63,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class GdacsFeedEntityManager: """Feed Entity Manager for GDACS feed.""" - def __init__(self, hass, config_entry, radius_in_km): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, radius_in_km: float + ) -> None: """Initialize the Feed Entity Manager.""" self._hass = hass self._config_entry = config_entry @@ -80,18 +87,18 @@ class GdacsFeedEntityManager: ) self._config_entry_id = config_entry.entry_id self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) - self._track_time_remove_callback = None - self._status_info = None - self.listeners = [] + self._track_time_remove_callback: Callable[[], None] | None = None + self._status_info: StatusUpdate | None = None + self.listeners: list[Callable[[], None]] = [] - async def async_init(self): + async def async_init(self) -> None: """Schedule initial and regular updates based on configured time interval.""" await self._hass.config_entries.async_forward_entry_setups( self._config_entry, PLATFORMS ) - async def update(event_time): + async def update(event_time: datetime) -> None: """Update.""" await self.async_update() @@ -102,12 +109,12 @@ class GdacsFeedEntityManager: _LOGGER.debug("Feed entity manager initialized") - async def async_update(self): + async def async_update(self) -> None: """Refresh data.""" await self._feed_manager.update() _LOGGER.debug("Feed entity manager updated") - async def async_stop(self): + async def async_stop(self) -> None: """Stop this feed entity manager from refreshing.""" for unsub_dispatcher in self.listeners: unsub_dispatcher() @@ -117,19 +124,19 @@ class GdacsFeedEntityManager: _LOGGER.debug("Feed entity manager stopped") @callback - def async_event_new_entity(self): + def async_event_new_entity(self) -> str: """Return manager specific event to signal new entity.""" return f"gdacs_new_geolocation_{self._config_entry_id}" - def get_entry(self, external_id): + def get_entry(self, external_id: str) -> FeedEntry | None: """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) - def status_info(self): + def status_info(self) -> StatusUpdate | None: """Return latest status update info received.""" return self._status_info - async def _generate_entity(self, external_id): + async def _generate_entity(self, external_id: str) -> None: """Generate new entity.""" async_dispatcher_send( self._hass, @@ -139,15 +146,15 @@ class GdacsFeedEntityManager: external_id, ) - async def _update_entity(self, external_id): + async def _update_entity(self, external_id: str) -> None: """Update entity.""" async_dispatcher_send(self._hass, f"gdacs_update_{external_id}") - async def _remove_entity(self, external_id): + async def _remove_entity(self, external_id: str) -> None: """Remove entity.""" async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}") - async def _status_update(self, status_info): + async def _status_update(self, status_info: StatusUpdate) -> None: """Propagate status update.""" _LOGGER.debug("Status update received: %s", status_info) self._status_info = status_info diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py index b59b3bcc775..acc3bbc1991 100644 --- a/homeassistant/components/gdacs/config_flow.py +++ b/homeassistant/components/gdacs/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure the GDACS integration.""" import logging +from typing import Any import voluptuous as vol @@ -10,6 +11,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from .const import CONF_CATEGORIES, DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -24,13 +26,15 @@ _LOGGER = logging.getLogger(__name__) class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a GDACS config flow.""" - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" _LOGGER.debug("User input: %s", user_input) if not user_input: diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 1d3dabc464c..5c4fb9d33bc 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -2,10 +2,10 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime import logging from typing import Any -from aio_georss_gdacs import GdacsFeedManager from aio_georss_gdacs.feed_entry import GdacsFeedEntry from homeassistant.components.geo_location import GeolocationEvent @@ -58,7 +58,7 @@ async def async_setup_entry( @callback def async_add_geolocation( - feed_manager: GdacsFeedManager, integration_id: str, external_id: str + feed_manager: GdacsFeedEntityManager, integration_id: str, external_id: str ) -> None: """Add geolocation entity from feed.""" new_entity = GdacsEvent(feed_manager, integration_id, external_id) @@ -83,25 +83,28 @@ class GdacsEvent(GeolocationEvent): _attr_source = SOURCE def __init__( - self, feed_manager: GdacsFeedManager, integration_id: str, external_id: str + self, + feed_manager: GdacsFeedEntityManager, + integration_id: str, + external_id: str, ) -> None: """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager self._external_id = external_id self._attr_unique_id = f"{integration_id}_{external_id}" self._attr_unit_of_measurement = UnitOfLength.KILOMETERS - self._alert_level = None - self._country = None - self._description = None - self._duration_in_week = None - self._event_type_short = None - self._event_type = None - self._from_date = None - self._to_date = None - self._population = None - self._severity = None - self._vulnerability = None - self._version = None + self._alert_level: str | None = None + self._country: str | None = None + self._description: str | None = None + self._duration_in_week: int | None = None + self._event_type_short: str | None = None + self._event_type: str | None = None + self._from_date: datetime | None = None + self._to_date: datetime | None = None + self._population: str | None = None + self._severity: str | None = None + self._vulnerability: str | float | None = None + self._version: int | None = None self._remove_signal_delete: Callable[[], None] self._remove_signal_update: Callable[[], None] diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 8a0a0113ced..8039d5274ed 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -2,7 +2,11 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime import logging +from typing import Any + +from aio_georss_client.status_update import StatusUpdate from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry @@ -12,6 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import GdacsFeedEntityManager from .const import DEFAULT_ICON, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -34,7 +39,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the GDACS Feed platform.""" - manager = hass.data[DOMAIN][FEED][entry.entry_id] + manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] sensor = GdacsSensor(entry, manager) async_add_entities([sensor]) @@ -48,20 +53,22 @@ class GdacsSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, config_entry: ConfigEntry, manager) -> None: + def __init__( + self, config_entry: ConfigEntry, manager: GdacsFeedEntityManager + ) -> None: """Initialize entity.""" assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id self._manager = manager - self._status = None - self._last_update = None - self._last_update_successful = None - self._last_timestamp = None - self._total = None - self._created = None - self._updated = None - self._removed = None + self._status: str | None = None + self._last_update: datetime | None = None + self._last_update_successful: datetime | None = None + self._last_timestamp: datetime | None = None + self._total: int | None = None + self._created: int | None = None + self._updated: int | None = None + self._removed: int | None = None self._remove_signal_status: Callable[[], None] | None = None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.unique_id)}, @@ -86,7 +93,7 @@ class GdacsSensor(SensorEntity): self._remove_signal_status() @callback - def _update_status_callback(self): + def _update_status_callback(self) -> None: """Call status update method.""" _LOGGER.debug("Received status update for %s", self._config_entry_id) self.async_schedule_update_ha_state(True) @@ -99,7 +106,7 @@ class GdacsSensor(SensorEntity): if status_info: self._update_from_status_info(status_info) - def _update_from_status_info(self, status_info): + def _update_from_status_info(self, status_info: StatusUpdate) -> None: """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( @@ -118,14 +125,14 @@ class GdacsSensor(SensorEntity): self._removed = status_info.removed @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the sensor.""" return self._total @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attributes = {} + attributes: dict[str, Any] = {} for key, value in ( (ATTR_STATUS, self._status), (ATTR_LAST_UPDATE, self._last_update), From c3e8e931e6871235cdbd0bc186fd3c3785bf71b5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 15 Jan 2024 09:37:57 +0100 Subject: [PATCH 0629/1544] Deprecate passing step_id to FlowHandler methods (#107944) --- homeassistant/data_entry_flow.py | 49 ++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 207328992ab..5eed267fbbd 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -76,6 +76,9 @@ FLOW_NOT_COMPLETE_STEPS = { } STEP_ID_OPTIONAL_STEPS = { + FlowResultType.EXTERNAL_STEP, + FlowResultType.FORM, + FlowResultType.MENU, FlowResultType.SHOW_PROGRESS, } @@ -575,25 +578,30 @@ class FlowHandler: def async_show_form( self, *, - step_id: str, + step_id: str | None = None, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, preview: str | None = None, ) -> FlowResult: - """Return the definition of a form to gather user input.""" - return FlowResult( + """Return the definition of a form to gather user input. + + The step_id parameter is deprecated and will be removed in a future release. + """ + flow_result = FlowResult( type=FlowResultType.FORM, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, data_schema=data_schema, errors=errors, description_placeholders=description_placeholders, last_step=last_step, # Display next or submit button in frontend preview=preview, # Display preview component in frontend ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_create_entry( @@ -636,19 +644,24 @@ class FlowHandler: def async_external_step( self, *, - step_id: str, + step_id: str | None = None, url: str, description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: - """Return the definition of an external step for the user to take.""" - return FlowResult( + """Return the definition of an external step for the user to take. + + The step_id parameter is deprecated and will be removed in a future release. + """ + flow_result = FlowResult( type=FlowResultType.EXTERNAL_STEP, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, url=url, description_placeholders=description_placeholders, ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_external_step_done(self, *, next_step_id: str) -> FlowResult: @@ -669,7 +682,10 @@ class FlowHandler: description_placeholders: Mapping[str, str] | None = None, progress_task: asyncio.Task[Any] | None = None, ) -> FlowResult: - """Show a progress message to the user, without user input allowed.""" + """Show a progress message to the user, without user input allowed. + + The step_id parameter is deprecated and will be removed in a future release. + """ if progress_task is None and not self.__no_progress_task_reported: self.__no_progress_task_reported = True cls = self.__class__ @@ -685,7 +701,7 @@ class FlowHandler: report_issue, ) - result = FlowResult( + flow_result = FlowResult( type=FlowResultType.SHOW_PROGRESS, flow_id=self.flow_id, handler=self.handler, @@ -694,8 +710,8 @@ class FlowHandler: progress_task=progress_task, ) if step_id is not None: - result["step_id"] = step_id - return result + flow_result["step_id"] = step_id + return flow_result @callback def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: @@ -711,23 +727,26 @@ class FlowHandler: def async_show_menu( self, *, - step_id: str, + step_id: str | None = None, menu_options: list[str] | dict[str, str], description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a navigation menu to the user. Options dict maps step_id => i18n label + The step_id parameter is deprecated and will be removed in a future release. """ - return FlowResult( + flow_result = FlowResult( type=FlowResultType.MENU, flow_id=self.flow_id, handler=self.handler, - step_id=step_id, data_schema=vol.Schema({"next_step_id": vol.In(menu_options)}), menu_options=menu_options, description_placeholders=description_placeholders, ) + if step_id is not None: + flow_result["step_id"] = step_id + return flow_result @callback def async_remove(self) -> None: From dd2527db5bee7b4eb94a4b397afe4be6e04df88b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 15 Jan 2024 08:40:01 +0000 Subject: [PATCH 0630/1544] Bump evohome client to 0.4.17 (#108051) --- homeassistant/components/evohome/climate.py | 8 ++++++-- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 1e092d7fc34..97a126e2660 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -248,16 +248,20 @@ class EvoZone(EvoChild, EvoClimateEntity): def min_temp(self) -> float: """Return the minimum target temperature of a Zone. - The default is 5, but is user-configurable within 5-35 (in Celsius). + The default is 5, but is user-configurable within 5-21 (in Celsius). """ + if self._evo_device.min_heat_setpoint is None: + return 5 return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: """Return the maximum target temperature of a Zone. - The default is 35, but is user-configurable within 5-35 (in Celsius). + The default is 35, but is user-configurable within 21-35 (in Celsius). """ + if self._evo_device.max_heat_setpoint is None: + return 35 return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 062bba1cfdc..9d32ba98e92 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.15"] + "requirements": ["evohome-async==0.4.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2279b93e70f..5d0fd2f36d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -806,7 +806,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.15 +evohome-async==0.4.17 # homeassistant.components.faa_delays faadelays==2023.9.1 From 5cc1a761ddafb3fa5ff382861d07c3530195896f Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Mon, 15 Jan 2024 21:41:44 +1300 Subject: [PATCH 0631/1544] Fix malformed user input error on MJPEG config flow (#108058) --- homeassistant/components/mjpeg/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index 61c80bcde38..024766f4c63 100644 --- a/homeassistant/components/mjpeg/config_flow.py +++ b/homeassistant/components/mjpeg/config_flow.py @@ -54,7 +54,7 @@ def async_get_schema( if show_name: schema = { - vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME)): str, + vol.Required(CONF_NAME, default=defaults.get(CONF_NAME)): str, **schema, } From 52acc4bbab638fff8e799883d0b6758679fb7dfd Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 15 Jan 2024 11:08:38 +0100 Subject: [PATCH 0632/1544] Fix turning on the light with a specific color (#108080) --- homeassistant/components/matter/light.py | 12 ++++++++++++ tests/components/matter/test_light.py | 12 +++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 52a6b4162fe..43c47046162 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -89,6 +89,10 @@ class MatterLight(MatterEntity, LightEntity): colorY=int(matter_xy[1]), # It's required in TLV. We don't implement transition time yet. transitionTime=0, + # allow setting the color while the light is off, + # by setting the optionsMask to 1 (=ExecuteIfOff) + optionsMask=1, + optionsOverride=1, ) ) @@ -103,6 +107,10 @@ class MatterLight(MatterEntity, LightEntity): saturation=int(matter_hs[1]), # It's required in TLV. We don't implement transition time yet. transitionTime=0, + # allow setting the color while the light is off, + # by setting the optionsMask to 1 (=ExecuteIfOff) + optionsMask=1, + optionsOverride=1, ) ) @@ -114,6 +122,10 @@ class MatterLight(MatterEntity, LightEntity): colorTemperatureMireds=color_temp, # It's required in TLV. We don't implement transition time yet. transitionTime=0, + # allow setting the color while the light is off, + # by setting the optionsMask to 1 (=ExecuteIfOff) + optionsMask=1, + optionsOverride=1, ) ) diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 78ffa477b33..fb988d26a1c 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -202,6 +202,8 @@ async def test_color_temperature_light( command=clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperatureMireds=300, transitionTime=0, + optionsMask=1, + optionsOverride=1, ), ), call( @@ -278,7 +280,11 @@ async def test_extended_color_light( node_id=light_node.node_id, endpoint_id=1, command=clusters.ColorControl.Commands.MoveToColor( - colorX=0.5 * 65536, colorY=0.5 * 65536, transitionTime=0 + colorX=0.5 * 65536, + colorY=0.5 * 65536, + transitionTime=0, + optionsMask=1, + optionsOverride=1, ), ), call( @@ -311,8 +317,8 @@ async def test_extended_color_light( hue=167, saturation=254, transitionTime=0, - optionsMask=0, - optionsOverride=0, + optionsMask=1, + optionsOverride=1, ), ), call( From bd37d3776b48c6fabd40528473f9c6d17027fa4c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 15 Jan 2024 11:09:40 +0100 Subject: [PATCH 0633/1544] Set webhook `local_only` to True by default (#107670) --- homeassistant/components/webhook/strings.json | 8 ----- homeassistant/components/webhook/trigger.py | 33 +------------------ tests/components/webhook/test_trigger.py | 2 +- 3 files changed, 2 insertions(+), 41 deletions(-) delete mode 100644 homeassistant/components/webhook/strings.json diff --git a/homeassistant/components/webhook/strings.json b/homeassistant/components/webhook/strings.json deleted file mode 100644 index 53b932727d0..00000000000 --- a/homeassistant/components/webhook/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "trigger_missing_local_only": { - "title": "Update webhook trigger: {webhook_id}", - "description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. [Edit the automation]({edit}) \"{automation_name}\", (`{entity_id}`) and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'" - } - } -} diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index e0f3412a562..05bb53564bd 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -11,11 +11,6 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -88,31 +83,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Trigger based on incoming webhooks.""" webhook_id: str = config[CONF_WEBHOOK_ID] - local_only = config.get(CONF_LOCAL_ONLY) - issue_id: str | None = None - if local_only is None: - issue_id = f"trigger_missing_local_only_{webhook_id}" - variables = trigger_info["variables"] or {} - automation_info = variables.get("this", {}) - automation_id = automation_info.get("attributes", {}).get("id") - automation_entity_id = automation_info.get("entity_id") - automation_name = trigger_info.get("name") or automation_entity_id - async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2023.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger", - translation_key="trigger_missing_local_only", - translation_placeholders={ - "webhook_id": webhook_id, - "automation_name": automation_name, - "entity_id": automation_entity_id, - "edit": f"/config/automation/edit/{automation_id}", - }, - ) + local_only = config.get(CONF_LOCAL_ONLY, True) allowed_methods = config.get(CONF_ALLOWED_METHODS, DEFAULT_METHODS) job = HassJob(action) @@ -138,8 +109,6 @@ async def async_attach_trigger( @callback def unregister() -> None: """Unregister webhook.""" - if issue_id: - async_delete_issue(hass, DOMAIN, issue_id) triggers[webhook_id].remove(trigger_instance) if not triggers[webhook_id]: async_unregister(hass, webhook_id) diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 990482c500e..713130b6fb6 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -150,7 +150,7 @@ async def test_webhook_allowed_methods_internet( "platform": "webhook", "webhook_id": "post_webhook", "allowed_methods": "PUT", - # Enable after 2023.11.0: "local_only": False, + "local_only": False, }, "action": { "event": "test_success", From 4d7186b6e670d8df5b098699af1428abbb38487a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:23:26 +0100 Subject: [PATCH 0634/1544] Improve ffmpeg and freebox typing (#108026) --- homeassistant/components/ffmpeg/camera.py | 20 +++++++++++++------ .../components/freebox/binary_sensor.py | 4 ++-- homeassistant/components/freebox/camera.py | 15 +++++++++----- .../components/freebox/device_tracker.py | 4 ++-- homeassistant/components/freebox/home_base.py | 20 +++++++++++-------- homeassistant/components/freebox/router.py | 6 +++--- homeassistant/components/freebox/switch.py | 2 +- 7 files changed, 44 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index fb2519fb071..b3e9e3f909f 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -1,6 +1,8 @@ """Support for Cameras with FFmpeg as decoder.""" from __future__ import annotations +from typing import Any + from aiohttp import web from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG @@ -14,7 +16,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_EXTRA_ARGUMENTS, CONF_INPUT, DATA_FFMPEG, async_get_image +from . import ( + CONF_EXTRA_ARGUMENTS, + CONF_INPUT, + DATA_FFMPEG, + FFmpegManager, + async_get_image, +) DEFAULT_NAME = "FFmpeg" DEFAULT_ARGUMENTS = "-pred 1" @@ -43,14 +51,14 @@ class FFmpegCamera(Camera): _attr_supported_features = CameraEntityFeature.STREAM - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize a FFmpeg camera.""" super().__init__() - self._manager = hass.data[DATA_FFMPEG] - self._name = config.get(CONF_NAME) - self._input = config.get(CONF_INPUT) - self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) + self._manager: FFmpegManager = hass.data[DATA_FFMPEG] + self._name: str = config[CONF_NAME] + self._input: str = config[CONF_INPUT] + self._extra_arguments: str = config[CONF_EXTRA_ARGUMENTS] async def stream_source(self): """Return the stream source.""" diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index b5e0258d844..ef7f1ea3899 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -83,7 +83,7 @@ class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): ) self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) - async def async_update_signal(self): + async def async_update_signal(self) -> None: """Update name & state.""" self._attr_is_on = self._edit_state( await self.get_home_endpoint_value(self._command_id) @@ -167,7 +167,7 @@ class FreeboxRaidDegradedSensor(BinarySensorEntity): return self._raid["degraded"] @callback - def async_on_demand_update(self): + def async_on_demand_update(self) -> None: """Update state.""" self.async_update_state() self.async_write_ha_state() diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index f5c86ec0bce..96b0f63a92e 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -29,11 +29,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up cameras.""" - router = hass.data[DOMAIN][entry.unique_id] - tracked: set = set() + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + tracked: set[str] = set() @callback - def update_callback(): + def update_callback() -> None: add_entities(hass, router, async_add_entities, tracked) router.listeners.append( @@ -45,9 +45,14 @@ async def async_setup_entry( @callback -def add_entities(hass: HomeAssistant, router, async_add_entities, tracked): +def add_entities( + hass: HomeAssistant, + router: FreeboxRouter, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: """Add new cameras from the router.""" - new_tracked = [] + new_tracked: list[FreeboxCamera] = [] for nodeid, node in router.home_devices.items(): if (node["category"] != FreeboxHomeCategory.CAMERA) or (nodeid in tracked): diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 42e028b881e..663acdc1f15 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -103,7 +103,7 @@ class FreeboxDevice(ScannerEntity): return SourceType.ROUTER @callback - def async_on_demand_update(self): + def async_on_demand_update(self) -> None: """Update state.""" self.async_update_state() self.async_write_ha_state() @@ -120,6 +120,6 @@ class FreeboxDevice(ScannerEntity): ) -def icon_for_freebox_device(device) -> str: +def icon_for_freebox_device(device: dict[str, Any]) -> str: """Return a device icon from its type.""" return DEVICE_ICONS.get(device["host_type"], "mdi:help-network") diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index 022528e5ea7..2d75494e281 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -1,6 +1,7 @@ """Support for Freebox base features.""" from __future__ import annotations +from collections.abc import Callable import logging from typing import Any @@ -42,7 +43,7 @@ class FreeboxHomeEntity(Entity): self._available = True self._firmware = node["props"].get("FwVersion") self._manufacturer = "Freebox SAS" - self._remove_signal_update: Any + self._remove_signal_update: Callable[[], None] | None = None self._model = CATEGORY_TO_MODEL.get(node["category"]) if self._model is None: @@ -65,7 +66,7 @@ class FreeboxHomeEntity(Entity): ), ) - async def async_update_signal(self): + async def async_update_signal(self) -> None: """Update signal.""" self._node = self._router.home_devices[self._id] # Update name @@ -77,7 +78,9 @@ class FreeboxHomeEntity(Entity): ) self.async_write_ha_state() - async def set_home_endpoint_value(self, command_id: Any, value=None) -> bool: + async def set_home_endpoint_value( + self, command_id: int | None, value: bool | None = None + ) -> bool: """Set Home endpoint value.""" if command_id is None: _LOGGER.error("Unable to SET a value through the API. Command is None") @@ -97,7 +100,7 @@ class FreeboxHomeEntity(Entity): node = await self._router.home.get_home_endpoint_value(self._id, command_id) return node.get("value") - def get_command_id(self, nodes, ep_type, name) -> int | None: + def get_command_id(self, nodes, ep_type: str, name: str) -> int | None: """Get the command id.""" node = next( filter(lambda x: (x["name"] == name and x["ep_type"] == ep_type), nodes), @@ -110,7 +113,7 @@ class FreeboxHomeEntity(Entity): return None return node["id"] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register state update callback.""" self.remove_signal_update( async_dispatcher_connect( @@ -120,11 +123,12 @@ class FreeboxHomeEntity(Entity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """When entity will be removed from hass.""" - self._remove_signal_update() + if self._remove_signal_update is not None: + self._remove_signal_update() - def remove_signal_update(self, dispacher: Any): + def remove_signal_update(self, dispacher: Callable[[], None]) -> None: """Register state update callback.""" self._remove_signal_update = dispacher diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 765761c43f2..15e3b34bd77 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -1,7 +1,7 @@ """Represent the Freebox router and its devices and sensors.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from datetime import datetime import json @@ -38,7 +38,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def is_json(json_str): +def is_json(json_str: str) -> bool: """Validate if a String is a JSON value or not.""" try: json.loads(json_str) @@ -95,7 +95,7 @@ class FreeboxRouter: self.call_list: list[dict[str, Any]] = [] self.home_granted = True self.home_devices: dict[str, Any] = {} - self.listeners: list[dict[str, Any]] = [] + self.listeners: list[Callable[[], None]] = [] async def update_all(self, now: datetime | None = None) -> None: """Update all Freebox platforms.""" diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index e7547b97d4e..5b6dd494f0b 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -51,7 +51,7 @@ class FreeboxSwitch(SwitchEntity): self._attr_device_info = router.device_info self._attr_unique_id = f"{router.mac} {entity_description.name}" - async def _async_set_state(self, enabled: bool): + async def _async_set_state(self, enabled: bool) -> None: """Turn the switch on or off.""" try: await self._router.wifi.set_global_config({"enabled": enabled}) From 7f619579fa36890e613845f3dbaeeba4ba87c220 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 15 Jan 2024 10:24:55 +0000 Subject: [PATCH 0635/1544] Harden zone schedule processing for evohome (#108079) --- homeassistant/components/evohome/__init__.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 390bdeb3f33..fafa89f4575 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -673,7 +673,7 @@ class EvoChild(EvoDevice): dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset return dt_util.as_local(dt_aware) - if not self._schedule or not self._schedule.get("DailySchedules"): + if not (schedule := self._schedule.get("DailySchedules")): return {} # no scheduled setpoints when {'DailySchedules': []} day_time = dt_util.now() @@ -682,7 +682,7 @@ class EvoChild(EvoDevice): try: # Iterate today's switchpoints until past the current time of day... - day = self._schedule["DailySchedules"][day_of_week] + day = schedule[day_of_week] sp_idx = -1 # last switchpoint of the day before for i, tmp in enumerate(day["Switchpoints"]): if time_of_day > tmp["TimeOfDay"]: @@ -699,7 +699,7 @@ class EvoChild(EvoDevice): ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), ): sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] + day = schedule[(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] switchpoint_time_of_day = dt_util.parse_datetime( @@ -730,9 +730,17 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] - self._evo_device.get_schedule(), update_state=False - ) + try: + self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] + self._evo_device.get_schedule(), update_state=False + ) + except evo.InvalidSchedule as err: + _LOGGER.warning( + "%s: Unable to retrieve the schedule: %s", + self._evo_device, + err, + ) + self._schedule = {} _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) From ede6e0180863bca0d382192b117a0fd1752774b4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 15 Jan 2024 12:10:17 +0100 Subject: [PATCH 0636/1544] Bump Jinja2 to 3.1.3 (#108082) --- 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 59e003c1263..6cef233d904 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ home-assistant-intents==2024.1.2 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 -Jinja2==3.1.2 +Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.10 diff --git a/pyproject.toml b/pyproject.toml index ac8aa79c91f..3780642c0c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "httpx==0.26.0", "home-assistant-bluetooth==1.12.0", "ifaddr==0.2.0", - "Jinja2==3.1.2", + "Jinja2==3.1.3", "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index e9f61c8b2e7..fb6beb22b4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ ciso8601==2.3.0 httpx==0.26.0 home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 -Jinja2==3.1.2 +Jinja2==3.1.3 lru-dict==1.3.0 PyJWT==2.8.0 cryptography==41.0.7 From 5dde45e01f51e7974afd0e92db83464e356d48e1 Mon Sep 17 00:00:00 2001 From: Grant <38738958+ThePapaG@users.noreply.github.com> Date: Mon, 15 Jan 2024 22:53:16 +1000 Subject: [PATCH 0637/1544] Fix comment for SmartThings fan capability (#108086) * Fix the fan to support preset modes * Add more tests and fix some comments * Don't override inherited member * Don't check for supported feature as the check is already performed before here * Do not check for feature on properties * Update homeassistant/components/smartthings/fan.py Co-authored-by: G Johansson * Fix tests * Fix comment * Break line --------- Co-authored-by: G Johansson Co-authored-by: Martin Hjelmare --- homeassistant/components/smartthings/fan.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 647a99ebfa6..0e15ea7800a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -52,7 +52,9 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: Capability.fan_speed, ] - # If none of the optional capabilities are supported then error + # At least one of the optional capabilities must be supported + # to classify this entity as a fan. + # If they are not then return None and don't setup the platform. if not any(capability in capabilities for capability in optional): return None From 6a9fdaae7a754ae5e128d600442c1f302494ad2c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 16:53:24 +0100 Subject: [PATCH 0638/1544] Enable strict typing for onboarding (#108097) --- .strict-typing | 1 + .../components/onboarding/__init__.py | 12 +++++- homeassistant/components/onboarding/views.py | 42 +++++++++++-------- homeassistant/components/person/__init__.py | 8 +++- mypy.ini | 10 +++++ 5 files changed, 52 insertions(+), 21 deletions(-) diff --git a/.strict-typing b/.strict-typing index f128385834b..d0b47db2d59 100644 --- a/.strict-typing +++ b/.strict-typing @@ -304,6 +304,7 @@ homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.nut.* +homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index d334a0051c3..4243d05c085 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -1,4 +1,6 @@ """Support to help onboard new users.""" +from __future__ import annotations + from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback @@ -23,10 +25,15 @@ STORAGE_VERSION = 4 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -class OnboadingStorage(Store): +class OnboadingStorage(Store[dict[str, list[str]]]): """Store onboarding data.""" - async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, list[str]], + ) -> dict[str, list[str]]: """Migrate to the new version.""" # From version 1 -> 2, we automatically mark the integration step done if old_major_version < 2: @@ -56,6 +63,7 @@ def async_is_user_onboarded(hass: HomeAssistant) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the onboarding component.""" store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) + data: dict[str, list[str]] | None if (data := await store.async_load()) is None: data = {"done": []} diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b1b4ea29222..c403bcd5ab2 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from http import HTTPStatus -from typing import cast +from typing import TYPE_CHECKING, Any, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -21,6 +21,9 @@ from homeassistant.helpers import area_registry as ar from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations +if TYPE_CHECKING: + from . import OnboadingStorage + from .const import ( DEFAULT_AREAS, DOMAIN, @@ -32,7 +35,9 @@ from .const import ( ) -async def async_setup(hass, data, store): +async def async_setup( + hass: HomeAssistant, data: dict[str, list[str]], store: OnboadingStorage +) -> None: """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(InstallationTypeOnboardingView(data)) @@ -49,12 +54,12 @@ class OnboardingView(HomeAssistantView): url = "/api/onboarding" name = "api:onboarding" - def __init__(self, data, store): + def __init__(self, data: dict[str, list[str]], store: OnboadingStorage) -> None: """Initialize the onboarding view.""" self._store = store self._data = data - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" return self.json( [{"step": key, "done": key in self._data["done"]} for key in STEPS] @@ -68,16 +73,16 @@ class InstallationTypeOnboardingView(HomeAssistantView): url = "/api/onboarding/installation_type" name = "api:onboarding:installation_type" - def __init__(self, data): + def __init__(self, data: dict[str, list[str]]) -> None: """Initialize the onboarding installation type view.""" self._data = data - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Return the onboarding status.""" if self._data["done"]: raise HTTPUnauthorized() - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] info = await async_get_system_info(hass) return self.json({"installation_type": info["installation_type"]}) @@ -85,20 +90,20 @@ class InstallationTypeOnboardingView(HomeAssistantView): class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" - step: str | None = None + step: str - def __init__(self, data, store): + def __init__(self, data: dict[str, list[str]], store: OnboadingStorage) -> None: """Initialize the onboarding view.""" self._store = store self._data = data self._lock = asyncio.Lock() @callback - def _async_is_done(self): + def _async_is_done(self) -> bool: """Return if this step is done.""" return self.step in self._data["done"] - async def _async_mark_done(self, hass): + async def _async_mark_done(self, hass: HomeAssistant) -> None: """Mark step as done.""" self._data["done"].append(self.step) await self._store.async_save(self._data) @@ -180,9 +185,9 @@ class CoreConfigOnboardingView(_BaseOnboardingView): name = "api:onboarding:core_config" step = STEP_CORE_CONFIG - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle finishing core config step.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): @@ -205,7 +210,8 @@ class CoreConfigOnboardingView(_BaseOnboardingView): if ( hassio.is_hassio(hass) - and "raspberrypi" in hassio.get_core_info(hass)["machine"] + and (core_info := hassio.get_core_info(hass)) + and "raspberrypi" in core_info["machine"] ): onboard_integrations.append("rpi_power") @@ -232,9 +238,9 @@ class IntegrationOnboardingView(_BaseOnboardingView): @RequestDataValidator( vol.Schema({vol.Required("client_id"): str, vol.Required("redirect_uri"): str}) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle token creation.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] refresh_token_id = request[KEY_HASS_REFRESH_TOKEN_ID] async with self._lock: @@ -276,9 +282,9 @@ class AnalyticsOnboardingView(_BaseOnboardingView): name = "api:onboarding:analytics" step = STEP_ANALYTICS - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle finishing analytics step.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self._lock: if self._async_is_done(): diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 49b719a5490..f6444a869ee 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -92,7 +92,13 @@ CONFIG_SCHEMA = vol.Schema( @bind_hass -async def async_create_person(hass, name, *, user_id=None, device_trackers=None): +async def async_create_person( + hass: HomeAssistant, + name: str, + *, + user_id: str | None = None, + device_trackers: list[str] | None = None, +) -> None: """Create a new person.""" await hass.data[DOMAIN][1].async_create_item( { diff --git a/mypy.ini b/mypy.ini index a47452f6012..87829af666b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2801,6 +2801,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onboarding.*] +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.oncue.*] check_untyped_defs = true disallow_incomplete_defs = true From 749ef45727a880108c6450c4674bb4793fec4aa5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 15 Jan 2024 18:20:34 +0100 Subject: [PATCH 0639/1544] Add availability to command_line (#105300) * Add availability to command_line * Add tests * freezer --- .../components/command_line/__init__.py | 5 ++ .../components/command_line/binary_sensor.py | 7 ++- .../components/command_line/cover.py | 6 ++- .../components/command_line/switch.py | 6 ++- .../command_line/test_binary_sensor.py | 53 ++++++++++++++++++- tests/components/command_line/test_cover.py | 50 +++++++++++++++++ tests/components/command_line/test_sensor.py | 52 +++++++++++++++++- tests/components/command_line/test_switch.py | 51 ++++++++++++++++++ 8 files changed, 225 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index ba4292b5a65..701391ab389 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -55,6 +55,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY from homeassistant.helpers.typing import ConfigType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN @@ -90,6 +91,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL ): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) COVER_SCHEMA = vol.Schema( @@ -105,6 +107,7 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) NOTIFY_SCHEMA = vol.Schema( @@ -129,6 +132,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) SWITCH_SCHEMA = vol.Schema( @@ -144,6 +148,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_AVAILABILITY): cv.template, } ) COMBINED_SCHEMA = vol.Schema( diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 31259ddf909..20b538fc4d7 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -24,7 +24,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -63,6 +66,7 @@ async def async_setup_platform( scan_interval: timedelta = binary_sensor_config.get( CONF_SCAN_INTERVAL, SCAN_INTERVAL ) + availability: Template | None = binary_sensor_config.get(CONF_AVAILABILITY) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -72,6 +76,7 @@ async def async_setup_platform( CONF_NAME: Template(name, hass), CONF_DEVICE_CLASS: device_class, CONF_ICON: icon, + CONF_AVAILABILITY: availability, } async_add_entities( diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 93c007297ea..845de352d73 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -50,6 +53,7 @@ async def async_setup_platform( trigger_entity_config = { CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, device_name), hass), + CONF_AVAILABILITY: device_config.get(CONF_AVAILABILITY), } covers.append( diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 0af6163312c..efeded194ce 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -48,6 +51,7 @@ async def async_setup_platform( CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), CONF_ICON: device_config.get(CONF_ICON), + CONF_AVAILABILITY: device_config.get(CONF_AVAILABILITY), } value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index eaa7061551a..7975660fda3 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -6,6 +6,7 @@ from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -15,7 +16,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -289,3 +290,53 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0", + "value_template": "{{ value | multiply(0.1) }}", + "availability": '{{ states("sensor.input1")=="on" }}', + } + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_ON + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"0", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index e6e428388f4..901fc39eb34 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -7,6 +7,7 @@ import os import tempfile from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -22,6 +23,8 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_STOP_COVER, + STATE_OPEN, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -340,3 +343,50 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo 10", + "name": "Test", + "availability": '{{ states("sensor.input1")=="on" }}', + }, + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_OPEN + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"50\n", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 9f28b8cc6d0..64227116cfe 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -7,6 +7,7 @@ import subprocess from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -16,7 +17,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -708,3 +709,52 @@ async def test_template_not_error_when_data_is_none( "Template variable error: 'None' has no attribute 'split' when rendering" not in caplog.text ) + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo January 17, 2022", + "device_class": "date", + "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", + "availability": '{{ states("sensor.input1")=="on" }}', + } + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "2022-01-17" + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"January 17, 2022", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index f1f4096fa91..47d9184f4f9 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -9,6 +9,7 @@ import subprocess import tempfile from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -25,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -710,3 +712,52 @@ async def test_updating_manually( ) await hass.async_block_till_done() assert called + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "availability": '{{ states("sensor.input1")=="on" }}', + }, + } + ] + } + ], +) +async def test_availability( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability.""" + + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_ON + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with patch( + "homeassistant.components.command_line.utils.subprocess.check_output", + return_value=b"50\n", + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE From 5b3e1306f82b2d370154e57657250b3107ae768a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 15 Jan 2024 18:26:49 +0100 Subject: [PATCH 0640/1544] Add tests for System Monitor (#107891) * Add tests * no coordinator * Coverage * processes * test init * util * test icon * Add tests * Mod tests * Add tests * Test attributes * snapshots * icon * test disk mounts * fixes * svmem * cache_clear * test icon * reset icon test * test_processor_temperature * fix tests on macos --------- Co-authored-by: J. Nick Koston --- .coveragerc | 3 - .../components/systemmonitor/sensor.py | 32 +- tests/components/systemmonitor/conftest.py | 233 +++++++++- .../systemmonitor/snapshots/test_sensor.ambr | 399 ++++++++++++++++++ tests/components/systemmonitor/test_init.py | 60 +++ tests/components/systemmonitor/test_sensor.py | 346 +++++++++++++++ tests/components/systemmonitor/test_util.py | 90 ++++ 7 files changed, 1147 insertions(+), 16 deletions(-) create mode 100644 tests/components/systemmonitor/snapshots/test_sensor.ambr create mode 100644 tests/components/systemmonitor/test_init.py create mode 100644 tests/components/systemmonitor/test_sensor.py create mode 100644 tests/components/systemmonitor/test_util.py diff --git a/.coveragerc b/.coveragerc index 88a9f96a608..f6ecdc3e718 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1316,9 +1316,6 @@ omit = homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/system_bridge/update.py - homeassistant/components/systemmonitor/__init__.py - homeassistant/components/systemmonitor/sensor.py - homeassistant/components/systemmonitor/util.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py homeassistant/components/tado/climate.py diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 95437c7fa4c..1a48e34d0e9 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import datetime, timedelta -from functools import cache +from functools import cache, lru_cache import logging import os import socket import sys -from typing import Any +from typing import Any, Literal import psutil import voluptuous as vol @@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" -if sys.maxsize > 2**32: - CPU_ICON = "mdi:cpu-64-bit" -else: - CPU_ICON = "mdi:cpu-32-bit" SENSOR_TYPE_NAME = 0 SENSOR_TYPE_UOM = 1 @@ -70,6 +66,14 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" +@lru_cache +def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: + """Return cpu icon.""" + if sys.maxsize > 2**32: + return "mdi:cpu-64-bit" + return "mdi:cpu-32-bit" + + @dataclass(frozen=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" @@ -121,19 +125,19 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { "load_15m": SysMonitorSensorEntityDescription( key="load_15m", name="Load (15m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_1m": SysMonitorSensorEntityDescription( key="load_1m", name="Load (1m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_5m": SysMonitorSensorEntityDescription( key="load_5m", name="Load (5m)", - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "memory_free": SysMonitorSensorEntityDescription( @@ -210,14 +214,14 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { "process": SysMonitorSensorEntityDescription( key="process", name="Process", - icon=CPU_ICON, + icon=get_cpu_icon(), mandatory_arg=True, ), "processor_use": SysMonitorSensorEntityDescription( key="processor_use", name="Processor use", native_unit_of_measurement=PERCENTAGE, - icon=CPU_ICON, + icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "processor_temperature": SysMonitorSensorEntityDescription( @@ -751,7 +755,11 @@ def _getloadavg() -> tuple[float, float, float]: def _read_cpu_temperature() -> float | None: """Attempt to read CPU / processor temperature.""" - temps = psutil.sensors_temperatures() + try: + temps = psutil.sensors_temperatures() + except AttributeError: + # Linux, macOS + return None for name, entries in temps.items(): for i, entry in enumerate(entries, start=1): diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index ca21c971cf1..b349e5cf5e1 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -1,11 +1,61 @@ """Fixtures for the System Monitor integration.""" from __future__ import annotations +from collections import namedtuple from collections.abc import Generator -from unittest.mock import AsyncMock, patch +import socket +from unittest.mock import AsyncMock, Mock, patch +from psutil import NoSuchProcess, Process +from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr, sswap import pytest +from homeassistant.components.systemmonitor.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +# Different depending on platform so making according to Linux +svmem = namedtuple( + "svmem", + [ + "total", + "available", + "percent", + "used", + "free", + "active", + "inactive", + "buffers", + "cached", + "shared", + "slab", + ], +) + + +@pytest.fixture(autouse=True) +def mock_sys_platform() -> Generator[None, None, None]: + """Mock sys platform to Linux.""" + with patch("sys.platform", "linux"): + yield + + +class MockProcess(Process): + """Mock a Process class.""" + + def __init__(self, name: str, ex: bool = False) -> None: + """Initialize the process.""" + super().__init__(1) + self._name = name + self._ex = ex + + def name(self): + """Return a name.""" + if self._ex: + raise NoSuchProcess(1, self._name) + return self._name + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -15,3 +65,184 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={ + "sensor": {"process": ["python3", "pip"]}, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + }, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + +@pytest.fixture +def mock_process() -> list[MockProcess]: + """Mock process.""" + _process_python = MockProcess("python3") + _process_pip = MockProcess("pip") + return [_process_python, _process_pip] + + +@pytest.fixture +def mock_psutil(mock_process: list[MockProcess]) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.sensor.psutil", + autospec=True, + ) as mock_psutil: + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**2, 300 * 1024**2, 200 * 1024**2, 60.0 + ) + mock_psutil.swap_memory.return_value = sswap( + 100 * 1024**2, 60 * 1024**2, 40 * 1024**2, 60.0, 1, 1 + ) + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 40 * 1024**2, + 40.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + "eth1": snetio(200 * 1024**2, 200 * 1024**2, 150, 150, 0, 0, 0, 0), + "vethxyzxyz": snetio(300 * 1024**2, 300 * 1024**2, 150, 150, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_psutil.cpu_percent.return_value = 10.0 + mock_psutil.boot_time.return_value = 1703973338.0 + mock_psutil.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_psutil.sensors_temperatures = Mock() + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_psutil.NoSuchProcess = NoSuchProcess + yield mock_psutil + + +@pytest.fixture +def mock_util(mock_process) -> Mock: + """Mock psutil.""" + with patch( + "homeassistant.components.systemmonitor.util.psutil", autospec=True + ) as mock_util: + mock_util.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "eth1": [ + snicaddr( + socket.AF_INET, + "192.168.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + "vethxyzxyz": [ + snicaddr( + socket.AF_INET, + "172.16.10.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + mock_process = [MockProcess("python3")] + mock_util.process_iter.return_value = mock_process + # sensors_temperatures not available on MacOS so we + # need to override the spec + mock_util.sensors_temperatures = Mock() + mock_util.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), + sdiskpart("test3", "/incorrect", "", "", 1, 1), + sdiskpart("proc", "/proc/run", "proc", "", 1, 1), + ] + mock_util.disk_usage.return_value = sdiskusage(10, 10, 0, 0) + yield mock_util + + +@pytest.fixture +def mock_os() -> Mock: + """Mock os.""" + with patch("homeassistant.components.systemmonitor.sensor.os") as mock_os, patch( + "homeassistant.components.systemmonitor.util.os" + ) as mock_os_util: + mock_os_util.name = "nt" + mock_os.getloadavg.return_value = (1, 2, 3) + yield mock_os diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..be32e1f54ef --- /dev/null +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -0,0 +1,399 @@ +# serializer version: 1 +# name: test_sensor[System Monitor Disk free / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free / - state] + '0.2' +# --- +# name: test_sensor[System Monitor Disk free /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk free /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk free /media/share - state] + '0.2' +# --- +# name: test_sensor[System Monitor Disk use (percent) / - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) / - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /home/notexist/', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use (percent) /media/share - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Disk use (percent) /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Disk use (percent) /media/share - state] + '60.0' +# --- +# name: test_sensor[System Monitor Disk use / - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use / - state] + '0.3' +# --- +# name: test_sensor[System Monitor Disk use /media/share - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Disk use /media/share', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Disk use /media/share - state] + '0.3' +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth0 - state] + '192.168.1.1' +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv4 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv4 address eth1 - state] + '192.168.10.1' +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth0', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor IPv6 address eth1', + 'icon': 'mdi:ip-network', + }) +# --- +# name: test_sensor[System Monitor IPv6 address eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Last boot - attributes] + ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'System Monitor Last boot', + }) +# --- +# name: test_sensor[System Monitor Last boot - state] + '2023-12-30T21:55:38+00:00' +# --- +# name: test_sensor[System Monitor Load (15m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (15m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (15m) - state] + '3' +# --- +# name: test_sensor[System Monitor Load (1m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (1m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (1m) - state] + '1' +# --- +# name: test_sensor[System Monitor Load (5m) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Load (5m)', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Load (5m) - state] + '2' +# --- +# name: test_sensor[System Monitor Memory free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory free', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory use (percent) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Memory use (percent)', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Memory use (percent) - state] + '40.0' +# --- +# name: test_sensor[System Monitor Memory use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Memory use', + 'icon': 'mdi:memory', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Memory use - state] + '60.0' +# --- +# name: test_sensor[System Monitor Network in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network in eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth0 - state] + '100.0' +# --- +# name: test_sensor[System Monitor Network out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Network out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network out eth1 - state] + '200.0' +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput in eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput in eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth0', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth0 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - attributes] + ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'System Monitor Network throughput out eth1', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Network throughput out eth1 - state] + 'unknown' +# --- +# name: test_sensor[System Monitor Packets in eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets in eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets in eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets in eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Packets out eth0 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth0', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth0 - state] + '50' +# --- +# name: test_sensor[System Monitor Packets out eth1 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Packets out eth1', + 'icon': 'mdi:server-network', + 'state_class': , + }) +# --- +# name: test_sensor[System Monitor Packets out eth1 - state] + '150' +# --- +# name: test_sensor[System Monitor Process pip - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process pip', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process pip - state] + 'on' +# --- +# name: test_sensor[System Monitor Process python3 - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Process python3', + 'icon': 'mdi:cpu-64-bit', + }) +# --- +# name: test_sensor[System Monitor Process python3 - state] + 'on' +# --- +# name: test_sensor[System Monitor Processor temperature - attributes] + ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'System Monitor Processor temperature', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Processor temperature - state] + '50.0' +# --- +# name: test_sensor[System Monitor Processor use - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Processor use', + 'icon': 'mdi:cpu-64-bit', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Processor use - state] + '10' +# --- +# name: test_sensor[System Monitor Swap free - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap free', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap free - state] + '40.0' +# --- +# name: test_sensor[System Monitor Swap use (percent) - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor Swap use (percent)', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[System Monitor Swap use (percent) - state] + '60.0' +# --- +# name: test_sensor[System Monitor Swap use - attributes] + ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'System Monitor Swap use', + 'icon': 'mdi:harddisk', + 'state_class': , + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[System Monitor Swap use - state] + '60.0' +# --- diff --git a/tests/components/systemmonitor/test_init.py b/tests/components/systemmonitor/test_init.py new file mode 100644 index 00000000000..a352f9a1b95 --- /dev/null +++ b/tests/components/systemmonitor/test_init.py @@ -0,0 +1,60 @@ +"""Test for System Monitor init.""" +from __future__ import annotations + +from homeassistant.components.systemmonitor.const import CONF_PROCESS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_load_unload_entry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test load and unload an entry.""" + + assert mock_added_config_entry.state == ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_added_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_adding_processor_to_options( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test options listener.""" + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is None + + result = await hass.config_entries.options.async_init( + mock_added_config_entry.entry_id + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["python3", "pip", "systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["python3", "pip", "systemd"], + }, + "resources": [ + "disk_use_percent_/", + "disk_use_percent_/home/notexist/", + "memory_free_", + "network_out_eth0", + "process_python3", + ], + } + + process_sensor = hass.states.get("sensor.system_monitor_process_systemd") + assert process_sensor is not None + assert process_sensor.state == STATE_OFF diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py new file mode 100644 index 00000000000..d173bb11d2e --- /dev/null +++ b/tests/components/systemmonitor/test_sensor.py @@ -0,0 +1,346 @@ +"""Test System Monitor sensor.""" +from datetime import timedelta +import socket +from unittest.mock import Mock, patch + +from freezegun.api import FrozenDateTimeFactory +from psutil._common import shwtemp, snetio, snicaddr +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.systemmonitor.sensor import ( + _read_cpu_temperature, + get_cpu_icon, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import MockProcess, svmem + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + assert memory_sensor.attributes == { + "state_class": "measurement", + "unit_of_measurement": "MiB", + "device_class": "data_size", + "icon": "mdi:memory", + "friendly_name": "System Monitor Memory free", + } + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + for entity in er.async_entries_for_config_entry( + entity_registry, mock_added_config_entry.entry_id + ): + state = hass.states.get(entity.entity_id) + assert state.state == snapshot(name=f"{state.name} - state") + assert state.attributes == snapshot(name=f"{state.name} - attributes") + + +async def test_sensor_not_loading_veth_networks( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, +) -> None: + """Test the sensor.""" + network_sensor_1 = hass.states.get("sensor.system_monitor_network_out_eth1") + network_sensor_2 = hass.states.get( + "sensor.sensor.system_monitor_network_out_vethxyzxyz" + ) + assert network_sensor_1 is not None + assert network_sensor_1.state == "200.0" + assert network_sensor_2 is None + + +async def test_sensor_icon( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor icon for 32bit/64bit system.""" + + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**32): + assert get_cpu_icon() == "mdi:cpu-32-bit" + get_cpu_icon.cache_clear() + with patch("sys.maxsize", 2**64): + assert get_cpu_icon() == "mdi:cpu-64-bit" + + +async def test_sensor_yaml( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "disk_use_percent"}, + {"type": "disk_use_percent", "arg": "/media/share"}, + {"type": "memory_free", "arg": "/"}, + {"type": "network_out", "arg": "eth0"}, + {"type": "process", "arg": "python3"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + +async def test_sensor_yaml_fails_missing_argument( + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, +) -> None: + """Test the sensor imported from YAML fails on missing mandatory argument.""" + config = { + "sensor": { + "platform": "systemmonitor", + "resources": [ + {"type": "network_in"}, + ], + } + } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + assert "Mandatory 'arg' is missing for sensor type 'network_in'" in caplog.text + + +async def test_sensor_updating( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor.""" + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "40.0" + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + mock_psutil.virtual_memory.side_effect = Exception("Failed to update") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == STATE_UNAVAILABLE + + mock_psutil.virtual_memory.side_effect = None + mock_psutil.virtual_memory.return_value = svmem( + 100 * 1024**2, + 25 * 1024**2, + 25.0, + 60 * 1024**2, + 30 * 1024**2, + 1, + 1, + 1, + 1, + 1, + 1, + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + memory_sensor = hass.states.get("sensor.system_monitor_memory_free") + assert memory_sensor is not None + assert memory_sensor.state == "25.0" + + +async def test_sensor_process_fails( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test process not exist failure.""" + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + assert process_sensor.state == STATE_ON + + _process = MockProcess("python3", True) + + mock_psutil.process_iter.return_value = [_process] + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + process_sensor = hass.states.get("sensor.system_monitor_process_python3") + assert process_sensor is not None + # assert process_sensor.state == STATE_ON + + assert "Failed to load process with ID: 1, old name: python3" in caplog.text + + +async def test_sensor_network_sensors( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_added_config_entry: ConfigEntry, + mock_psutil: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test process not exist failure.""" + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "200.0" + assert packets_out_sensor.state == "150" + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(200 * 1024**2, 200 * 1024**2, 100, 100, 0, 0, 0, 0), + "eth1": snetio(400 * 1024**2, 400 * 1024**2, 300, 300, 0, 0, 0, 0), + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == "400.0" + assert packets_out_sensor.state == "300" + assert float(throughput_network_out_sensor.state) == pytest.approx(3.493, rel=0.1) + + mock_psutil.net_io_counters.return_value = { + "eth0": snetio(100 * 1024**2, 100 * 1024**2, 50, 50, 0, 0, 0, 0), + } + mock_psutil.net_if_addrs.return_value = { + "eth0": [ + snicaddr( + socket.AF_INET, + "192.168.1.1", + "255.255.255.0", + "255.255.255.255", + None, + ) + ], + } + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") + packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") + throughput_network_out_sensor = hass.states.get( + "sensor.system_monitor_network_throughput_out_eth1" + ) + + assert network_out_sensor is not None + assert packets_out_sensor is not None + assert throughput_network_out_sensor is not None + assert network_out_sensor.state == STATE_UNKNOWN + assert packets_out_sensor.state == STATE_UNKNOWN + assert throughput_network_out_sensor.state == STATE_UNKNOWN + + +async def test_missing_cpu_temperature( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor when temperature missing.""" + mock_psutil.sensors_temperatures.return_value = { + "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert "Cannot read CPU / processor temperature information" in caplog.text + temp_sensor = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_sensor is None + + +async def test_processor_temperature() -> None: + """Test the disk failures.""" + + with patch("sys.platform", "linux"), patch( + "homeassistant.components.systemmonitor.sensor.psutil" + ) as mock_psutil: + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + temperature = _read_cpu_temperature() + assert temperature == 50.0 + + with patch("sys.platform", "nt"), patch( + "homeassistant.components.systemmonitor.sensor.psutil", + ) as mock_psutil: + mock_psutil.sensors_temperatures.side_effect = AttributeError( + "sensors_temperatures not exist" + ) + temperature = _read_cpu_temperature() + assert temperature is None + + with patch("sys.platform", "darwin"), patch( + "homeassistant.components.systemmonitor.sensor.psutil" + ) as mock_psutil: + mock_psutil.sensors_temperatures.return_value = { + "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] + } + temperature = _read_cpu_temperature() + assert temperature == 50.0 diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py new file mode 100644 index 00000000000..c0c6829a752 --- /dev/null +++ b/tests/components/systemmonitor/test_util.py @@ -0,0 +1,90 @@ +"""Test System Monitor utils.""" + +from unittest.mock import Mock, patch + +from psutil._common import sdiskpart +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (PermissionError("No permission"), "No permission for running user to access"), + (OSError("OS error"), "was excluded because of: OS error"), + ], +) +async def test_disk_setup_failure( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the disk failures.""" + + with patch( + "homeassistant.components.systemmonitor.util.psutil.disk_usage", + side_effect=side_effect, + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free_media_share") + assert disk_sensor is None + + assert error_text in caplog.text + + +async def test_disk_util( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_os: Mock, + mock_util: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the disk failures.""" + + mock_util.disk_partitions.return_value = [ + sdiskpart("test", "/", "ext4", "", 1, 1), # Should be ok + sdiskpart("test2", "/media/share", "ext4", "", 1, 1), # Should be ok + sdiskpart("test3", "/incorrect", "", "", 1, 1), # Should be skipped as no type + sdiskpart( + "proc", "/proc/run", "proc", "", 1, 1 + ), # Should be skipped as in skipped disk types + sdiskpart( + "test4", + "/tmpfs/", # noqa: S108 + "tmpfs", + "", + 1, + 1, + ), # Should be skipped as in skipped disk types + sdiskpart("test5", "E:", "cd", "cdrom", 1, 1), # Should be skipped as cdrom + ] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + disk_sensor1 = hass.states.get("sensor.system_monitor_disk_free") + disk_sensor2 = hass.states.get("sensor.system_monitor_disk_free_media_share") + disk_sensor3 = hass.states.get("sensor.system_monitor_disk_free_incorrect") + disk_sensor4 = hass.states.get("sensor.system_monitor_disk_free_proc_run") + disk_sensor5 = hass.states.get("sensor.system_monitor_disk_free_tmpfs") + disk_sensor6 = hass.states.get("sensor.system_monitor_disk_free_e") + assert disk_sensor1 is not None + assert disk_sensor2 is not None + assert disk_sensor3 is None + assert disk_sensor4 is None + assert disk_sensor5 is None + assert disk_sensor6 is None From 3bc20a072a7fa21ccd1c9479417d520c9f3b1327 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 15 Jan 2024 19:46:28 +0100 Subject: [PATCH 0641/1544] Fix test_sensor_process_fails test in System Monitor (#108110) --- tests/components/systemmonitor/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index d173bb11d2e..35ee331c699 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.systemmonitor.sensor import ( get_cpu_icon, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -212,7 +212,7 @@ async def test_sensor_process_fails( process_sensor = hass.states.get("sensor.system_monitor_process_python3") assert process_sensor is not None - # assert process_sensor.state == STATE_ON + assert process_sensor.state == STATE_OFF assert "Failed to load process with ID: 1, old name: python3" in caplog.text From 28e18ce7bff19484a8513b2a7ac5a9427d5335cf Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 15 Jan 2024 20:53:56 +0200 Subject: [PATCH 0642/1544] Fix Shelly Gen1 entity description restore (#108052) * Fix Shelly Gen1 entity description restore * Update tests/components/shelly/test_sensor.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- .../components/shelly/binary_sensor.py | 13 --------- homeassistant/components/shelly/entity.py | 28 +++++-------------- homeassistant/components/shelly/number.py | 21 +------------- homeassistant/components/shelly/sensor.py | 18 +----------- tests/components/shelly/test_sensor.py | 18 ++++++++++-- 5 files changed, 24 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index b07747f298e..4ad51e5cc0f 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD @@ -210,16 +209,6 @@ RPC_SENSORS: Final = { } -def _build_block_description(entry: RegistryEntry) -> BlockBinarySensorDescription: - """Build description when restoring block attribute entities.""" - return BlockBinarySensorDescription( - key="", - name="", - icon=entry.original_icon, - device_class=entry.original_device_class, - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -248,7 +237,6 @@ async def async_setup_entry( async_add_entities, SENSORS, BlockSleepingBinarySensor, - _build_block_description, ) else: async_setup_entry_attribute_entities( @@ -257,7 +245,6 @@ async def async_setup_entry( async_add_entities, SENSORS, BlockBinarySensor, - _build_block_description, ) async_setup_entry_rest( hass, diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 796402c8bba..3132f1f571e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -39,7 +39,6 @@ def async_setup_entry_attribute_entities( async_add_entities: AddEntitiesCallback, sensors: Mapping[tuple[str, str], BlockEntityDescription], sensor_class: Callable, - description_class: Callable[[RegistryEntry], BlockEntityDescription], ) -> None: """Set up entities for attributes.""" coordinator = get_entry_data(hass)[config_entry.entry_id].block @@ -56,7 +55,6 @@ def async_setup_entry_attribute_entities( coordinator, sensors, sensor_class, - description_class, ) @@ -113,7 +111,6 @@ def async_restore_block_attribute_entities( coordinator: ShellyBlockCoordinator, sensors: Mapping[tuple[str, str], BlockEntityDescription], sensor_class: Callable, - description_class: Callable[[RegistryEntry], BlockEntityDescription], ) -> None: """Restore block attributes entities.""" entities = [] @@ -128,11 +125,12 @@ def async_restore_block_attribute_entities( continue attribute = entry.unique_id.split("-")[-1] - description = description_class(entry) + block_type = entry.unique_id.split("-")[-2].split("_")[0] - entities.append( - sensor_class(coordinator, None, attribute, description, entry, sensors) - ) + if description := sensors.get((block_type, attribute)): + entities.append( + sensor_class(coordinator, None, attribute, description, entry) + ) if not entities: return @@ -444,7 +442,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity): """Available.""" available = super().available - if not available or not self.entity_description.available: + if not available or not self.entity_description.available or self.block is None: return available return self.entity_description.available(self.block) @@ -559,10 +557,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): attribute: str, description: BlockEntityDescription, entry: RegistryEntry | None = None, - sensors: Mapping[tuple[str, str], BlockEntityDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" - self.sensors = sensors self.last_state: State | None = None self.coordinator = coordinator self.attribute = attribute @@ -587,11 +583,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): @callback def _update_callback(self) -> None: """Handle device update.""" - if ( - self.block is not None - or not self.coordinator.device.initialized - or self.sensors is None - ): + if self.block is not None or not self.coordinator.device.initialized: super()._update_callback() return @@ -607,13 +599,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): if sensor_id != entity_sensor: continue - description = self.sensors.get((block.type, sensor_id)) - if description is None: - continue - self.block = block - self.entity_description = description - LOGGER.debug("Entity %s attached to block", self.name) super()._update_callback() return diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 77d066a6106..5d35e71ce5d 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -1,7 +1,6 @@ """Number for Shelly.""" from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Final, cast @@ -56,22 +55,6 @@ NUMBERS: Final = { } -def _build_block_description(entry: RegistryEntry) -> BlockNumberDescription: - """Build description when restoring block attribute entities.""" - assert entry.capabilities - return BlockNumberDescription( - key="", - name="", - icon=entry.original_icon, - native_unit_of_measurement=entry.unit_of_measurement, - device_class=entry.original_device_class, - native_min_value=cast(float, entry.capabilities.get("min")), - native_max_value=cast(float, entry.capabilities.get("max")), - native_step=cast(float, entry.capabilities.get("step")), - mode=cast(NumberMode, entry.capabilities.get("mode")), - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -85,7 +68,6 @@ async def async_setup_entry( async_add_entities, NUMBERS, BlockSleepingNumber, - _build_block_description, ) @@ -101,11 +83,10 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): attribute: str, description: BlockNumberDescription, entry: RegistryEntry | None = None, - sensors: Mapping[tuple[str, str], BlockNumberDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" self.restored_data: NumberExtraStoredData | None = None - super().__init__(coordinator, block, attribute, description, entry, sensors) + super().__init__(coordinator, block, attribute, description, entry) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index c7d89f2d284..b439a19e318 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,7 +1,6 @@ """Sensor for Shelly.""" from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from typing import Final, cast @@ -36,7 +35,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from homeassistant.util.enum import try_parse_enum from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator @@ -963,17 +961,6 @@ RPC_SENSORS: Final = { } -def _build_block_description(entry: RegistryEntry) -> BlockSensorDescription: - """Build description when restoring block attribute entities.""" - return BlockSensorDescription( - key="", - name="", - icon=entry.original_icon, - native_unit_of_measurement=entry.unit_of_measurement, - device_class=try_parse_enum(SensorDeviceClass, entry.original_device_class), - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -1002,7 +989,6 @@ async def async_setup_entry( async_add_entities, SENSORS, BlockSleepingSensor, - _build_block_description, ) else: async_setup_entry_attribute_entities( @@ -1011,7 +997,6 @@ async def async_setup_entry( async_add_entities, SENSORS, BlockSensor, - _build_block_description, ) async_setup_entry_rest( hass, config_entry, async_add_entities, REST_SENSORS, RestSensor @@ -1075,10 +1060,9 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor): attribute: str, description: BlockSensorDescription, entry: RegistryEntry | None = None, - sensors: Mapping[tuple[str, str], BlockSensorDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" - super().__init__(coordinator, block, attribute, description, entry, sensors) + super().__init__(coordinator, block, attribute, description, entry) self.restored_data: SensorExtraStoredData | None = None async def async_added_to_hass(self) -> None: diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 380f4f5999e..86c6356191b 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -6,9 +6,15 @@ from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, @@ -153,7 +159,11 @@ async def test_block_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "20.4" + state = hass.states.get(entity_id) + assert state + assert state.state == "20.4" + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) @@ -237,7 +247,9 @@ async def test_block_not_matched_restored_sleeping_sensor( assert hass.states.get(entity_id).state == "20.4" # Make device online - monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "type", "other_type") + monkeypatch.setattr( + mock_block_device.blocks[SENSOR_BLOCK_ID], "description", "other_desc" + ) monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_update() await hass.async_block_till_done() From 1a4d1907c96c9446dd4c19ea33e3c2994dd17573 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 15 Jan 2024 20:33:15 +0100 Subject: [PATCH 0643/1544] Make ATTR_SERIAL_NUMBER a generic homeassistant constant (#108106) --- homeassistant/components/aurora_abb_powerone/config_flow.py | 3 +-- homeassistant/components/aurora_abb_powerone/const.py | 1 - homeassistant/components/aurora_abb_powerone/sensor.py | 2 +- homeassistant/components/linux_battery/sensor.py | 3 +-- homeassistant/const.py | 1 + tests/components/aurora_abb_powerone/test_config_flow.py | 3 +-- tests/components/aurora_abb_powerone/test_init.py | 3 +-- tests/components/aurora_abb_powerone/test_sensor.py | 3 +-- 8 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 07741bd4e3c..32295c3bf47 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -10,13 +10,12 @@ import serial.tools.list_ports import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_ADDRESS, DEFAULT_INTEGRATION_TITLE, DOMAIN, diff --git a/homeassistant/components/aurora_abb_powerone/const.py b/homeassistant/components/aurora_abb_powerone/const.py index d1266a838c3..904f103d1c3 100644 --- a/homeassistant/components/aurora_abb_powerone/const.py +++ b/homeassistant/components/aurora_abb_powerone/const.py @@ -20,6 +20,5 @@ MANUFACTURER = "ABB" ATTR_DEVICE_NAME = "device_name" ATTR_DEVICE_ID = "device_id" -ATTR_SERIAL_NUMBER = "serial_number" ATTR_MODEL = "model" ATTR_FIRMWARE = "firmware" diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 80b0fd656b6..2ca7fa3e7ef 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_SERIAL_NUMBER, EntityCategory, UnitOfEnergy, UnitOfPower, @@ -31,7 +32,6 @@ from .const import ( ATTR_DEVICE_NAME, ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN, MANUFACTURER, diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index 765e0d79537..08b2dc33bae 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.const import ATTR_NAME, CONF_NAME, PERCENTAGE +from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -31,7 +31,6 @@ ATTR_ENERGY_NOW = "energy_now" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL_NAME = "model_name" ATTR_POWER_NOW = "power_now" -ATTR_SERIAL_NUMBER = "serial_number" ATTR_STATUS = "status" ATTR_VOLTAGE_MIN_DESIGN = "voltage_min_design" ATTR_VOLTAGE_NOW = "voltage_now" diff --git a/homeassistant/const.py b/homeassistant/const.py index e0d5a859913..a6c8cfa0405 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -540,6 +540,7 @@ ATTR_CONNECTIONS: Final = "connections" ATTR_DEFAULT_NAME: Final = "default_name" ATTR_MANUFACTURER: Final = "manufacturer" ATTR_MODEL: Final = "model" +ATTR_SERIAL_NUMBER: Final = "serial_number" ATTR_SUGGESTED_AREA: Final = "suggested_area" ATTR_SW_VERSION: Final = "sw_version" ATTR_HW_VERSION: Final = "hw_version" diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index d156dce2154..3b5b375ed8b 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -8,10 +8,9 @@ from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.aurora_abb_powerone.const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DOMAIN, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index 92b448d8645..a330507c779 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -4,10 +4,9 @@ from unittest.mock import patch from homeassistant.components.aurora_abb_powerone.const import ( ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DOMAIN, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index a78682ced6d..4dbbf5f0048 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -8,12 +8,11 @@ from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, ATTR_FIRMWARE, ATTR_MODEL, - ATTR_SERIAL_NUMBER, DEFAULT_INTEGRATION_TITLE, DOMAIN, SCAN_INTERVAL, ) -from homeassistant.const import CONF_ADDRESS, CONF_PORT +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed From b9e532cbb3a83532dab36d533d44681470333d83 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 15 Jan 2024 20:33:30 +0100 Subject: [PATCH 0644/1544] Use compat for supported features in media player (#108102) --- homeassistant/components/media_player/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 113048421e1..673f0a44374 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1295,7 +1295,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" From c2dec8f84fded9cb19d149e558b898c8a5965d71 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 23:09:09 +0100 Subject: [PATCH 0645/1544] Improve electric_kiwi generic typing (#108084) --- homeassistant/components/electric_kiwi/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 3c7edd28421..c3f49d1aba9 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -18,7 +18,7 @@ ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) -class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator): +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): """ElectricKiwi Account Data object.""" def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: From f28f2e4ed46cd00647c8df63cc957aa2bfd163b3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 23:11:03 +0100 Subject: [PATCH 0646/1544] Improve google_translate typing (#108093) --- .../components/google_translate/tts.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 45288e81996..7774d9fd6c8 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -77,17 +77,17 @@ class GoogleTTSEntity(TextToSpeechEntity): self._attr_unique_id = config_entry.entry_id @property - def default_language(self): + def default_language(self) -> str: """Return the default language.""" return self._lang @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return SUPPORT_OPTIONS @@ -120,7 +120,7 @@ class GoogleTTSEntity(TextToSpeechEntity): class GoogleProvider(Provider): """The Google speech API provider.""" - def __init__(self, hass, lang, tld): + def __init__(self, hass: HomeAssistant, lang: str, tld: str) -> None: """Init Google TTS service.""" self.hass = hass if lang in MAP_LANG_TLD: @@ -132,21 +132,23 @@ class GoogleProvider(Provider): self.name = "Google" @property - def default_language(self): + def default_language(self) -> str: """Return the default language.""" return self._lang @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: """Load TTS from google.""" tld = self._tld if language in MAP_LANG_TLD: From 369ed5b7019ac2ca435b68a4c034165a81f8e21f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 23:11:46 +0100 Subject: [PATCH 0647/1544] Improve typing for the generic integration (#108094) --- homeassistant/components/generic/camera.py | 2 +- homeassistant/components/generic/config_flow.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 171497f479b..902f5ebadde 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -178,7 +178,7 @@ class GenericCamera(Camera): return self._last_image @property - def name(self): + def name(self) -> str: """Return the name of this device.""" return self._name diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index af3ff414ac5..4eb5c3a2a4c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -77,8 +77,8 @@ IMAGE_PREVIEWS_ACTIVE = "previews" def build_schema( user_input: Mapping[str, Any], is_options_flow: bool = False, - show_advanced_options=False, -): + show_advanced_options: bool = False, +) -> vol.Schema: """Create schema for camera config setup.""" spec = { vol.Optional( @@ -276,7 +276,7 @@ async def async_test_stream( return {} -def register_preview(hass: HomeAssistant): +def register_preview(hass: HomeAssistant) -> None: """Set up previews for camera feeds during config flow.""" hass.data.setdefault(DOMAIN, {}) From e8b962ea89825de2c8aebffa304e53c5e75e951a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 15 Jan 2024 23:32:58 +0100 Subject: [PATCH 0648/1544] Improve risco typing (#108041) --- homeassistant/components/risco/__init__.py | 2 + .../components/risco/alarm_control_panel.py | 9 +++-- .../components/risco/binary_sensor.py | 11 +++--- homeassistant/components/risco/config_flow.py | 38 +++++++++++++------ homeassistant/components/risco/entity.py | 12 +++--- homeassistant/components/risco/sensor.py | 20 ++++++---- homeassistant/components/risco/switch.py | 10 +++-- 7 files changed, 64 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 9c62447ee04..c58721e4e28 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -1,4 +1,6 @@ """The Risco integration.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass, field from datetime import timedelta diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index a72efe1629c..8a233d0b5fe 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -6,6 +6,7 @@ import logging from typing import Any from pyrisco.common import Partition +from pyrisco.local.partition import Partition as LocalPartition from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -132,7 +133,7 @@ class RiscoAlarm(AlarmControlPanelEntity): return None - def _validate_code(self, code): + def _validate_code(self, code: str | None) -> bool: """Validate given code.""" return code == self._code @@ -159,7 +160,7 @@ class RiscoAlarm(AlarmControlPanelEntity): """Send arm custom bypass command.""" await self._arm(STATE_ALARM_ARMED_CUSTOM_BYPASS, code) - async def _arm(self, mode, code): + async def _arm(self, mode: str, code: str | None) -> None: if self.code_arm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for %s", mode) return @@ -205,7 +206,7 @@ class RiscoCloudAlarm(RiscoAlarm, RiscoCloudEntity): def _get_data_from_coordinator(self) -> None: self._partition = self.coordinator.data.partitions[self._partition_id] - async def _call_alarm_method(self, method, *args): + async def _call_alarm_method(self, method: str, *args: Any) -> None: alarm = await getattr(self._risco, method)(self._partition_id, *args) self._partition = alarm.partitions[self._partition_id] self.async_write_ha_state() @@ -220,7 +221,7 @@ class RiscoLocalAlarm(RiscoAlarm): self, system_id: str, partition_id: int, - partition: Partition, + partition: LocalPartition, partition_updates: dict[int, Callable[[], Any]], code: str, options: dict[str, Any], diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index f60b0bf3c35..ea7153b2aee 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pyrisco.common import Zone +from pyrisco.cloud.zone import Zone as CloudZone +from pyrisco.local.zone import Zone as LocalZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -53,7 +54,7 @@ class RiscoCloudBinarySensor(RiscoCloudZoneEntity, BinarySensorEntity): _attr_name = None def __init__( - self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone + self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: CloudZone ) -> None: """Init the zone.""" super().__init__(coordinator=coordinator, suffix="", zone_id=zone_id, zone=zone) @@ -70,7 +71,7 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION _attr_name = None - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__(system_id=system_id, suffix="", zone_id=zone_id, zone=zone) @@ -93,7 +94,7 @@ class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_translation_key = "alarmed" - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__( system_id=system_id, @@ -113,7 +114,7 @@ class RiscoLocalArmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): _attr_translation_key = "armed" - def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: + def __init__(self, system_id: str, zone_id: int, zone: LocalZone) -> None: """Init the zone.""" super().__init__( system_id=system_id, diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index ef96714742d..61a452a7ecb 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -63,7 +63,9 @@ HA_STATES = [ ] -async def validate_cloud_input(hass: core.HomeAssistant, data) -> dict[str, str]: +async def validate_cloud_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect to Risco Cloud. Data has the keys from CLOUD_SCHEMA with values provided by the user. @@ -124,16 +126,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" return self.async_show_menu( step_id="user", menu_options=["cloud", "local"], ) - async def async_step_cloud(self, user_input=None): + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a cloud based alarm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: if not self._reauth_entry: await self.async_set_unique_id(user_input[CONF_USERNAME]) @@ -168,14 +174,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_entry = await self.async_set_unique_id(entry_data[CONF_USERNAME]) return await self.async_step_cloud() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a local based alarm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: info = await validate_local_input(self.hass, user_input) - except CannotConnectError: - _LOGGER.debug("Cannot connect", exc_info=1) + except CannotConnectError as ex: + _LOGGER.debug("Cannot connect", exc_info=ex) errors["base"] = "cannot_connect" except UnauthorizedError: errors["base"] = "invalid_auth" @@ -208,7 +216,7 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} - def _options_schema(self): + def _options_schema(self) -> vol.Schema: return vol.Schema( { vol.Required( @@ -224,7 +232,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): } ) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: self._data = {**self._data, **user_input} @@ -232,7 +242,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="init", data_schema=self._options_schema()) - async def async_step_risco_to_ha(self, user_input=None): + async def async_step_risco_to_ha( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Map Risco states to HA states.""" if user_input is not None: self._data[CONF_RISCO_STATES_TO_HA] = user_input @@ -250,7 +262,9 @@ class RiscoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="risco_to_ha", data_schema=options) - async def async_step_ha_to_risco(self, user_input=None): + async def async_step_ha_to_risco( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Map HA states to Risco states.""" if user_input is not None: self._data[CONF_HA_STATES_TO_RISCO] = user_input diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index e522c29ce19..ac3c04cfc2e 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import Any -from pyrisco.common import Zone +from pyrisco import RiscoCloud +from pyrisco.cloud.zone import Zone as CloudZone +from pyrisco.local.zone import Zone as LocalZone from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,7 +16,7 @@ from . import RiscoDataUpdateCoordinator, zone_update_signal from .const import DOMAIN -def zone_unique_id(risco, zone_id: int) -> str: +def zone_unique_id(risco: RiscoCloud, zone_id: int) -> str: """Return unique id for a cloud zone.""" return f"{risco.site_uuid}_zone_{zone_id}" @@ -36,7 +38,7 @@ class RiscoCloudEntity(CoordinatorEntity[RiscoDataUpdateCoordinator]): self.async_write_ha_state() @property - def _risco(self): + def _risco(self) -> RiscoCloud: """Return the Risco API object.""" return self.coordinator.risco @@ -52,7 +54,7 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): coordinator: RiscoDataUpdateCoordinator, suffix: str, zone_id: int, - zone: Zone, + zone: CloudZone, **kwargs: Any, ) -> None: """Init the zone.""" @@ -84,7 +86,7 @@ class RiscoLocalZoneEntity(Entity): system_id: str, suffix: str, zone_id: int, - zone: Zone, + zone: LocalZone, **kwargs: Any, ) -> None: """Init the zone.""" diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index 1d60ea4d7c2..138c08c18f6 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -2,8 +2,11 @@ from __future__ import annotations from collections.abc import Collection, Mapping +from datetime import datetime from typing import Any +from pyrisco.cloud.event import Event + from homeassistant.components.binary_sensor import DOMAIN as BS_DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -66,22 +69,23 @@ async def async_setup_entry( class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEntity): """Sensor for Risco events.""" + _entity_registry: er.EntityRegistry + def __init__( self, coordinator: RiscoEventsDataUpdateCoordinator, category_id: int | None, - excludes: Collection[int] | None, + excludes: Collection[int], name: str, entry_id: str, ) -> None: """Initialize sensor.""" super().__init__(coordinator) - self._event = None + self._event: Event | None = None self._category_id = category_id self._excludes = excludes self._name = name self._entry_id = entry_id - self._entity_registry: er.EntityRegistry | None = None self._attr_unique_id = f"events_{name}_{self.coordinator.risco.site_uuid}" self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP @@ -91,7 +95,7 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt await super().async_added_to_hass() self._entity_registry = er.async_get(self.hass) - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: events = self.coordinator.data for event in reversed(events): if event.category_id in self._excludes: @@ -103,14 +107,14 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt self.async_write_ha_state() @property - def native_value(self): + def native_value(self) -> datetime | None: """Value of sensor.""" if self._event is None: return None - return dt_util.parse_datetime(self._event.time).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ) + if res := dt_util.parse_datetime(self._event.time): + return res.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + return None @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 9b34479f8a2..d22b2bb2192 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -1,6 +1,8 @@ """Support for bypassing Risco alarm zones.""" from __future__ import annotations +from typing import Any + from pyrisco.common import Zone from homeassistant.components.switch import SwitchEntity @@ -58,11 +60,11 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Return true if the zone is bypassed.""" return self._zone.bypassed - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._bypass(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._bypass(False) @@ -92,11 +94,11 @@ class RiscoLocalSwitch(RiscoLocalZoneEntity, SwitchEntity): """Return true if the zone is bypassed.""" return self._zone.bypassed - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._bypass(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._bypass(False) From 5011a25ea688cd1e7c6b915cc322589cfb03df05 Mon Sep 17 00:00:00 2001 From: Leendert Gravendeel Date: Tue, 16 Jan 2024 06:39:50 +0100 Subject: [PATCH 0649/1544] Add Epion integration (#107570) * Adding initial Epion Air integration logic * Skipping sensors with missing data * Patching Epion integration * Adding additional Epion measurement types * Cleaning up logging * Cleaning up code * Fixing error handling for invalid Epion keys * Adding tests and improving error handling * Patching Epion tests * Cleaning up Epion integration code * Bumping Epion package and including missing files * Moving data updates to coordinator and addressing feedback * Improve exception handling * Exposing model name and firmware version * Cleaning up code according to review * Cleaning up code according to review * Adding check to prevent duplicate account setup * Refactoring tests and checking for duplicates * Cleaning up test code according to review * Cleaning up test code * Removing entity name overrides * Fix code format for tests * Adding missing newlines in JSON files * Fixing formatting * Updating device method to always return a device * Updating coordinator --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/epion/__init__.py | 32 +++++ homeassistant/components/epion/config_flow.py | 54 ++++++++ homeassistant/components/epion/const.py | 5 + homeassistant/components/epion/coordinator.py | 45 +++++++ homeassistant/components/epion/manifest.json | 11 ++ homeassistant/components/epion/sensor.py | 113 +++++++++++++++++ homeassistant/components/epion/strings.json | 18 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/epion/__init__.py | 1 + tests/components/epion/conftest.py | 25 ++++ .../fixtures/get_current_one_device.json | 15 +++ tests/components/epion/test_config_flow.py | 115 ++++++++++++++++++ 17 files changed, 452 insertions(+) create mode 100644 homeassistant/components/epion/__init__.py create mode 100644 homeassistant/components/epion/config_flow.py create mode 100644 homeassistant/components/epion/const.py create mode 100644 homeassistant/components/epion/coordinator.py create mode 100644 homeassistant/components/epion/manifest.json create mode 100644 homeassistant/components/epion/sensor.py create mode 100644 homeassistant/components/epion/strings.json create mode 100644 tests/components/epion/__init__.py create mode 100644 tests/components/epion/conftest.py create mode 100644 tests/components/epion/fixtures/get_current_one_device.json create mode 100644 tests/components/epion/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f6ecdc3e718..9c02fb7f677 100644 --- a/.coveragerc +++ b/.coveragerc @@ -331,6 +331,9 @@ omit = homeassistant/components/environment_canada/weather.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py + homeassistant/components/epion/__init__.py + homeassistant/components/epion/coordinator.py + homeassistant/components/epion/sensor.py homeassistant/components/epson/__init__.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1288ea53591..d1aa09eb93c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -359,6 +359,8 @@ build.json @home-assistant/supervisor /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 +/homeassistant/components/epion/ @lhgravendeel +/tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer /tests/components/epson/ @pszafer /homeassistant/components/epsonworkforce/ @ThaStealth diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py new file mode 100644 index 00000000000..ed2f5559f32 --- /dev/null +++ b/homeassistant/components/epion/__init__.py @@ -0,0 +1,32 @@ +"""The Epion integration.""" +from __future__ import annotations + +from epion import Epion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import EpionCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Epion coordinator from a config entry.""" + api = Epion(entry.data[CONF_API_KEY]) + coordinator = EpionCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Epion config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/epion/config_flow.py b/homeassistant/components/epion/config_flow.py new file mode 100644 index 00000000000..7c89df94519 --- /dev/null +++ b/homeassistant/components/epion/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Epion.""" +from __future__ import annotations + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class EpionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Epion.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input: + api = Epion(user_input[CONF_API_KEY]) + try: + api_data = await self.hass.async_add_executor_job(api.get_current) + except EpionAuthenticationError: + errors["base"] = "invalid_auth" + except EpionConnectionError: + _LOGGER.error("Unexpected problem when configuring Epion API") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(api_data["accountId"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Epion integration", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/epion/const.py b/homeassistant/components/epion/const.py new file mode 100644 index 00000000000..83f82261583 --- /dev/null +++ b/homeassistant/components/epion/const.py @@ -0,0 +1,5 @@ +"""Constants for the Epion API.""" +from datetime import timedelta + +DOMAIN = "epion" +REFRESH_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py new file mode 100644 index 00000000000..3eb7efb5dc7 --- /dev/null +++ b/homeassistant/components/epion/coordinator.py @@ -0,0 +1,45 @@ +"""The Epion data coordinator.""" + +import logging +from typing import Any + +from epion import Epion, EpionAuthenticationError, EpionConnectionError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import REFRESH_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Epion data update coordinator.""" + + def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + """Initialize the Epion coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Epion", + update_interval=REFRESH_INTERVAL, + ) + self.epion_api = epion_api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Epion API and construct a dictionary with device IDs as keys.""" + try: + response = await self.hass.async_add_executor_job( + self.epion_api.get_current + ) + except EpionAuthenticationError as err: + _LOGGER.error("Authentication error with Epion API") + raise ConfigEntryAuthFailed from err + except EpionConnectionError as err: + _LOGGER.error("Epion API connection problem") + raise UpdateFailed(f"Error communicating with API: {err}") from err + device_data = {} + for epion_device in response["devices"]: + device_data[epion_device["deviceId"]] = epion_device + return device_data diff --git a/homeassistant/components/epion/manifest.json b/homeassistant/components/epion/manifest.json new file mode 100644 index 00000000000..a1b8497f7e2 --- /dev/null +++ b/homeassistant/components/epion/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "epion", + "name": "Epion", + "codeowners": ["@lhgravendeel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/epion", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["epion"], + "requirements": ["epion==0.0.3"] +} diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py new file mode 100644 index 00000000000..826d565c2cd --- /dev/null +++ b/homeassistant/components/epion/sensor.py @@ -0,0 +1,113 @@ +"""Support for Epion API.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EpionCoordinator + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="co2", + suggested_display_precision=0, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + key="temperature", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + key="humidity", + suggested_display_precision=1, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.HPA, + key="pressure", + suggested_display_precision=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add an Epion entry.""" + coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EpionSensor(coordinator, epion_device_id, description) + for epion_device_id in coordinator.data + for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): + """Representation of an Epion Air sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EpionCoordinator, + epion_device_id: str, + description: SensorEntityDescription, + ) -> None: + """Initialize an EpionSensor.""" + super().__init__(coordinator) + self._epion_device_id = epion_device_id + self.entity_description = description + self.unique_id = f"{epion_device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._epion_device_id)}, + manufacturer="Epion", + name=self.device.get("deviceName"), + sw_version=self.device.get("fwVersion"), + model="Epion Air", + ) + + @property + def native_value(self) -> float | None: + """Return the value reported by the sensor, or None if the relevant sensor can't produce a current measurement.""" + return self.device.get(self.entity_description.key) + + @property + def available(self) -> bool: + """Return the availability of the device that provides this sensor data.""" + return super().available and self._epion_device_id in self.coordinator.data + + @property + def device(self) -> dict[str, Any]: + """Get the device record from the current coordinator data, or None if there is no data being returned for this device ID anymore.""" + return self.coordinator.data[self._epion_device_id] diff --git a/homeassistant/components/epion/strings.json b/homeassistant/components/epion/strings.json new file mode 100644 index 00000000000..f8ef9de230c --- /dev/null +++ b/homeassistant/components/epion/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c62203b4d6c..752092c02c7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -140,6 +140,7 @@ FLOWS = { "enocean", "enphase_envoy", "environment_canada", + "epion", "epson", "escea", "esphome", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 49527ba6dd0..373853681d7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1577,6 +1577,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "epion": { + "name": "Epion", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "epson": { "name": "Epson", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index 5d0fd2f36d9..5cf4603f373 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -787,6 +787,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fbc37f8323..53be88e3f4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -638,6 +638,9 @@ env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 +# homeassistant.components.epion +epion==0.0.3 + # homeassistant.components.epson epson-projector==0.5.1 diff --git a/tests/components/epion/__init__.py b/tests/components/epion/__init__.py new file mode 100644 index 00000000000..2327d2fa848 --- /dev/null +++ b/tests/components/epion/__init__.py @@ -0,0 +1 @@ +"""Tests for the Epion component.""" diff --git a/tests/components/epion/conftest.py b/tests/components/epion/conftest.py new file mode 100644 index 00000000000..2290d0d4c8f --- /dev/null +++ b/tests/components/epion/conftest.py @@ -0,0 +1,25 @@ +"""Epion tests configuration.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tests.common import load_json_object_fixture + + +@pytest.fixture +def mock_epion(): + """Build a fixture for the Epion API that connects successfully and returns one device.""" + current_one_device_data = load_json_object_fixture( + "epion/get_current_one_device.json" + ) + mock_epion_api = MagicMock() + with patch( + "homeassistant.components.epion.config_flow.Epion", + return_value=mock_epion_api, + ) as mock_epion_api, patch( + "homeassistant.components.epion.Epion", + return_value=mock_epion_api, + ): + mock_epion_api.return_value.get_current.return_value = current_one_device_data + yield mock_epion_api diff --git a/tests/components/epion/fixtures/get_current_one_device.json b/tests/components/epion/fixtures/get_current_one_device.json new file mode 100644 index 00000000000..4cfeb673bfe --- /dev/null +++ b/tests/components/epion/fixtures/get_current_one_device.json @@ -0,0 +1,15 @@ +{ + "devices": [ + { + "deviceId": "abc", + "deviceName": "Test Device", + "co2": 500, + "temperature": 12.34, + "humidity": 34.56, + "pressure": 1010.101, + "lastMeasurement": 1705329293171, + "fwVersion": "1.2.3" + } + ], + "accountId": "account-dupe-123" +} diff --git a/tests/components/epion/test_config_flow.py b/tests/components/epion/test_config_flow.py new file mode 100644 index 00000000000..50666d52336 --- /dev/null +++ b/tests/components/epion/test_config_flow.py @@ -0,0 +1,115 @@ +"""Tests for the Epion config flow.""" +from unittest.mock import MagicMock, patch + +from epion import EpionAuthenticationError, EpionConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.epion.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +API_KEY = "test-key-123" + + +async def test_user_flow(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test we can handle a regular successflow setup flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (EpionAuthenticationError("Invalid auth"), "invalid_auth"), + (EpionConnectionError("Timeout error"), "cannot_connect"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str, mock_epion: MagicMock +) -> None: + """Test we can handle Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_epion.return_value.get_current.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_epion.return_value.get_current.side_effect = None + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Epion integration" + assert result["data"] == { + CONF_API_KEY: API_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry(hass: HomeAssistant, mock_epion: MagicMock) -> None: + """Test duplicate setup handling.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + }, + unique_id="account-dupe-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.epion.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: API_KEY}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 From 562798f037d610949a438ba877c2e0dbdcd5a3ab Mon Sep 17 00:00:00 2001 From: cnico Date: Tue, 16 Jan 2024 06:56:54 +0100 Subject: [PATCH 0650/1544] Bump flipr-api to 1.5.1 (#108130) Flipr-api version update for resolution of issue https://github.com/home-assistant/core/issues/105778 --- homeassistant/components/flipr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index 73a0b3edb26..898cd640349 100644 --- a/homeassistant/components/flipr/manifest.json +++ b/homeassistant/components/flipr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flipr", "iot_class": "cloud_polling", "loggers": ["flipr_api"], - "requirements": ["flipr-api==1.5.0"] + "requirements": ["flipr-api==1.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5cf4603f373..53fad4b5d7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -846,7 +846,7 @@ fjaraskupan==2.2.0 flexit_bacnet==2.1.0 # homeassistant.components.flipr -flipr-api==1.5.0 +flipr-api==1.5.1 # homeassistant.components.flux_led flux-led==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53be88e3f4b..9cf64e28fe5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -681,7 +681,7 @@ fjaraskupan==2.2.0 flexit_bacnet==2.1.0 # homeassistant.components.flipr -flipr-api==1.5.0 +flipr-api==1.5.1 # homeassistant.components.flux_led flux-led==1.0.4 From af6ad6be414c4b5014882b5235bbb0565e1c1b6d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 16 Jan 2024 08:51:57 +0100 Subject: [PATCH 0651/1544] Remove deprecated vacuum services from tuya (#107896) --- homeassistant/components/tuya/strings.json | 24 --------------- homeassistant/components/tuya/vacuum.py | 34 ---------------------- 2 files changed, 58 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index e9b13e10a95..ad9da548d6c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -817,29 +817,5 @@ "name": "Sterilization" } } - }, - "issues": { - "service_deprecation_turn_off": { - "title": "Tuya vacuum support for vacuum.turn_off is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::tuya::issues::service_deprecation_turn_off::title%]", - "description": "Tuya vacuum support for the vacuum.turn_off service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.stop and select submit below to mark this issue as resolved." - } - } - } - }, - "service_deprecation_turn_on": { - "title": "Tuya vacuum support for vacuum.turn_on is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::tuya::issues::service_deprecation_turn_on::title%]", - "description": "Tuya vacuum support for the vacuum.turn_on service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.start and select submit below to mark this issue as resolved." - } - } - } - } } } diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index b332be7de2d..d067d3786ea 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -15,7 +15,6 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -105,11 +104,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.SEEK, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER, prefer_function=True): - self._attr_supported_features |= ( - VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF - ) - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -151,34 +145,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): return None return TUYA_STATUS_TO_HA.get(status) - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self._send_command([{"code": DPCode.POWER, "value": True}]) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_on", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_on", - ) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self._send_command([{"code": DPCode.POWER, "value": False}]) - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation_turn_off", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation_turn_off", - ) - def start(self, **kwargs: Any) -> None: """Start the device.""" self._send_command([{"code": DPCode.POWER_GO, "value": True}]) From fb24e086b27b92762426ed4d1112694944aa7d7e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 16 Jan 2024 09:04:27 +0100 Subject: [PATCH 0652/1544] Hide FlowResultType.SHOW_PROGRESS_DONE from frontend (#107799) * Hide FlowResultType.SHOW_PROGRESS_DONE from frontend * Update tests --- homeassistant/data_entry_flow.py | 11 +++++- tests/test_data_entry_flow.py | 58 ++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 5eed267fbbd..40d0a4de763 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -311,6 +311,15 @@ class FlowManager(abc.ABC): async def async_configure( self, flow_id: str, user_input: dict | None = None + ) -> FlowResult: + """Continue a data entry flow.""" + result: FlowResult | None = None + while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE: + result = await self._async_configure(flow_id, user_input) + return result + + async def _async_configure( + self, flow_id: str, user_input: dict | None = None ) -> FlowResult: """Continue a data entry flow.""" if (flow := self._progress.get(flow_id)) is None: @@ -455,7 +464,7 @@ class FlowManager(abc.ABC): # The flow's progress task was changed, register a callback on it async def call_configure() -> None: with suppress(UnknownFlow): - await self.async_configure(flow.flow_id) + await self._async_configure(flow.flow_id) def schedule_configure(_: asyncio.Task) -> None: self.hass.async_create_task(call_configure()) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 130f4829ca2..78833ac7517 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -507,6 +507,54 @@ async def test_show_progress_error(hass: HomeAssistant, manager) -> None: assert result["reason"] == "error" +async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) -> None: + """Test show progress done is not sent to frontend.""" + manager.hass = hass + async_show_progress_done_called = False + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + progress_task: asyncio.Task[None] | None = None + + async def async_step_init(self, user_input=None): + async def long_running_job() -> None: + return + + if not self.progress_task: + self.progress_task = hass.async_create_task(long_running_job()) + if self.progress_task.done(): + nonlocal async_show_progress_done_called + async_show_progress_done_called = True + return self.async_show_progress_done(next_step_id="finish") + return self.async_show_progress( + step_id="init", + progress_action="task", + # Set to None to simulate flow manager has not yet called when + # frontend loads + progress_task=None, + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=None, data=self.data) + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + await hass.async_block_till_done() + assert not async_show_progress_done_called + + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert async_show_progress_done_called + + async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> None: """Test show progress logic. @@ -580,7 +628,10 @@ async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> Non result = await manager.async_configure( result["flow_id"], {"task_finished": 2, "title": "Hello"} ) - assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE + # Note: The SHOW_PROGRESS_DONE is hidden from frontend; FlowManager automatically + # calls the flow again + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "Hello" await hass.async_block_till_done() assert len(events) == 2 # 1 for task one and 1 for task two @@ -590,11 +641,6 @@ async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> Non "refresh": True, } - # Frontend refreshes the flow - result = await manager.async_configure(result["flow_id"]) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Hello" - # Check for deprecation warning assert ( "tests.test_data_entry_flow::TestFlow calls async_show_progress without passing" From 28281523ec6501bdbf126a32ad95ead90374b768 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 16 Jan 2024 09:47:53 +0100 Subject: [PATCH 0653/1544] Add pylint plugin to check for sorted platforms list (#108115) --- homeassistant/components/abode/__init__.py | 6 +- .../components/advantage_air/__init__.py | 2 +- .../components/alarmdecoder/__init__.py | 2 +- .../components/amberelectric/const.py | 2 +- homeassistant/components/atag/__init__.py | 2 +- homeassistant/components/august/const.py | 2 +- homeassistant/components/bloomsky/__init__.py | 2 +- .../components/coolmaster/__init__.py | 2 +- .../components/danfoss_air/__init__.py | 2 +- homeassistant/components/dynalite/const.py | 2 +- homeassistant/components/econet/__init__.py | 2 +- homeassistant/components/enocean/const.py | 2 +- homeassistant/components/fibaro/__init__.py | 4 +- .../components/fireservicerota/__init__.py | 2 +- homeassistant/components/freebox/const.py | 4 +- homeassistant/components/fritz/const.py | 2 +- homeassistant/components/gdacs/const.py | 2 +- .../components/geonetnz_quakes/const.py | 2 +- .../components/huawei_lte/__init__.py | 2 +- homeassistant/components/hyperion/__init__.py | 2 +- homeassistant/components/ipma/__init__.py | 2 +- homeassistant/components/juicenet/__init__.py | 2 +- homeassistant/components/kaiterra/const.py | 2 +- .../components/lutron_caseta/__init__.py | 2 +- .../components/mobile_app/__init__.py | 2 +- .../components/modern_forms/__init__.py | 2 +- .../components/moehlenhoff_alpha2/__init__.py | 2 +- homeassistant/components/mqtt/const.py | 4 +- homeassistant/components/neato/__init__.py | 8 +-- homeassistant/components/nest/__init__.py | 2 +- .../components/nissan_leaf/__init__.py | 2 +- .../components/philips_js/__init__.py | 4 +- homeassistant/components/plaato/const.py | 2 +- .../components/progettihwsw/__init__.py | 2 +- homeassistant/components/rachio/__init__.py | 2 +- homeassistant/components/rainbird/__init__.py | 6 +- homeassistant/components/rfxtrx/__init__.py | 6 +- homeassistant/components/ring/const.py | 4 +- homeassistant/components/roborock/const.py | 2 +- homeassistant/components/roomba/const.py | 2 +- homeassistant/components/smartthings/const.py | 8 +-- homeassistant/components/spider/const.py | 2 +- homeassistant/components/tailwind/__init__.py | 2 +- .../components/vodafone_station/__init__.py | 2 +- homeassistant/components/wallbox/__init__.py | 2 +- .../plugins/hass_enforce_sorted_platforms.py | 39 ++++++++++ pyproject.toml | 1 + tests/pylint/conftest.py | 21 ++++++ tests/pylint/test_enforce_sorted_platforms.py | 71 +++++++++++++++++++ 49 files changed, 194 insertions(+), 62 deletions(-) create mode 100644 pylint/plugins/hass_enforce_sorted_platforms.py create mode 100644 tests/pylint/test_enforce_sorted_platforms.py diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 4e4b6a9561d..55ce9e054c3 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -63,12 +63,12 @@ AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, - Platform.LOCK, - Platform.SWITCH, - Platform.COVER, Platform.CAMERA, + Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 1383ea7c054..0ef2c0eada5 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -19,11 +19,11 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.LIGHT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, - Platform.LIGHT, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 807d12383bc..19d1d729a5e 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -39,8 +39,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, - Platform.SENSOR, Platform.BINARY_SENSOR, + Platform.SENSOR, ] diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 5f92e5a9117..8416b7ca33c 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -11,4 +11,4 @@ CONF_SITE_NMI = "site_nmi" ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index 2d04ca798e0..b0cc83ab88e 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "atag" -PLATFORMS = [Platform.CLIMATE, Platform.WATER_HEATER, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index b97890d09b6..0cbd21f397e 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -50,9 +50,9 @@ LOGIN_METHODS = ["phone", "email"] DEFAULT_LOGIN_METHOD = "email" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, - Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR, ] diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py index 5b069cacdb3..59e224b0b6b 100644 --- a/homeassistant/components/bloomsky/__init__.py +++ b/homeassistant/components/bloomsky/__init__.py @@ -16,7 +16,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] DOMAIN = "bloomsky" diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index eaca8949b14..d01310a6266 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -9,7 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN from .coordinator import CoolmasterDataUpdateCoordinator -PLATFORMS = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 599edbb7703..5069a62bcdf 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -15,7 +15,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] DOMAIN = "danfoss_air" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 83cc639d1da..f46719febb1 100644 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -6,7 +6,7 @@ from homeassistant.const import CONF_ROOM, Platform LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.COVER] +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SWITCH] CONF_ACTIVE = "active" diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 67cbd7496e3..5728c87938b 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -27,8 +27,8 @@ from .const import API_CLIENT, DOMAIN, EQUIPMENT _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - Platform.CLIMATE, Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 08e9b5ba11d..f9c522393d7 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -15,8 +15,8 @@ SIGNAL_SEND_MESSAGE = "enocean.send_message" LOGGER = logging.getLogger(__package__) PLATFORMS = [ - Platform.LIGHT, Platform.BINARY_SENSOR, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8b41c4f404f..159ba62bd24 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -42,12 +42,12 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.LIGHT, + Platform.LOCK, Platform.SCENE, Platform.SENSOR, - Platform.LOCK, Platform.SWITCH, - Platform.EVENT, ] FIBARO_TYPEMAP = { diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index a9a4323fe12..cb7a18dfcac 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -25,7 +25,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index f74f6f49ebf..ef5cabda1b6 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -19,12 +19,12 @@ API_VERSION = "v6" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, Platform.DEVICE_TRACKER, Platform.SENSOR, - Platform.BINARY_SENSOR, Platform.SWITCH, - Platform.CAMERA, ] DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 16015ec5837..fb60eaef5f8 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -28,8 +28,8 @@ class MeshRoles(StrEnum): DOMAIN = "fritz" PLATFORMS = [ - Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, Platform.SENSOR, diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index 551c8be5810..6be7e7b32fc 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform DOMAIN = "gdacs" -PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] +PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] FEED = "feed" diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index f3303d551ce..6ec2199f9e4 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -5,7 +5,7 @@ from homeassistant.const import Platform DOMAIN = "geonetnz_quakes" -PLATFORMS = [Platform.SENSOR, Platform.GEO_LOCATION] +PLATFORMS = [Platform.GEO_LOCATION, Platform.SENSOR] CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" CONF_MMI = "mmi" diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ba276625730..29c59d3ff9c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -127,9 +127,9 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, - Platform.SELECT, ] diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index ea038b3b408..42d9770656b 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -32,7 +32,7 @@ from .const import ( SIGNAL_INSTANCE_REMOVE, ) -PLATFORMS = [Platform.LIGHT, Platform.SWITCH, Platform.CAMERA] +PLATFORMS = [Platform.CAMERA, Platform.LIGHT, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 5ff89fa8ed5..4cb8f921ba4 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -17,7 +17,7 @@ from .const import DATA_API, DATA_LOCATION, DOMAIN DEFAULT_NAME = "ipma" -PLATFORMS = [Platform.WEATHER, Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index c1744b30b1a..bcefe763e15 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -20,7 +20,7 @@ from .device import JuiceNetApi _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] CONFIG_SCHEMA = vol.Schema( vol.All( diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py index c77b3c34564..a73a698f80a 100644 --- a/homeassistant/components/kaiterra/const.py +++ b/homeassistant/components/kaiterra/const.py @@ -72,4 +72,4 @@ DEFAULT_AQI_STANDARD = "us" DEFAULT_PREFERRED_UNIT: list[str] = [] DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) -PLATFORMS = [Platform.SENSOR, Platform.AIR_QUALITY] +PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR] diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 0788af76aca..33cf6f21d6f 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -91,12 +91,12 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.SCENE, Platform.SWITCH, - Platform.BUTTON, ] diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 124ef750baa..831d2d5cdfb 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -39,7 +39,7 @@ from .http_api import RegistrationsView from .util import async_create_cloud_hook from .webhook import handle_webhook -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index 78d2fafa078..3401d961bc8 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -34,8 +34,8 @@ _P = ParamSpec("_P") SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ Platform.BINARY_SENSOR, - Platform.LIGHT, Platform.FAN, + Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 05f6b23fdd0..fa5db2d0e81 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -17,7 +17,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] UPDATE_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 50ea3860d9e..304877695c3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -142,9 +142,9 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, + Platform.COVER, Platform.DEVICE_TRACKER, Platform.EVENT, - Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, @@ -152,8 +152,8 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, - Platform.SELECT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 52bc841f3b5..196961652f0 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -41,11 +41,11 @@ CONFIG_SCHEMA = vol.Schema( ) PLATFORMS = [ - Platform.CAMERA, - Platform.VACUUM, - Platform.SWITCH, - Platform.SENSOR, Platform.BUTTON, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, ] diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index e85073061c2..8d1d58f9117 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -95,7 +95,7 @@ CONFIG_SCHEMA = vol.Schema( ) # Platforms for SDM API -PLATFORMS = [Platform.SENSOR, Platform.CAMERA, Platform.CLIMATE] +PLATFORMS = [Platform.CAMERA, Platform.CLIMATE, Platform.SENSOR] # Fetch media events with a disk backed cache, with a limit for each camera # device. The largest media items are mp4 clips at ~120kb each, and we target diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index a32ba2e6329..a0cb3c4f8cc 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -87,7 +87,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.BINARY_SENSOR, Platform.BUTTON] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] SIGNAL_UPDATE_LEAF = "nissan_leaf_update" diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index b81fec90a59..3fce7f1fafd 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -32,11 +32,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN PLATFORMS = [ - Platform.MEDIA_PLAYER, + Platform.BINARY_SENSOR, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SWITCH, - Platform.BINARY_SENSOR, ] LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/plaato/const.py b/homeassistant/components/plaato/const.py index c47b91a4adb..6825baff906 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -17,7 +17,7 @@ PLACEHOLDER_DOCS_URL = "docs_url" PLACEHOLDER_DEVICE_TYPE = "device_type" PLACEHOLDER_DEVICE_NAME = "device_name" DOCS_URL = "https://www.home-assistant.io/integrations/plaato/" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] SENSOR_DATA = "sensor_data" COORDINATOR = "coordinator" DEVICE = "device" diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index d1d27b78769..1bf23befbdb 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index e47004f5fb7..13299b4e7dc 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -22,7 +22,7 @@ from .webhooks import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index e5731dc08fe..f7eab3bc2f2 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -20,11 +20,11 @@ from .coordinator import RainbirdData _LOGGER = logging.getLogger(__name__) PLATFORMS = [ - Platform.SWITCH, - Platform.SENSOR, Platform.BINARY_SENSOR, - Platform.NUMBER, Platform.CALENDAR, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index cfacc627744..ffbc3d26421 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -76,12 +76,12 @@ def _bytearray_string(data: Any) -> bytearray: SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) PLATFORMS = [ - Platform.SWITCH, - Platform.SENSOR, - Platform.LIGHT, Platform.BINARY_SENSOR, Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, Platform.SIREN, + Platform.SWITCH, ] diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 10d517ab4a3..4f208e4f63e 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -15,11 +15,11 @@ DEFAULT_ENTITY_NAMESPACE = "ring" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.CAMERA, Platform.LIGHT, Platform.SENSOR, - Platform.SWITCH, - Platform.CAMERA, Platform.SIREN, + Platform.SWITCH, ] diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index d7a3a9229f5..f163c9620d1 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -9,8 +9,8 @@ CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" PLATFORMS = [ - Platform.BUTTON, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index 151d3bfb68e..71db96f8c21 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -2,7 +2,7 @@ from homeassistant.const import Platform DOMAIN = "roomba" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.VACUUM] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.VACUUM] CONF_CERT = "certificate" CONF_CONTINUOUS = "continuous" CONF_BLID = "blid" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 1bd21cd73cd..393242a30dd 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -34,15 +34,15 @@ STORAGE_VERSION = 1 # Ordered 'specific to least-specific platform' in order for capabilities # to be drawn-down and represented by the most appropriate platform. PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.LOCK, - Platform.COVER, - Platform.SWITCH, - Platform.BINARY_SENSOR, - Platform.SENSOR, Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, ] IGNORED_CAPABILITIES = [ diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py index 503625fedd2..e48e963637a 100644 --- a/homeassistant/components/spider/const.py +++ b/homeassistant/components/spider/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "spider" DEFAULT_SCAN_INTERVAL = 300 -PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index f4772050e5a..c7ceb88294a 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -9,7 +9,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.BUTTON, Platform.NUMBER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 816e9241739..66960921750 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.BUTTON] +PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 8194a3ea262..4ca6e768f64 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator -PLATFORMS = [Platform.SENSOR, Platform.NUMBER, Platform.LOCK, Platform.SWITCH] +PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/pylint/plugins/hass_enforce_sorted_platforms.py b/pylint/plugins/hass_enforce_sorted_platforms.py new file mode 100644 index 00000000000..b3fb7c8adcc --- /dev/null +++ b/pylint/plugins/hass_enforce_sorted_platforms.py @@ -0,0 +1,39 @@ +"""Plugin for checking sorted platforms list.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassEnforceSortedPlatformsChecker(BaseChecker): + """Checker for sorted platforms list.""" + + name = "hass_enforce_sorted_platforms" + priority = -1 + msgs = { + "W7451": ( + "Platforms must be sorted alphabetically", + "hass-enforce-sorted-platforms", + "Used when PLATFORMS should be sorted alphabetically.", + ), + } + options = () + + def visit_assign(self, node: nodes.Assign) -> None: + """Check for sorted PLATFORMS const.""" + for target in node.targets: + if ( + isinstance(target, nodes.AssignName) + and target.name == "PLATFORMS" + and isinstance(node.value, nodes.List) + ): + platforms = [v.as_string() for v in node.value.elts] + sorted_platforms = sorted(platforms) + if platforms != sorted_platforms: + self.add_message("hass-enforce-sorted-platforms", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSortedPlatformsChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index 3780642c0c5..4e2dfb9a77e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_sorted_platforms", "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 03f637a646f..3554dc66c92 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -80,3 +80,24 @@ def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) super_call_checker.module = "homeassistant.components.pylint_test" return super_call_checker + + +@pytest.fixture(name="hass_enforce_sorted_platforms", scope="session") +def hass_enforce_sorted_platforms_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_sorted_platforms check.""" + return _load_plugin_from_file( + "hass_enforce_sorted_platforms", + "pylint/plugins/hass_enforce_sorted_platforms.py", + ) + + +@pytest.fixture(name="enforce_sorted_platforms_checker") +def enforce_sorted_platforms_checker_fixture( + hass_enforce_sorted_platforms, linter +) -> BaseChecker: + """Fixture to provide a hass_enforce_sorted_platforms checker.""" + enforce_sorted_platforms_checker = ( + hass_enforce_sorted_platforms.HassEnforceSortedPlatformsChecker(linter) + ) + enforce_sorted_platforms_checker.module = "homeassistant.components.pylint_test" + return enforce_sorted_platforms_checker diff --git a/tests/pylint/test_enforce_sorted_platforms.py b/tests/pylint/test_enforce_sorted_platforms.py new file mode 100644 index 00000000000..923291411f0 --- /dev/null +++ b/tests/pylint/test_enforce_sorted_platforms.py @@ -0,0 +1,71 @@ +"""Tests for pylint hass_enforce_sorted_platforms plugin.""" +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import UNDEFINED +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + PLATFORMS = [Platform.SENSOR] + """, + id="one_platform", + ), + pytest.param( + """ + PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + """, + id="multiple_platforms", + ), + ], +) +def test_enforce_sorted_platforms( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(enforce_sorted_platforms_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_enforce_sorted_platforms_bad( + linter: UnittestLinter, + enforce_sorted_platforms_checker: BaseChecker, +) -> None: + """Bad test case.""" + assign_node = astroid.extract_node( + """ + PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] + """, + "homeassistant.components.pylint_test", + ) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-sorted-platforms", + line=2, + node=assign_node, + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=2, + end_col_offset=70, + ), + ): + enforce_sorted_platforms_checker.visit_assign(assign_node) From 5afe155cd9963f9d8775b74f07e73a51031207e5 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Tue, 16 Jan 2024 01:07:51 -0800 Subject: [PATCH 0654/1544] Fix MatrixBot not resolving room aliases per-command (#106347) --- homeassistant/components/matrix/__init__.py | 7 +- tests/components/matrix/conftest.py | 72 ++++++-- tests/components/matrix/test_commands.py | 180 ++++++++++++++++++++ tests/components/matrix/test_matrix_bot.py | 66 +------ 4 files changed, 246 insertions(+), 79 deletions(-) create mode 100644 tests/components/matrix/test_commands.py diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 44a65a2de59..e91ee4d270c 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -223,9 +223,12 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) + if rooms := command.get(CONF_ROOMS): + command[CONF_ROOMS] = [self._listening_rooms[room] for room in rooms] + else: + command[CONF_ROOMS] = list(self._listening_rooms.values()) - # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. + # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_EXPRESSION are set. if (word_command := command.get(CONF_WORD)) is not None: for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index 1198d7e6012..3e7d4833d6f 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -31,6 +31,8 @@ from homeassistant.components.matrix import ( CONF_WORD, EVENT_MATRIX_COMMAND, MatrixBot, + RoomAlias, + RoomAnyID, RoomID, ) from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN @@ -51,13 +53,15 @@ from tests.common import async_capture_events TEST_NOTIFIER_NAME = "matrix_notify" TEST_HOMESERVER = "example.com" -TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" -TEST_ROOM_A_ID = "!RoomA-ID:example.com" -TEST_ROOM_B_ID = "!RoomB-ID:example.com" -TEST_ROOM_B_ALIAS = "#RoomB-Alias:example.com" -TEST_JOINABLE_ROOMS = { +TEST_DEFAULT_ROOM = RoomID("!DefaultNotificationRoom:example.com") +TEST_ROOM_A_ID = RoomID("!RoomA-ID:example.com") +TEST_ROOM_B_ID = RoomID("!RoomB-ID:example.com") +TEST_ROOM_B_ALIAS = RoomAlias("#RoomB-Alias:example.com") +TEST_ROOM_C_ID = RoomID("!RoomC-ID:example.com") +TEST_JOINABLE_ROOMS: dict[RoomAnyID, RoomID] = { TEST_ROOM_A_ID: TEST_ROOM_A_ID, TEST_ROOM_B_ALIAS: TEST_ROOM_B_ID, + TEST_ROOM_C_ID: TEST_ROOM_C_ID, } TEST_BAD_ROOM = "!UninvitedRoom:example.com" TEST_MXID = "@user:example.com" @@ -74,7 +78,7 @@ class _MockAsyncClient(AsyncClient): async def close(self): return None - async def room_resolve_alias(self, room_alias: str): + async def room_resolve_alias(self, room_alias: RoomAnyID): if room_id := TEST_JOINABLE_ROOMS.get(room_alias): return RoomResolveAliasResponse( room_alias=room_alias, room_id=room_id, servers=[TEST_HOMESERVER] @@ -150,6 +154,16 @@ MOCK_CONFIG_DATA = { CONF_EXPRESSION: "My name is (?P.*)", CONF_NAME: "ExpressionTriggerEventName", }, + { + CONF_WORD: "WordTriggerSubset", + CONF_NAME: "WordTriggerSubsetEventName", + CONF_ROOMS: [TEST_ROOM_B_ALIAS, TEST_ROOM_C_ID], + }, + { + CONF_EXPRESSION: "Your name is (?P.*)", + CONF_NAME: "ExpressionTriggerSubsetEventName", + CONF_ROOMS: [TEST_ROOM_B_ALIAS, TEST_ROOM_C_ID], + }, ], }, NOTIFY_DOMAIN: { @@ -164,15 +178,32 @@ MOCK_WORD_COMMANDS = { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], + "rooms": list(TEST_JOINABLE_ROOMS.values()), } }, TEST_ROOM_B_ID: { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], - } + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + "WordTriggerSubset": { + "word": "WordTriggerSubset", + "name": "WordTriggerSubsetEventName", + "rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID], + }, + }, + TEST_ROOM_C_ID: { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + "WordTriggerSubset": { + "word": "WordTriggerSubset", + "name": "WordTriggerSubsetEventName", + "rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID], + }, }, } @@ -181,15 +212,32 @@ MOCK_EXPRESSION_COMMANDS = { { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], + "rooms": list(TEST_JOINABLE_ROOMS.values()), } ], TEST_ROOM_B_ID: [ { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], - } + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + { + "expression": re.compile("Your name is (?P.*)"), + "name": "ExpressionTriggerSubsetEventName", + "rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID], + }, + ], + TEST_ROOM_C_ID: [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": list(TEST_JOINABLE_ROOMS.values()), + }, + { + "expression": re.compile("Your name is (?P.*)"), + "name": "ExpressionTriggerSubsetEventName", + "rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID], + }, ], } diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py new file mode 100644 index 00000000000..cbf85ccc597 --- /dev/null +++ b/tests/components/matrix/test_commands.py @@ -0,0 +1,180 @@ +"""Test MatrixBot's ability to parse and respond to commands in matrix rooms.""" +from functools import partial +from itertools import chain +from typing import Any + +from nio import MatrixRoom, RoomMessageText +from pydantic.dataclasses import dataclass +import pytest + +from homeassistant.components.matrix import MatrixBot, RoomID +from homeassistant.core import Event, HomeAssistant + +from tests.components.matrix.conftest import ( + MOCK_EXPRESSION_COMMANDS, + MOCK_WORD_COMMANDS, + TEST_MXID, + TEST_ROOM_A_ID, + TEST_ROOM_B_ID, + TEST_ROOM_C_ID, +) + +ALL_ROOMS = (TEST_ROOM_A_ID, TEST_ROOM_B_ID, TEST_ROOM_C_ID) +SUBSET_ROOMS = (TEST_ROOM_B_ID, TEST_ROOM_C_ID) + + +@dataclass +class CommandTestParameters: + """Dataclass of parameters representing the command config parameters and expected result state. + + Switches behavior based on `room_id` and `expected_event_room_data`. + """ + + room_id: RoomID + room_message: RoomMessageText + expected_event_data_extra: dict[str, Any] | None + + @property + def expected_event_data(self) -> dict[str, Any] | None: + """Fully-constructed expected event data. + + Commands that are named with 'Subset' are expected not to be read from Room A. + """ + + if ( + self.expected_event_data_extra is None + or "Subset" in self.expected_event_data_extra["command"] + and self.room_id not in SUBSET_ROOMS + ): + return None + return { + "sender": "@SomeUser:example.com", + "room": self.room_id, + } | self.expected_event_data_extra + + +room_message_base = partial( + RoomMessageText, + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, +) +word_command_global = partial( + CommandTestParameters, + room_message=room_message_base(body="!WordTrigger arg1 arg2"), + expected_event_data_extra={ + "command": "WordTriggerEventName", + "args": ["arg1", "arg2"], + }, +) +expr_command_global = partial( + CommandTestParameters, + room_message=room_message_base(body="My name is FakeName"), + expected_event_data_extra={ + "command": "ExpressionTriggerEventName", + "args": {"name": "FakeName"}, + }, +) +word_command_subset = partial( + CommandTestParameters, + room_message=room_message_base(body="!WordTriggerSubset arg1 arg2"), + expected_event_data_extra={ + "command": "WordTriggerSubsetEventName", + "args": ["arg1", "arg2"], + }, +) +expr_command_subset = partial( + CommandTestParameters, + room_message=room_message_base(body="Your name is FakeName"), + expected_event_data_extra={ + "command": "ExpressionTriggerSubsetEventName", + "args": {"name": "FakeName"}, + }, +) +# Messages without commands should trigger nothing +fake_command_global = partial( + CommandTestParameters, + room_message=room_message_base(body="This is not a real command!"), + expected_event_data_extra=None, +) +# Valid commands sent by the bot user should trigger nothing +self_command_global = partial( + CommandTestParameters, + room_message=room_message_base( + body="!WordTrigger arg1 arg2", + source={ + "event_id": "fake_event_id", + "sender": TEST_MXID, + "origin_server_ts": 123456789, + }, + ), + expected_event_data_extra=None, +) + + +@pytest.mark.parametrize( + "command_params", + chain( + (word_command_global(room_id) for room_id in ALL_ROOMS), + (expr_command_global(room_id) for room_id in ALL_ROOMS), + (word_command_subset(room_id) for room_id in SUBSET_ROOMS), + (expr_command_subset(room_id) for room_id in SUBSET_ROOMS), + ), +) +async def test_commands( + hass: HomeAssistant, + matrix_bot: MatrixBot, + command_events: list[Event], + command_params: CommandTestParameters, +): + """Test that the configured commands are used correctly.""" + room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id) + + await hass.async_start() + assert len(command_events) == 0 + await matrix_bot._handle_room_message(room, command_params.room_message) + await hass.async_block_till_done() + + # MatrixBot should emit exactly one Event with matching data from this Command + assert len(command_events) == 1 + event = command_events[0] + assert event.data == command_params.expected_event_data + + +@pytest.mark.parametrize( + "command_params", + chain( + (word_command_subset(TEST_ROOM_A_ID),), + (expr_command_subset(TEST_ROOM_A_ID),), + (fake_command_global(room_id) for room_id in ALL_ROOMS), + (self_command_global(room_id) for room_id in ALL_ROOMS), + ), +) +async def test_non_commands( + hass: HomeAssistant, + matrix_bot: MatrixBot, + command_events: list[Event], + command_params: CommandTestParameters, +): + """Test that normal/non-qualifying messages don't wrongly trigger commands.""" + room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id) + + await hass.async_start() + assert len(command_events) == 0 + await matrix_bot._handle_room_message(room, command_params.room_message) + await hass.async_block_till_done() + + # MatrixBot should not treat this message as a Command + assert len(command_events) == 0 + + +async def test_commands_parsing(hass: HomeAssistant, matrix_bot: MatrixBot): + """Test that the configured commands were parsed correctly.""" + + await hass.async_start() + assert matrix_bot._word_commands == MOCK_WORD_COMMANDS + assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py index 0048f6665e8..bfd6d5824cb 100644 --- a/tests/components/matrix/test_matrix_bot.py +++ b/tests/components/matrix/test_matrix_bot.py @@ -1,5 +1,4 @@ """Configure and test MatrixBot.""" -from nio import MatrixRoom, RoomMessageText from homeassistant.components.matrix import ( DOMAIN as MATRIX_DOMAIN, @@ -9,12 +8,7 @@ from homeassistant.components.matrix import ( from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant -from .conftest import ( - MOCK_EXPRESSION_COMMANDS, - MOCK_WORD_COMMANDS, - TEST_NOTIFIER_NAME, - TEST_ROOM_A_ID, -) +from .conftest import TEST_NOTIFIER_NAME async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): @@ -29,61 +23,3 @@ async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): # Verify that the matrix notifier is registered assert (notify_service := services.get(NOTIFY_DOMAIN)) assert TEST_NOTIFIER_NAME in notify_service - - -async def test_commands(hass, matrix_bot: MatrixBot, command_events): - """Test that the configured commands were parsed correctly.""" - - await hass.async_start() - assert len(command_events) == 0 - - assert matrix_bot._word_commands == MOCK_WORD_COMMANDS - assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS - - room_id = TEST_ROOM_A_ID - room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) - - # Test single-word command. - word_command_message = RoomMessageText( - body="!WordTrigger arg1 arg2", - formatted_body=None, - format=None, - source={ - "event_id": "fake_event_id", - "sender": "@SomeUser:example.com", - "origin_server_ts": 123456789, - }, - ) - await matrix_bot._handle_room_message(room, word_command_message) - await hass.async_block_till_done() - assert len(command_events) == 1 - event = command_events.pop() - assert event.data == { - "command": "WordTriggerEventName", - "sender": "@SomeUser:example.com", - "room": room_id, - "args": ["arg1", "arg2"], - } - - # Test expression command. - room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) - expression_command_message = RoomMessageText( - body="My name is FakeName", - formatted_body=None, - format=None, - source={ - "event_id": "fake_event_id", - "sender": "@SomeUser:example.com", - "origin_server_ts": 123456789, - }, - ) - await matrix_bot._handle_room_message(room, expression_command_message) - await hass.async_block_till_done() - assert len(command_events) == 1 - event = command_events.pop() - assert event.data == { - "command": "ExpressionTriggerEventName", - "sender": "@SomeUser:example.com", - "room": room_id, - "args": {"name": "FakeName"}, - } From 6cab4486f7fe38fd4d851b30ffc2ce7bf3f5cd1f Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Tue, 16 Jan 2024 09:23:04 +0000 Subject: [PATCH 0655/1544] Fix loading empty yaml files with include_dir_named (#107853) --- homeassistant/util/yaml/loader.py | 5 +---- tests/util/yaml/test_init.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 97dbb7d8789..aac3e1274ee 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -359,10 +359,7 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - continue - mapping[filename] = loaded_yaml + mapping[filename] = load_yaml(fname, loader.secrets) return _add_reference(mapping, loader, node) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 1e31d8c6955..30637fe2785 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -193,7 +193,7 @@ def test_include_dir_list_recursive( ), ( {"/test/first.yaml": "1", "/test/second.yaml": None}, - {"first": 1}, + {"first": 1, "second": None}, ), ], ) From 7942e9e3fe8b2a2273a2436413cae3e671bfbad4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 16 Jan 2024 11:10:03 +0100 Subject: [PATCH 0656/1544] bump pyfritzhome to 0.6.10 (#108128) --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index fdf38d88439..5d41f8c12dc 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["pyfritzhome"], "quality_scale": "gold", - "requirements": ["pyfritzhome==0.6.9"], + "requirements": ["pyfritzhome==0.6.10"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 53fad4b5d7b..cbceff16686 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1792,7 +1792,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.9 +pyfritzhome==0.6.10 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cf64e28fe5..94ec221a1fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.9 +pyfritzhome==0.6.10 # homeassistant.components.ifttt pyfttt==0.3 From ef49e8a82f272f3ab6d5cfe344a2711f29413d89 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 12:13:41 +0100 Subject: [PATCH 0657/1544] Bump holidays to 0.41 (#108132) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index e20984e3029..4d26e93e591 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.40", "babel==2.13.1"] + "requirements": ["holidays==0.41", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 2eda7c2dfb0..27d440d4832 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.40"] + "requirements": ["holidays==0.41"] } diff --git a/requirements_all.txt b/requirements_all.txt index cbceff16686..a0ff07eb5cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1044,7 +1044,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.40 +holidays==0.41 # homeassistant.components.frontend home-assistant-frontend==20240112.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94ec221a1fc..00d2c8c4f4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -837,7 +837,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.40 +holidays==0.41 # homeassistant.components.frontend home-assistant-frontend==20240112.0 From 09234ca3af4693ba52cf37b8ad714feeb81af7be Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 16 Jan 2024 13:05:58 +0100 Subject: [PATCH 0658/1544] Update python-bsblan version to 0.5.18 (#108145) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 59d52c3ae00..3f58fbe364c 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.16"] + "requirements": ["python-bsblan==0.5.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0ff07eb5cf..db2897ba930 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2162,7 +2162,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.16 +python-bsblan==0.5.18 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00d2c8c4f4a..4f77b49d633 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1656,7 +1656,7 @@ python-MotionMount==0.3.1 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==0.5.16 +python-bsblan==0.5.18 # homeassistant.components.ecobee python-ecobee-api==0.2.17 From 549ff6ddc6cd0a46e6e8d8d2b1d68411e65ec30f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Jan 2024 02:06:21 -1000 Subject: [PATCH 0659/1544] Enable compression on frontend index page (#108148) --- homeassistant/components/frontend/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 14892c35aac..d168dc2a6aa 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -610,7 +610,8 @@ class IndexView(web_urldispatcher.AbstractResource): else: extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls - return web.Response( + + response = web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], @@ -619,6 +620,8 @@ class IndexView(web_urldispatcher.AbstractResource): ), content_type="text/html", ) + response.enable_compression() + return response def __len__(self) -> int: """Return length of resource.""" From ef7ebcffd64c8c9da08a6fc4d252d58cc823643f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 05:10:09 -0700 Subject: [PATCH 0660/1544] Bump `aioridwell` to 2024.01.0 (#108126) --- homeassistant/components/ridwell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json index 72a29182169..c02cc012e0f 100644 --- a/homeassistant/components/ridwell/manifest.json +++ b/homeassistant/components/ridwell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioridwell"], - "requirements": ["aioridwell==2023.07.0"] + "requirements": ["aioridwell==2024.01.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index db2897ba930..365c9c790a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -347,7 +347,7 @@ aioraven==0.5.0 aiorecollect==2023.09.0 # homeassistant.components.ridwell -aioridwell==2023.07.0 +aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed aioruckus==0.34 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f77b49d633..64c7c1fb47c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -320,7 +320,7 @@ aioraven==0.5.0 aiorecollect==2023.09.0 # homeassistant.components.ridwell -aioridwell==2023.07.0 +aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed aioruckus==0.34 From b46d0fb07cd2c2f0463c3e7dc30f164b16b97bff Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 16 Jan 2024 13:13:33 +0100 Subject: [PATCH 0661/1544] Remove YAML import support for plum_lightpad (#108114) --- .../components/plum_lightpad/__init__.py | 54 +------------------ .../components/plum_lightpad/config_flow.py | 6 --- .../plum_lightpad/test_config_flow.py | 33 +----------- tests/components/plum_lightpad/test_init.py | 25 --------- 4 files changed, 3 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index 241c14f29b9..78c7bf7ff6a 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -3,75 +3,25 @@ import logging from aiohttp import ContentTypeError from requests.exceptions import ConnectTimeout, HTTPError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .utils import load_plum _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [Platform.LIGHT] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Plum Lightpad Platform initialization.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - _LOGGER.debug("Found Plum Lightpad configuration in config, importing") - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Plum Lightpad", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Plum Lightpad from a config entry.""" _LOGGER.debug("Setting up config entry with ID = %s", entry.unique_id) diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py index b2afb55fc5d..9f81a57d42e 100644 --- a/homeassistant/components/plum_lightpad/config_flow.py +++ b/homeassistant/components/plum_lightpad/config_flow.py @@ -58,9 +58,3 @@ class PlumLightpadConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password} ) - - async def async_step_import( - self, import_config: dict[str, Any] | None - ) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) diff --git a/tests/components/plum_lightpad/test_config_flow.py b/tests/components/plum_lightpad/test_config_flow.py index e919932be28..40852094f5b 100644 --- a/tests/components/plum_lightpad/test_config_flow.py +++ b/tests/components/plum_lightpad/test_config_flow.py @@ -22,8 +22,6 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" ), patch( - "homeassistant.components.plum_lightpad.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.plum_lightpad.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -39,7 +37,6 @@ async def test_form(hass: HomeAssistant) -> None: "username": "test-plum-username", "password": "test-plum-password", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -76,7 +73,7 @@ async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: with patch( "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ), patch("homeassistant.components.plum_lightpad.async_setup") as mock_setup, patch( + ), patch( "homeassistant.components.plum_lightpad.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -86,32 +83,4 @@ async def test_form_one_entry_per_email_allowed(hass: HomeAssistant) -> None: assert result2["type"] == "abort" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_import(hass: HomeAssistant) -> None: - """Test configuring the flow using configuration.yaml.""" - - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ), patch( - "homeassistant.components.plum_lightpad.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.plum_lightpad.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-plum-username", "password": "test-plum-password"}, - ) - assert result["type"] == "create_entry" - assert result["title"] == "test-plum-username" - assert result["data"] == { - "username": "test-plum-username", - "password": "test-plum-password", - } - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/plum_lightpad/test_init.py b/tests/components/plum_lightpad/test_init.py index 2a3249b7514..66402abf13c 100644 --- a/tests/components/plum_lightpad/test_init.py +++ b/tests/components/plum_lightpad/test_init.py @@ -19,31 +19,6 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant) -> None: assert DOMAIN not in hass.data -async def test_async_setup_imports_from_config(hass: HomeAssistant) -> None: - """Test that specifying config will setup an entry.""" - with patch( - "homeassistant.components.plum_lightpad.utils.Plum.loadCloudData" - ) as mock_loadCloudData, patch( - "homeassistant.components.plum_lightpad.async_setup_entry", - return_value=True, - ) as mock_async_setup_entry: - result = await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - "username": "test-plum-username", - "password": "test-plum-password", - } - }, - ) - await hass.async_block_till_done() - - assert result is True - assert len(mock_loadCloudData.mock_calls) == 1 - assert len(mock_async_setup_entry.mock_calls) == 1 - - async def test_async_setup_entry_sets_up_light(hass: HomeAssistant) -> None: """Test that configuring entry sets up light domain.""" config_entry = MockConfigEntry( From fa2f9eac1afe1a83e50b883bcda76ab590895fe6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 13:27:49 +0100 Subject: [PATCH 0662/1544] Remove config import in meteo_france (#107970) Co-authored-by: jbouwh --- .../components/meteo_france/__init__.py | 43 +------------------ .../components/meteo_france/config_flow.py | 4 -- .../meteo_france/test_config_flow.py | 29 +------------ 3 files changed, 3 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index ff29f9d2f95..3f1cd2a5e34 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -7,13 +7,11 @@ from meteofrance_api.helpers import is_valid_warning_department from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -34,43 +32,6 @@ SCAN_INTERVAL = timedelta(minutes=15) CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CITY_SCHEMA]))}, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Meteo-France from legacy config file.""" - if not (conf := config.get(DOMAIN)): - return True - - for city_conf in conf: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Météo-France", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Meteo-France account from a config entry.""" diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index dd62ffc24be..a3001ee25c0 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -81,10 +81,6 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - return await self.async_step_user(user_input) - async def async_step_cities( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index 80155d3311a..0405f8efa18 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -80,9 +80,6 @@ def mock_controller_client_single(): def mock_setup(): """Prevent setup.""" with patch( - "homeassistant.components.meteo_france.async_setup", - return_value=True, - ), patch( "homeassistant.components.meteo_france.async_setup_entry", return_value=True, ): @@ -155,21 +152,6 @@ async def test_user_list(hass: HomeAssistant, client_multiple) -> None: assert result["data"][CONF_LONGITUDE] == str(CITY_3_LON) -async def test_import(hass: HomeAssistant, client_multiple) -> None: - """Test import step.""" - # import with all - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_2_NAME}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}" - assert result["title"] == f"{CITY_2}" - assert result["data"][CONF_LATITUDE] == str(CITY_2_LAT) - assert result["data"][CONF_LONGITUDE] == str(CITY_2_LON) - - async def test_search_failed(hass: HomeAssistant, client_empty) -> None: """Test error displayed if no result in search.""" result = await hass.config_entries.flow.async_init( @@ -190,15 +172,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant, client_single) -> Non unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ).add_to_hass(hass) - # Should fail, same CITY same postal code (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_1_POSTAL}, - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - # Should fail, same CITY same postal code (flow) result = await hass.config_entries.flow.async_init( DOMAIN, From 3e72c346b7e777ae82b2c85d2f40c8b11d88a835 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 16 Jan 2024 13:29:26 +0100 Subject: [PATCH 0663/1544] Remove MELCloud YAML import support (#108113) --- homeassistant/components/melcloud/__init__.py | 40 +---- .../components/melcloud/config_flow.py | 61 +------- tests/components/melcloud/test_config_flow.py | 143 +----------------- 3 files changed, 6 insertions(+), 238 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index d1ed5cafcbf..2187cb5b8b8 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -9,16 +9,13 @@ from typing import Any from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import DOMAIN @@ -29,39 +26,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] -CONF_LANGUAGE = "language" -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_TOKEN): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Establish connection with MELCloud.""" - if DOMAIN not in config: - return True - - username = config[DOMAIN][CONF_USERNAME] - token = config[DOMAIN][CONF_TOKEN] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: username, CONF_TOKEN: token}, - ) - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 9293c9bb3d5..9db44d5276c 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -13,49 +13,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_create_import_issue( - hass: HomeAssistant, source: str, issue: str, success: bool = False -) -> None: - """Create issue from import.""" - if source != config_entries.SOURCE_IMPORT: - return - if not success: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{issue}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key=f"deprecated_yaml_import_issue_{issue}", - ) - return - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "MELCloud", - }, - ) - - class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -66,11 +31,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _create_entry(self, username: str, token: str) -> FlowResult: """Register new entry.""" await self.async_set_unique_id(username) - try: - self._abort_if_unique_id_configured({CONF_TOKEN: token}) - except AbortFlow: - await async_create_import_issue(self.hass, self.context["source"], "", True) - raise + self._abort_if_unique_id_configured({CONF_TOKEN: token}) return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_TOKEN: token} ) @@ -97,18 +58,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) except ClientResponseError as err: if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - await async_create_import_issue( - self.hass, self.context["source"], "invalid_auth" - ) return self.async_abort(reason="invalid_auth") - await async_create_import_issue( - self.hass, self.context["source"], "cannot_connect" - ) return self.async_abort(reason="cannot_connect") except (asyncio.TimeoutError, ClientError): - await async_create_import_issue( - self.hass, self.context["source"], "cannot_connect" - ) return self.async_abort(reason="cannot_connect") return await self._create_entry(username, acquired_token) @@ -127,15 +79,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] return await self._create_client(username, password=user_input[CONF_PASSWORD]) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - result = await self._create_client( - user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] - ) - if result["type"] == FlowResultType.CREATE_ENTRY: - await async_create_import_issue(self.hass, self.context["source"], "", True) - return result - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with MELCloud.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index f3d49f3c0bc..5e8614a555c 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -7,12 +7,11 @@ from aiohttp import ClientError, ClientResponseError import pymelcloud import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.melcloud.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -57,8 +56,6 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None: assert result["errors"] is None with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.melcloud.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -73,7 +70,6 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None: "username": "test-email@test-domain.com", "token": "test-token", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -122,138 +118,6 @@ async def test_form_response_errors( assert result["reason"] == message -@pytest.mark.parametrize( - ("error", "message", "issue"), - [ - ( - HTTPStatus.UNAUTHORIZED, - "invalid_auth", - "deprecated_yaml_import_issue_invalid_auth", - ), - ( - HTTPStatus.FORBIDDEN, - "invalid_auth", - "deprecated_yaml_import_issue_invalid_auth", - ), - ( - HTTPStatus.INTERNAL_SERVER_ERROR, - "cannot_connect", - "deprecated_yaml_import_issue_cannot_connect", - ), - ], -) -async def test_step_import_fails( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, - error: Exception, - message: str, - issue: str, -) -> None: - """Test raising issues on import.""" - mock_get_devices.side_effect = ClientResponseError( - mock_request_info(), (), status=error - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-email@test-domain.com", "token": "test-token"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == message - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue(DOMAIN, issue) - - -async def test_step_import_fails_ClientError( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, -) -> None: - """Test raising issues on import for ClientError.""" - mock_get_devices.side_effect = ClientError() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-email@test-domain.com", "token": "test-token"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - issue_registry = ir.async_get(hass) - assert issue_registry.async_get_issue( - DOMAIN, "deprecated_yaml_import_issue_cannot_connect" - ) - - -async def test_step_import_already_exist( - hass: HomeAssistant, - mock_login, - mock_get_devices, - mock_request_info, -) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {"username": "test-email@test-domain.com", "token": "test-token"} - config_entry = MockConfigEntry( - domain=DOMAIN, - data=conf, - title=conf["username"], - unique_id=conf["username"], - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_configured" - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_melcloud" - ) - assert issue.translation_key == "deprecated_yaml" - - -async def test_import_with_token( - hass: HomeAssistant, mock_login, mock_get_devices -) -> None: - """Test successful import.""" - with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.melcloud.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"username": "test-email@test-domain.com", "token": "test-token"}, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "test-email@test-domain.com" - assert result["data"] == { - "username": "test-email@test-domain.com", - "token": "test-token", - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_yaml_melcloud" - ) - assert issue.translation_key == "deprecated_yaml" - - async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) -> None: """Re-configuration with existing username should refresh token.""" mock_entry = MockConfigEntry( @@ -264,8 +128,6 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) mock_entry.add_to_hass(hass) with patch( - "homeassistant.components.melcloud.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.melcloud.async_setup_entry", return_value=True ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( @@ -280,7 +142,6 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) assert result["type"] == "abort" assert result["reason"] == "already_configured" await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 entries = hass.config_entries.async_entries(DOMAIN) From 7fe6fc987bc005f44fce90d99fc69959f9fe438d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 16 Jan 2024 13:31:42 +0100 Subject: [PATCH 0664/1544] Add config flow for Ecovacs (#108111) Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .coveragerc | 4 +- CODEOWNERS | 3 +- homeassistant/components/ecovacs/__init__.py | 62 +++---- .../components/ecovacs/config_flow.py | 136 +++++++++++++++ homeassistant/components/ecovacs/const.py | 5 + .../components/ecovacs/manifest.json | 3 +- homeassistant/components/ecovacs/strings.json | 35 ++++ homeassistant/components/ecovacs/util.py | 11 ++ homeassistant/components/ecovacs/vacuum.py | 11 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/ecovacs/__init__.py | 1 + tests/components/ecovacs/conftest.py | 14 ++ tests/components/ecovacs/test_config_flow.py | 160 ++++++++++++++++++ 15 files changed, 412 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/ecovacs/config_flow.py create mode 100644 homeassistant/components/ecovacs/const.py create mode 100644 homeassistant/components/ecovacs/strings.json create mode 100644 homeassistant/components/ecovacs/util.py create mode 100644 tests/components/ecovacs/__init__.py create mode 100644 tests/components/ecovacs/conftest.py create mode 100644 tests/components/ecovacs/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 9c02fb7f677..38b74601214 100644 --- a/.coveragerc +++ b/.coveragerc @@ -272,7 +272,9 @@ omit = homeassistant/components/econet/climate.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py - homeassistant/components/ecovacs/* + homeassistant/components/ecovacs/__init__.py + homeassistant/components/ecovacs/util.py + homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py homeassistant/components/ecowitt/binary_sensor.py homeassistant/components/ecowitt/entity.py diff --git a/CODEOWNERS b/CODEOWNERS index d1aa09eb93c..ffad270b09f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -321,7 +321,8 @@ build.json @home-assistant/supervisor /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23 -/homeassistant/components/ecovacs/ @OverloadUT @mib1185 +/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus +/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 9cb8a8c38d8..f8d6fc912e9 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -1,28 +1,26 @@ """Support for Ecovacs Deebot vacuums.""" import logging -import random -import string from sucks import EcoVacsAPI, VacBot import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from .const import CONF_CONTINENT, DOMAIN +from .util import get_client_device_id + _LOGGER = logging.getLogger(__name__) -DOMAIN = "ecovacs" - -CONF_COUNTRY = "country" -CONF_CONTINENT = "continent" CONFIG_SCHEMA = vol.Schema( { @@ -38,32 +36,39 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ECOVACS_DEVICES = "ecovacs_devices" - -# Generate a random device ID on each bootup -ECOVACS_API_DEVICEID = "".join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(8) -) +PLATFORMS = [ + Platform.VACUUM, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ecovacs component.""" - _LOGGER.debug("Creating new Ecovacs component") + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" def get_devices() -> list[VacBot]: ecovacs_api = EcoVacsAPI( - ECOVACS_API_DEVICEID, - config[DOMAIN].get(CONF_USERNAME), - EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), - config[DOMAIN].get(CONF_COUNTRY), - config[DOMAIN].get(CONF_CONTINENT), + get_client_device_id(), + entry.data[CONF_USERNAME], + EcoVacsAPI.md5(entry.data[CONF_PASSWORD]), + entry.data[CONF_COUNTRY], + entry.data[CONF_CONTINENT], ) ecovacs_devices = ecovacs_api.devices() - _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) + _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) devices: list[VacBot] = [] for device in ecovacs_devices: - _LOGGER.info( + _LOGGER.debug( "Discovered Ecovacs device on account: %s with nickname %s", device.get("did"), device.get("nick"), @@ -74,18 +79,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ecovacs_api.resource, ecovacs_api.user_access_token, device, - config[DOMAIN].get(CONF_CONTINENT).lower(), + entry.data[CONF_CONTINENT], monitor=True, ) devices.append(vacbot) return devices - hass.data[ECOVACS_DEVICES] = await hass.async_add_executor_job(get_devices) + hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = await hass.async_add_executor_job(get_devices) async def async_stop(event: object) -> None: """Shut down open connections to Ecovacs XMPP server.""" - devices: list[VacBot] = hass.data[ECOVACS_DEVICES] + devices: list[VacBot] = hass.data[DOMAIN][entry.entry_id] for device in devices: _LOGGER.info( "Shutting down connection to Ecovacs device %s", @@ -96,10 +103,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Listen for HA stop to disconnect. hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - if hass.data[ECOVACS_DEVICES]: - _LOGGER.debug("Starting vacuum components") - hass.async_create_task( - discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config) - ) + if hass.data[DOMAIN][entry.entry_id]: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py new file mode 100644 index 00000000000..05232dddb53 --- /dev/null +++ b/homeassistant/components/ecovacs/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for Ecovacs mqtt integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from sucks import EcoVacsAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import selector +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_CONTINENT, DOMAIN +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input.""" + errors: dict[str, str] = {} + try: + EcoVacsAPI( + get_client_device_id(), + user_input[CONF_USERNAME], + EcoVacsAPI.md5(user_input[CONF_PASSWORD]), + user_input[CONF_COUNTRY], + user_input[CONF_CONTINENT], + ) + except ValueError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors + + +class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecovacs.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + + errors = await self.hass.async_add_executor_job(validate_input, user_input) + + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT + ) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ) + ), + vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), + vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + } + ), + user_input, + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + + def create_repair(error: str | None = None) -> None: + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=ecovacs" + }, + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ecovacs", + }, + ) + + try: + result = await self.async_step_user(user_input) + except AbortFlow as ex: + if ex.reason == "already_configured": + create_repair() + raise ex + + if errors := result.get("errors"): + error = errors["base"] + create_repair(error) + return self.async_abort(reason=error) + + create_repair() + return result diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py new file mode 100644 index 00000000000..ed33f90f191 --- /dev/null +++ b/homeassistant/components/ecovacs/const.py @@ -0,0 +1,5 @@ +"""Ecovacs constants.""" + +DOMAIN = "ecovacs" + +CONF_CONTINENT = "continent" diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index eb8afcf0878..286a7ce5583 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,7 +1,8 @@ { "domain": "ecovacs", "name": "Ecovacs", - "codeowners": ["@OverloadUT", "@mib1185"], + "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks"], diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json new file mode 100644 index 00000000000..86bdef89b3b --- /dev/null +++ b/homeassistant/components/ecovacs/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "continent": "Continent", + "country": "Country", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "continent": "Your two-letter continent code (na, eu, etc)", + "country": "Your two-letter country code (us, uk, etc)" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py new file mode 100644 index 00000000000..d16214346ab --- /dev/null +++ b/homeassistant/components/ecovacs/util.py @@ -0,0 +1,11 @@ +"""Ecovacs util functions.""" + +import random +import string + + +def get_client_device_id() -> str: + """Get client device id.""" + return "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(8) + ) diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 2ec9a1a3e4a..3b4f86920b6 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -15,12 +15,12 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, VacuumEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ECOVACS_DEVICES +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,15 +28,14 @@ ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Ecovacs vacuums.""" vacuums = [] - devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES] + devices: list[sucks.VacBot] = hass.data[DOMAIN][config_entry.entry_id] for device in devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) vacuums.append(EcovacsVacuum(device)) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 752092c02c7..d14872aa29d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -126,6 +126,7 @@ FLOWS = { "ecobee", "ecoforest", "econet", + "ecovacs", "ecowitt", "edl21", "efergy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 373853681d7..07872e987ae 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1381,7 +1381,7 @@ "ecovacs": { "name": "Ecovacs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push" }, "ecowitt": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64c7c1fb47c..f9c2467629d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1230,6 +1230,9 @@ py-nextbusnext==1.0.2 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.ecovacs +py-sucks==0.9.8 + # homeassistant.components.synology_dsm py-synologydsm-api==2.1.4 diff --git a/tests/components/ecovacs/__init__.py b/tests/components/ecovacs/__init__.py new file mode 100644 index 00000000000..7305ba8c785 --- /dev/null +++ b/tests/components/ecovacs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecovacs integration.""" diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py new file mode 100644 index 00000000000..5c1cf7adae0 --- /dev/null +++ b/tests/components/ecovacs/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Ecovacs tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecovacs.async_setup_entry", return_value=True + ) as async_setup_entry: + yield async_setup_entry diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py new file mode 100644 index 00000000000..9688634bec4 --- /dev/null +++ b/tests/components/ecovacs/test_config_flow.py @@ -0,0 +1,160 @@ +"""Test Ecovacs config flow.""" +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from sucks import EcoVacsAPI + +from homeassistant.components.ecovacs.const import CONF_CONTINENT, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_COUNTRY: "it", + CONF_CONTINENT: "eu", +} + + +async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: + """Test config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_USER_INPUT, + ) + + +async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the user config flow.""" + with patch( + "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", + return_value=Mock(spec_set=EcoVacsAPI), + ) as mock_ecovacs: + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == _USER_INPUT[CONF_USERNAME] + assert result["data"] == _USER_INPUT + mock_setup_entry.assert_called() + mock_ecovacs.assert_called() + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ValueError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_user_flow_error( + hass: HomeAssistant, + side_effect: Exception, + reason: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test handling invalid connection.""" + with patch( + "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", + return_value=Mock(spec_set=EcoVacsAPI), + ) as mock_ecovacs: + mock_ecovacs.side_effect = side_effect + + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": reason} + mock_ecovacs.assert_called() + mock_setup_entry.assert_not_called() + + mock_ecovacs.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_USER_INPUT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == _USER_INPUT[CONF_USERNAME] + assert result["data"] == _USER_INPUT + mock_setup_entry.assert_called() + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry: AsyncMock +) -> None: + """Test importing yaml config.""" + with patch( + "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", + return_value=Mock(spec_set=EcoVacsAPI), + ) as mock_ecovacs: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=_USER_INPUT, + ) + mock_ecovacs.assert_called() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == _USER_INPUT[CONF_USERNAME] + assert result["data"] == _USER_INPUT + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + mock_setup_entry.assert_called() + + +async def test_import_flow_already_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test importing yaml config where entry already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=_USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=_USER_INPUT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (ValueError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_import_flow_error( + hass: HomeAssistant, + side_effect: Exception, + reason: str, + issue_registry: ir.IssueRegistry, +) -> None: + """Test handling invalid connection.""" + with patch( + "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", + return_value=Mock(spec_set=EcoVacsAPI), + ) as mock_ecovacs: + mock_ecovacs.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=_USER_INPUT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues + mock_ecovacs.assert_called() From 523352c97e79d5a3c973541c89d0ec2295671206 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 16 Jan 2024 13:38:47 +0100 Subject: [PATCH 0665/1544] Avoid keeping config dir in path (#107760) --- homeassistant/loader.py | 8 ++++++-- tests/test_loader.py | 11 +++++++++++ tests/testing_config/check_config_not_in_path.py | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tests/testing_config/check_config_not_in_path.py diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0a44ccb05c9..a4f1a862c45 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1177,8 +1177,12 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: Async friendly but not a coroutine. """ - if hass.config.config_dir not in sys.path: - sys.path.insert(0, hass.config.config_dir) + + sys.path.insert(0, hass.config.config_dir) + with suppress(ImportError): + import custom_components # pylint: disable=import-outside-toplevel # noqa: F401 + sys.path.remove(hass.config.config_dir) + sys.path_importer_cache.pop(hass.config.config_dir, None) def _lookup_path(hass: HomeAssistant) -> list[str]: diff --git a/tests/test_loader.py b/tests/test_loader.py index 7959ddb4684..501764bd022 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -873,3 +873,14 @@ async def test_async_suggest_report_issue( ) == report_issue ) + + +async def test_config_folder_not_in_path(hass): + """Test that config folder is not in path.""" + + # Verify that we are unable to import this file from top level + with pytest.raises(ImportError): + import check_config_not_in_path # noqa: F401 + + # Verify that we are able to load the file with absolute path + import tests.testing_config.check_config_not_in_path # noqa: F401 diff --git a/tests/testing_config/check_config_not_in_path.py b/tests/testing_config/check_config_not_in_path.py new file mode 100644 index 00000000000..312adec324e --- /dev/null +++ b/tests/testing_config/check_config_not_in_path.py @@ -0,0 +1 @@ +"""File that should not be possible to import via direct import.""" From 04bc8e09a5892cb804aac166f8dcd697f949095b Mon Sep 17 00:00:00 2001 From: DellanX <31318348+DellanX@users.noreply.github.com> Date: Tue, 16 Jan 2024 06:40:11 -0600 Subject: [PATCH 0666/1544] Default tuya climate temperature unit to system unit (#108050) --- homeassistant/components/tuya/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index b8c66c5cc35..74399d70991 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -156,8 +156,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ): prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT - # Default to Celsius - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + # Default to System Temperature Unit + self._attr_temperature_unit = self.hass.config.units.temperature_unit # Figure out current temperature, use preferred unit or what is available celsius_type = self.find_dpcode( From d4739cfa5cfb44c3cfc8632fc0e5eada4acf2cc3 Mon Sep 17 00:00:00 2001 From: max2697 <143563471+max2697@users.noreply.github.com> Date: Tue, 16 Jan 2024 06:43:19 -0600 Subject: [PATCH 0667/1544] Bump opower to 0.2.0 (#108067) --- 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 89b62912710..e654c044c16 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.1.0"] + "requirements": ["opower==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 365c9c790a8..2f4b411ec80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1443,7 +1443,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.1.0 +opower==0.2.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9c2467629d..8ce86ae77b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1125,7 +1125,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.1.0 +opower==0.2.0 # homeassistant.components.oralb oralb-ble==0.17.6 From 7deebf881732bf817cdcc21969fc26131a709aa5 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 16 Jan 2024 12:45:24 +0000 Subject: [PATCH 0668/1544] Handle renaming of evohome zones (#108089) --- homeassistant/components/evohome/climate.py | 7 +++++-- homeassistant/components/evohome/water_heater.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 97a126e2660..a1c46f3d331 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -182,8 +182,6 @@ class EvoZone(EvoChild, EvoClimateEntity): else: self._attr_unique_id = evo_device.zoneId - self._attr_name = evo_device.name - if evo_broker.client_v1: self._attr_precision = PRECISION_TENTHS else: @@ -219,6 +217,11 @@ class EvoZone(EvoChild, EvoClimateEntity): self._evo_device.set_temperature(temperature, until=until) ) + @property + def name(self) -> str | None: + """Return the name of the evohome entity.""" + return self._evo_device.name # zones can be easily renamed + @property def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 77a7b1c2ced..26a60f9ec08 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -89,6 +89,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): self._evo_id = evo_device.dhwId self._attr_unique_id = evo_device.dhwId + self._attr_name = evo_device.name # is static self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE From 26058bf92289acdee0f5c920729061d5f911c60c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 16 Jan 2024 14:02:34 +0100 Subject: [PATCH 0669/1544] Add serial_number attribute to MQTT device properties (#108105) --- homeassistant/components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/const.py | 3 +++ homeassistant/components/mqtt/mixins.py | 6 ++++++ tests/components/mqtt/test_common.py | 2 ++ tests/components/mqtt/test_device_trigger.py | 5 +++++ tests/components/mqtt/test_init.py | 2 ++ tests/components/mqtt/test_tag.py | 5 +++++ 7 files changed, 24 insertions(+) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 524448e02a8..5fadf6ba590 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -271,6 +271,7 @@ DEVICE_ABBREVIATIONS = { "hw": "hw_version", "sw": "sw_version", "sa": "suggested_area", + "sn": "serial_number", } ORIGIN_ABBREVIATIONS = { diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 304877695c3..fba2f13937e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,4 +1,5 @@ """Constants used by multiple MQTT modules.""" + from homeassistant.const import CONF_PAYLOAD, Platform ATTR_DISCOVERY_HASH = "discovery_hash" @@ -7,6 +8,7 @@ ATTR_DISCOVERY_TOPIC = "discovery_topic" ATTR_PAYLOAD = "payload" ATTR_QOS = "qos" ATTR_RETAIN = "retain" +ATTR_SERIAL_NUMBER = "serial_number" ATTR_TOPIC = "topic" CONF_AVAILABILITY = "availability" @@ -73,6 +75,7 @@ CONF_CONNECTIONS = "connections" CONF_MANUFACTURER = "manufacturer" CONF_HW_VERSION = "hw_version" CONF_SW_VERSION = "sw_version" +CONF_SERIAL_NUMBER = "serial_number" CONF_VIA_DEVICE = "via_device" CONF_DEPRECATED_VIA_HUB = "via_hub" CONF_SUGGESTED_AREA = "suggested_area" diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 1cb12930b5a..4c7837a7a2b 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, + ATTR_SERIAL_NUMBER, ATTR_SUGGESTED_AREA, ATTR_SW_VERSION, ATTR_VIA_DEVICE, @@ -82,6 +83,7 @@ from .const import ( CONF_ORIGIN, CONF_QOS, CONF_SCHEMA, + CONF_SERIAL_NUMBER, CONF_SUGGESTED_AREA, CONF_SW_VERSION, CONF_TOPIC, @@ -220,6 +222,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( vol.Optional(CONF_MODEL): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_HW_VERSION): cv.string, + vol.Optional(CONF_SERIAL_NUMBER): cv.string, vol.Optional(CONF_SW_VERSION): cv.string, vol.Optional(CONF_VIA_DEVICE): cv.string, vol.Optional(CONF_SUGGESTED_AREA): cv.string, @@ -1103,6 +1106,9 @@ def device_info_from_specifications( if CONF_HW_VERSION in specifications: info[ATTR_HW_VERSION] = specifications[CONF_HW_VERSION] + if CONF_SERIAL_NUMBER in specifications: + info[ATTR_SERIAL_NUMBER] = specifications[CONF_SERIAL_NUMBER] + if CONF_SW_VERSION in specifications: info[ATTR_SW_VERSION] = specifications[CONF_SW_VERSION] diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index cb5ff53d7e9..54b0f7f3506 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -43,6 +43,7 @@ DEFAULT_CONFIG_DEVICE_INFO_ID = { "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "suggested_area": "default_area", "configuration_url": "http://example.com", @@ -54,6 +55,7 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "suggested_area": "default_area", "configuration_url": "http://example.com", diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 90360bf7e3f..fffb9e57f84 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -992,6 +992,7 @@ async def test_entity_device_info_with_connection( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -1008,6 +1009,7 @@ async def test_entity_device_info_with_connection( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -1031,6 +1033,7 @@ async def test_entity_device_info_with_identifier( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -1045,6 +1048,7 @@ async def test_entity_device_info_with_identifier( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -1067,6 +1071,7 @@ async def test_entity_device_info_update( "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3d7b349712e..5bd41da057c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -804,6 +804,7 @@ def test_entity_device_info_schema() -> None: "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "configuration_url": "http://example.com", } @@ -819,6 +820,7 @@ def test_entity_device_info_schema() -> None: "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", "via_device": "test-hub", "configuration_url": "http://example.com", diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 0476c880b1a..cee7880cf1c 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -460,6 +460,7 @@ async def test_entity_device_info_with_connection( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -476,6 +477,7 @@ async def test_entity_device_info_with_connection( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -496,6 +498,7 @@ async def test_entity_device_info_with_identifier( "name": "Beer", "model": "Glass", "hw_version": "rev1", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } @@ -510,6 +513,7 @@ async def test_entity_device_info_with_identifier( assert device.name == "Beer" assert device.model == "Glass" assert device.hw_version == "rev1" + assert device.serial_number == "1234deadbeef" assert device.sw_version == "0.1-beta" @@ -529,6 +533,7 @@ async def test_entity_device_info_update( "manufacturer": "Whatever", "name": "Beer", "model": "Glass", + "serial_number": "1234deadbeef", "sw_version": "0.1-beta", }, } From 3d595fff13498b1eaf716bbd9fed8ee5e493df1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Jan 2024 03:05:01 -1000 Subject: [PATCH 0670/1544] Avoid duplicate timestamp conversions for websocket api and recorder (#108144) * Avoid duplicate timestamp conversions for websocket api and recorder We convert the time from datetime to timestamps one per open websocket connection and the recorder for every state update. Only do the conversion once since its ~30% of the cost of building the state diff * more * two more * two more in live history --- .../components/history/websocket_api.py | 8 ++---- homeassistant/components/logbook/models.py | 5 ++-- .../components/recorder/db_schema.py | 8 +++--- .../components/websocket_api/messages.py | 4 +-- homeassistant/core.py | 23 ++++++++++++++--- tests/test_core.py | 25 +++++++++++++++++++ 6 files changed, 54 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 4be63f29c02..5bc14cd4c02 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -302,13 +302,9 @@ def _history_compressed_state(state: State, no_attributes: bool) -> dict[str, An comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state.state} if not no_attributes or state.domain in history.NEED_ATTRIBUTE_DOMAINS: comp_state[COMPRESSED_STATE_ATTRIBUTES] = state.attributes - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( - state.last_updated - ) + comp_state[COMPRESSED_STATE_LAST_UPDATED] = state.last_updated_timestamp if state.last_changed != state.last_updated: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = dt_util.utc_to_timestamp( - state.last_changed - ) + comp_state[COMPRESSED_STATE_LAST_CHANGED] = state.last_changed_timestamp return comp_state diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 04a2458237f..84ae84a3b70 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -16,7 +16,6 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback -import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes @@ -131,7 +130,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_id_bin=ulid_to_bytes(context.id), context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), - time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), + time_fired_ts=event.time_fired_timestamp, row_id=hash(event), ) # States are prefiltered so we never get states @@ -147,7 +146,7 @@ def async_event_to_row(event: Event) -> EventAsRow: context_id_bin=ulid_to_bytes(context.id), context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), - time_fired_ts=dt_util.utc_to_timestamp(new_state.last_updated), + time_fired_ts=new_state.last_updated_timestamp, row_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), ) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index b864e104ae6..dff26214d67 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -296,7 +296,7 @@ class Events(Base): event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, - time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), + time_fired_ts=event.time_fired_timestamp, context_id=None, context_id_bin=ulid_to_bytes_or_none(event.context.id), context_user_id=None, @@ -495,16 +495,16 @@ class States(Base): # None state means the state was removed from the state machine if state is None: dbstate.state = "" - dbstate.last_updated_ts = dt_util.utc_to_timestamp(event.time_fired) + dbstate.last_updated_ts = event.time_fired_timestamp dbstate.last_changed_ts = None return dbstate dbstate.state = state.state - dbstate.last_updated_ts = dt_util.utc_to_timestamp(state.last_updated) + dbstate.last_updated_ts = state.last_updated_timestamp if state.last_updated == state.last_changed: dbstate.last_changed_ts = None else: - dbstate.last_changed_ts = dt_util.utc_to_timestamp(state.last_changed) + dbstate.last_changed_ts = state.last_changed_timestamp return dbstate diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 1d3181fcf3a..55144217fdc 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -183,9 +183,9 @@ def _state_diff( if old_state.state != new_state.state: additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: - additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp() + additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp elif old_state.last_updated != new_state.last_updated: - additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp() + additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp if old_state_context.parent_id != new_state_context.parent_id: additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} if old_state_context.user_id != new_state_context.user_id: diff --git a/homeassistant/core.py b/homeassistant/core.py index ebd40330d13..e65273538a8 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1077,6 +1077,11 @@ class Event: if not context.origin_event: context.origin_event = self + @cached_property + def time_fired_timestamp(self) -> float: + """Return time fired as a timestamp.""" + return self.time_fired.timestamp() + @cached_property def _as_dict(self) -> dict[str, Any]: """Create a dict representation of this Event. @@ -1445,6 +1450,16 @@ class State: "_", " " ) + @cached_property + def last_updated_timestamp(self) -> float: + """Timestamp of last update.""" + return self.last_updated.timestamp() + + @cached_property + def last_changed_timestamp(self) -> float: + """Timestamp of last change.""" + return self.last_changed.timestamp() + @cached_property def _as_dict(self) -> dict[str, Any]: """Return a dict representation of the State. @@ -1526,12 +1541,12 @@ class State: COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, COMPRESSED_STATE_CONTEXT: context, - COMPRESSED_STATE_LAST_CHANGED: dt_util.utc_to_timestamp(self.last_changed), + COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp, } if self.last_changed != self.last_updated: - compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( - self.last_updated - ) + compressed_state[ + COMPRESSED_STATE_LAST_UPDATED + ] = self.last_updated_timestamp return compressed_state @cached_property diff --git a/tests/test_core.py b/tests/test_core.py index 918f098eab7..c2a5a73e6ee 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -625,6 +625,14 @@ def test_event_eq() -> None: assert event1.as_dict() == event2.as_dict() +def test_event_time_fired_timestamp() -> None: + """Test time_fired_timestamp.""" + now = dt_util.utcnow() + event = ha.Event("some_type", {"some": "attr"}, time_fired=now) + assert event.time_fired_timestamp == now.timestamp() + assert event.time_fired_timestamp == now.timestamp() + + def test_event_json_fragment() -> None: """Test event JSON fragments.""" now = dt_util.utcnow() @@ -2453,6 +2461,23 @@ async def test_state_change_events_context_id_match_state_time( ) +def test_state_timestamps() -> None: + """Test timestamp functions for State.""" + now = dt_util.utcnow() + state = ha.State( + "light.bedroom", + "on", + {"brightness": 100}, + last_changed=now, + last_updated=now, + context=ha.Context(id="1234"), + ) + assert state.last_changed_timestamp == now.timestamp() + assert state.last_changed_timestamp == now.timestamp() + assert state.last_updated_timestamp == now.timestamp() + assert state.last_updated_timestamp == now.timestamp() + + async def test_state_firing_event_matches_context_id_ulid_time( hass: HomeAssistant, ) -> None: From a874895a819e59c0417c354c175737c3f2b1e0a4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:35:09 +0100 Subject: [PATCH 0671/1544] Add gateway_mode Select to Plugwise (#108019) --- homeassistant/components/plugwise/const.py | 2 ++ homeassistant/components/plugwise/select.py | 8 ++++++++ homeassistant/components/plugwise/strings.json | 8 ++++++++ tests/components/plugwise/test_select.py | 3 +++ 4 files changed, 21 insertions(+) diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index f5677c0b4a9..cad891f16f2 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -44,11 +44,13 @@ NumberType = Literal[ SelectType = Literal[ "select_dhw_mode", + "select_gateway_mode", "select_regulation_mode", "select_schedule", ] SelectOptionsType = Literal[ "dhw_modes", + "gateway_modes", "regulation_modes", "available_schedules", ] diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 4be21fe9026..abb36bd5743 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -50,6 +50,14 @@ SELECT_TYPES = ( command=lambda api, loc, opt: api.set_dhw_mode(opt), options_key="dhw_modes", ), + PlugwiseSelectEntityDescription( + key="select_gateway_mode", + translation_key="gateway_mode", + icon="mdi:cog-outline", + entity_category=EntityCategory.CONFIG, + command=lambda api, loc, opt: api.set_gateway_mode(opt), + options_key="gateway_modes", + ), ) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index addd1ceadb1..7d26f5a624c 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -97,6 +97,14 @@ "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]" } }, + "gateway_mode": { + "name": "Gateway mode", + "state": { + "away": "Pause", + "full": "Normal", + "vacation": "Vacation" + } + }, "regulation_mode": { "name": "Regulation mode", "state": { diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f1220a07a2b..86b21af9e8b 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -54,6 +54,9 @@ async def test_adam_select_regulation_mode( Also tests a change in climate _previous mode. """ + state = hass.states.get("select.adam_gateway_mode") + assert state + assert state.state == "full" state = hass.states.get("select.adam_regulation_mode") assert state assert state.state == "cooling" From 6bc36666b1551c9fdc567b02d122398fd687e787 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:24:16 +0100 Subject: [PATCH 0672/1544] Add integration lamarzocco (#102291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker Co-authored-by: Abílio Costa Co-authored-by: Paul Bottein Co-authored-by: J. Nick Koston Co-authored-by: tronikos Co-authored-by: Luke Lashley Co-authored-by: Franck Nijhof Co-authored-by: dupondje --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/lamarzocco/__init__.py | 36 +++ .../components/lamarzocco/config_flow.py | 156 ++++++++++++ homeassistant/components/lamarzocco/const.py | 7 + .../components/lamarzocco/coordinator.py | 96 +++++++ homeassistant/components/lamarzocco/entity.py | 50 ++++ .../components/lamarzocco/manifest.json | 11 + .../components/lamarzocco/strings.json | 48 ++++ homeassistant/components/lamarzocco/switch.py | 92 +++++++ 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/lamarzocco/__init__.py | 25 ++ tests/components/lamarzocco/conftest.py | 104 ++++++++ .../lamarzocco/fixtures/config.json | 187 ++++++++++++++ .../lamarzocco/fixtures/current_status.json | 61 +++++ .../lamarzocco/fixtures/statistics.json | 26 ++ .../lamarzocco/snapshots/test_switch.ambr | 161 ++++++++++++ .../components/lamarzocco/test_config_flow.py | 235 ++++++++++++++++++ tests/components/lamarzocco/test_init.py | 70 ++++++ tests/components/lamarzocco/test_switch.py | 91 +++++++ 24 files changed, 1482 insertions(+) create mode 100644 homeassistant/components/lamarzocco/__init__.py create mode 100644 homeassistant/components/lamarzocco/config_flow.py create mode 100644 homeassistant/components/lamarzocco/const.py create mode 100644 homeassistant/components/lamarzocco/coordinator.py create mode 100644 homeassistant/components/lamarzocco/entity.py create mode 100644 homeassistant/components/lamarzocco/manifest.json create mode 100644 homeassistant/components/lamarzocco/strings.json create mode 100644 homeassistant/components/lamarzocco/switch.py create mode 100644 tests/components/lamarzocco/__init__.py create mode 100644 tests/components/lamarzocco/conftest.py create mode 100644 tests/components/lamarzocco/fixtures/config.json create mode 100644 tests/components/lamarzocco/fixtures/current_status.json create mode 100644 tests/components/lamarzocco/fixtures/statistics.json create mode 100644 tests/components/lamarzocco/snapshots/test_switch.ambr create mode 100644 tests/components/lamarzocco/test_config_flow.py create mode 100644 tests/components/lamarzocco/test_init.py create mode 100644 tests/components/lamarzocco/test_switch.py diff --git a/.strict-typing b/.strict-typing index d0b47db2d59..a4238292b6b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -248,6 +248,7 @@ homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* +homeassistant.components.lamarzocco.* homeassistant.components.lametric.* homeassistant.components.laundrify.* homeassistant.components.lawn_mower.* diff --git a/CODEOWNERS b/CODEOWNERS index ffad270b09f..1aac954b280 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,6 +688,8 @@ build.json @home-assistant/supervisor /tests/components/kulersky/ @emlove /homeassistant/components/lacrosse_view/ @IceBotYT /tests/components/lacrosse_view/ @IceBotYT +/homeassistant/components/lamarzocco/ @zweckj +/tests/components/lamarzocco/ @zweckj /homeassistant/components/lametric/ @robbiet480 @frenck @bachya /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..8bf48c3bf91 --- /dev/null +++ b/homeassistant/components/lamarzocco/__init__.py @@ -0,0 +1,36 @@ +"""The La Marzocco integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + +PLATFORMS = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up La Marzocco as config entry.""" + + coordinator = LaMarzoccoUpdateCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +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].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py new file mode 100644 index 00000000000..b2f097b818b --- /dev/null +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -0,0 +1,156 @@ +"""Config flow for La Marzocco integration.""" +from collections.abc import Mapping +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_MACHINE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for La Marzocco.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + + self.reauth_entry: ConfigEntry | None = None + self._config: dict[str, Any] = {} + self._machines: list[tuple[str, str]] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + errors = {} + + if user_input: + data: dict[str, Any] = {} + if self.reauth_entry: + data = dict(self.reauth_entry.data) + data = { + **data, + **user_input, + } + + lm = LaMarzoccoClient() + try: + self._machines = await lm.get_all_machines(data) + except AuthFail: + _LOGGER.debug("Server rejected login credentials") + errors["base"] = "invalid_auth" + except RequestNotSuccessful as exc: + _LOGGER.exception("Error connecting to server: %s", str(exc)) + errors["base"] = "cannot_connect" + else: + if not self._machines: + errors["base"] = "no_machines" + + if not errors: + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + if not errors: + self._config = data + return await self.async_step_machine_selection() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_machine_selection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Let user select machine to connect to.""" + errors: dict[str, str] = {} + if user_input: + serial_number = user_input[CONF_MACHINE] + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + + # validate local connection if host is provided + if user_input.get(CONF_HOST): + lm = LaMarzoccoClient() + if not await lm.check_local_connection( + credentials=self._config, + host=user_input[CONF_HOST], + serial=serial_number, + ): + errors[CONF_HOST] = "cannot_connect" + + if not errors: + return self.async_create_entry( + title=serial_number, + data=self._config | user_input, + ) + + machine_options = [ + SelectOptionDict( + value=serial_number, + label=f"{model_name} ({serial_number})", + ) + for serial_number, model_name in self._machines + ] + + machine_selection_schema = vol.Schema( + { + vol.Required( + CONF_MACHINE, default=machine_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=machine_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_HOST): cv.string, + } + ) + + return self.async_show_form( + step_id="machine_selection", + data_schema=machine_selection_schema, + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + ) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py new file mode 100644 index 00000000000..2afd1c4cf48 --- /dev/null +++ b/homeassistant/components/lamarzocco/const.py @@ -0,0 +1,7 @@ +"""Constants for the La Marzocco integration.""" + +from typing import Final + +DOMAIN: Final = "lamarzocco" + +CONF_MACHINE: Final = "machine" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py new file mode 100644 index 00000000000..9b6341e0858 --- /dev/null +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -0,0 +1,96 @@ +"""Coordinator for La Marzocco API.""" +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_MACHINE, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to handle fetching data from the La Marzocco API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.lm = LaMarzoccoClient( + callback_websocket_notify=self.async_update_listeners, + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + if not self.lm.initialized: + await self._async_init_client() + + await self._async_handle_request( + self.lm.update_local_machine_status, force_update=True + ) + + _LOGGER.debug("Current status: %s", str(self.lm.current_status)) + + async def _async_init_client(self) -> None: + """Initialize the La Marzocco Client.""" + + # Initialize cloud API + _LOGGER.debug("Initializing Cloud API") + await self._async_handle_request( + self.lm.init_cloud_api, + credentials=self.config_entry.data, + machine_serial=self.config_entry.data[CONF_MACHINE], + ) + _LOGGER.debug("Model name: %s", self.lm.model_name) + + # initialize local API + if (host := self.config_entry.data.get(CONF_HOST)) is not None: + _LOGGER.debug("Initializing local API") + await self.lm.init_local_api( + host=host, + client=get_async_client(self.hass), + ) + + _LOGGER.debug("Init WebSocket in Background Task") + + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.lm.lm_local_api.websocket_connect( + callback=self.lm.on_websocket_message_received, + use_sigterm_handler=False, + ), + name="lm_websocket_task", + ) + + self.lm.initialized = True + + async def _async_handle_request( + self, + func: Callable[..., Coroutine[None, None, None]], + *args: Any, + **kwargs: Any, + ) -> None: + """Handle a request to the API.""" + try: + await func(*args, **kwargs) + except AuthFail as ex: + msg = "Authentication failed." + _LOGGER.debug(msg, exc_info=True) + raise ConfigEntryAuthFailed(msg) from ex + except RequestNotSuccessful as ex: + _LOGGER.debug(ex, exc_info=True) + raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py new file mode 100644 index 00000000000..b2cb6dc2bff --- /dev/null +++ b/homeassistant/components/lamarzocco/entity.py @@ -0,0 +1,50 @@ +"""Base class for the La Marzocco entities.""" + +from dataclasses import dataclass + +from lmcloud.const import LaMarzoccoModel + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoEntityDescription(EntityDescription): + """Description for all LM entities.""" + + supported_models: tuple[LaMarzoccoModel, ...] = ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + LaMarzoccoModel.LINEA_MICRA, + LaMarzoccoModel.LINEA_MINI, + ) + + +class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): + """Common elements for all entities.""" + + entity_description: LaMarzoccoEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + lm = coordinator.lm + self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lm.serial_number)}, + name=lm.machine_name, + manufacturer="La Marzocco", + model=lm.true_model_name, + serial_number=lm.serial_number, + sw_version=lm.firmware_version, + ) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json new file mode 100644 index 00000000000..422f971186e --- /dev/null +++ b/homeassistant/components/lamarzocco/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lamarzocco", + "name": "La Marzocco", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lamarzocco", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["lmcloud"], + "requirements": ["lmcloud==0.4.34"] +} diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json new file mode 100644 index 00000000000..e3490270172 --- /dev/null +++ b/homeassistant/components/lamarzocco/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "flow_title": "La Marzocco Espresso {host}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_machines": "No machines found in account", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "machine_not_found": "The configured machine was not found in your account. Did you login to the correct account?", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your username from the La Marzocco app", + "password": "Your password from the La Marzocco app" + } + }, + "machine_selection": { + "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "machine": "Machine" + }, + "data_description": { + "host": "Local IP address of the machine" + } + } + } + }, + "entity": { + "switch": { + "auto_on_off": { + "name": "Auto on/off" + }, + "steam_boiler": { + "name": "Steam boiler" + } + } + } +} diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py new file mode 100644 index 00000000000..4c39bd2c5f0 --- /dev/null +++ b/homeassistant/components/lamarzocco/switch.py @@ -0,0 +1,92 @@ +"""Switch platform for La Marzocco espresso machines.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +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 DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSwitchEntityDescription( + LaMarzoccoEntityDescription, + SwitchEntityDescription, +): + """Description of a La Marzocco Switch.""" + + control_fn: Callable[[LaMarzoccoUpdateCoordinator, bool], Coroutine[Any, Any, bool]] + is_on_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] + + +ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( + LaMarzoccoSwitchEntityDescription( + key="main", + name=None, + icon="mdi:power", + control_fn=lambda coordinator, state: coordinator.lm.set_power(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], + ), + LaMarzoccoSwitchEntityDescription( + key="auto_on_off", + translation_key="auto_on_off", + icon="mdi:alarm", + control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global( + state + ), + is_on_fn=lambda coordinator: coordinator.lm.current_status["global_auto"] + == "Enabled", + entity_category=EntityCategory.CONFIG, + ), + LaMarzoccoSwitchEntityDescription( + key="steam_boiler_enable", + translation_key="steam_boiler", + icon="mdi:water-boiler", + control_fn=lambda coordinator, state: coordinator.lm.set_steam(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status[ + "steam_boiler_enable" + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities and services.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoSwitchEntity(coordinator, description) + for description in ENTITIES + if coordinator.lm.model_name in description.supported_models + ) + + +class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): + """Switches representing espresso machine power, prebrew, and auto on/off.""" + + entity_description: LaMarzoccoSwitchEntityDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self.entity_description.control_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + await self.entity_description.control_fn(self.coordinator, False) + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self.entity_description.is_on_fn(self.coordinator) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d14872aa29d..22b0fcf064f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -261,6 +261,7 @@ FLOWS = { "kraken", "kulersky", "lacrosse_view", + "lamarzocco", "lametric", "landisgyr_heat_meter", "lastfm", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 07872e987ae..ff35cf3235f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2999,6 +2999,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "lamarzocco": { + "name": "La Marzocco", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "lametric": { "name": "LaMetric", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 87829af666b..1b352a72f18 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2241,6 +2241,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lamarzocco.*] +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.lametric.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2f4b411ec80..cdf97a57504 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1209,6 +1209,9 @@ linear-garage-door==0.2.7 # homeassistant.components.linode linode-api==4.1.9b1 +# homeassistant.components.lamarzocco +lmcloud==0.4.34 + # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ce86ae77b4..9f8daba5708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -954,6 +954,9 @@ libsoundtouch==0.8 # homeassistant.components.linear_garage_door linear-garage-door==0.2.7 +# homeassistant.components.lamarzocco +lmcloud==0.4.34 + # homeassistant.components.logi_circle logi-circle==0.2.3 diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..bac7d4b3c61 --- /dev/null +++ b/tests/components/lamarzocco/__init__.py @@ -0,0 +1,25 @@ +"""Mock inputs for tests.""" + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +HOST_SELECTION = { + CONF_HOST: "192.168.1.1", +} + +PASSWORD_SELECTION = { + CONF_PASSWORD: "password", +} + +USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} + + +async def async_init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the La Marzocco integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py new file mode 100644 index 00000000000..98baac22d33 --- /dev/null +++ b/tests/components/lamarzocco/conftest.py @@ -0,0 +1,104 @@ +"""Lamarzocco session fixtures.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from lmcloud.const import LaMarzoccoModel +import pytest + +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.core import HomeAssistant + +from . import USER_INPUT, async_init_integration + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + data=USER_INPUT | {CONF_MACHINE: mock_lamarzocco.serial_number}, + unique_id=mock_lamarzocco.serial_number, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock +) -> MockConfigEntry: + """Set up the LaMetric integration for testing.""" + await async_init_integration(hass, mock_config_entry) + + return mock_config_entry + + +@pytest.fixture +def device_fixture() -> LaMarzoccoModel: + """Return the device fixture for a specific device.""" + return LaMarzoccoModel.GS3_AV + + +@pytest.fixture +def mock_lamarzocco( + request: pytest.FixtureRequest, device_fixture: LaMarzoccoModel +) -> Generator[MagicMock, None, None]: + """Return a mocked LM client.""" + model_name = device_fixture + + if model_name == LaMarzoccoModel.GS3_AV: + serial_number = "GS01234" + true_model_name = "GS3 AV" + elif model_name == LaMarzoccoModel.GS3_MP: + serial_number = "GS01234" + true_model_name = "GS3 MP" + elif model_name == LaMarzoccoModel.LINEA_MICRA: + serial_number = "MR01234" + true_model_name = "Linea Micra" + elif model_name == LaMarzoccoModel.LINEA_MINI: + serial_number = "LM01234" + true_model_name = "Linea Mini" + + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient", + autospec=True, + ) as lamarzocco_mock, patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient", + new=lamarzocco_mock, + ): + lamarzocco = lamarzocco_mock.return_value + + lamarzocco.machine_info = { + "machine_name": serial_number, + "serial_number": serial_number, + } + + lamarzocco.model_name = model_name + lamarzocco.true_model_name = true_model_name + lamarzocco.machine_name = serial_number + lamarzocco.serial_number = serial_number + + lamarzocco.firmware_version = "1.1" + lamarzocco.latest_firmware_version = "1.1" + lamarzocco.gateway_version = "v2.2-rc0" + lamarzocco.latest_gateway_version = "v3.1-rc4" + + lamarzocco.current_status = load_json_object_fixture( + "current_status.json", DOMAIN + ) + lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) + lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN) + + lamarzocco.get_all_machines.return_value = [ + (serial_number, model_name), + ] + lamarzocco.check_local_connection.return_value = True + lamarzocco.initialized = False + + yield lamarzocco diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json new file mode 100644 index 00000000000..60d11b0d470 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config.json @@ -0,0 +1,187 @@ +{ + "version": "v1", + "preinfusionModesAvailable": ["ByDoseType"], + "machineCapabilities": [ + { + "family": "GS3AV", + "groupsNumber": 1, + "coffeeBoilersNumber": 1, + "hasCupWarmer": false, + "steamBoilersNumber": 1, + "teaDosesNumber": 1, + "machineModes": ["BrewingMode", "StandBy"], + "schedulingType": "weeklyScheduling" + } + ], + "machine_sn": "GS01234", + "machine_hw": "2", + "isPlumbedIn": true, + "isBackFlushEnabled": false, + "standByTime": 0, + "tankStatus": true, + "groupCapabilities": [ + { + "capabilities": { + "groupType": "AV_Group", + "groupNumber": "Group1", + "boilerId": "CoffeeBoiler1", + "hasScale": false, + "hasFlowmeter": true, + "numberOfDoses": 4 + }, + "doses": [ + { + "groupNumber": "Group1", + "doseIndex": "DoseA", + "doseType": "PulsesType", + "stopTarget": 135 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseB", + "doseType": "PulsesType", + "stopTarget": 97 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseC", + "doseType": "PulsesType", + "stopTarget": 108 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseD", + "doseType": "PulsesType", + "stopTarget": 121 + } + ], + "doseMode": { + "groupNumber": "Group1", + "brewingType": "PulsesType" + } + } + ], + "machineMode": "BrewingMode", + "teaDoses": { + "DoseA": { + "doseIndex": "DoseA", + "stopTarget": 8 + } + }, + "boilers": [ + { + "id": "SteamBoiler", + "isEnabled": true, + "target": 123.90000152587891, + "current": 123.80000305175781 + }, + { + "id": "CoffeeBoiler1", + "isEnabled": true, + "target": 95, + "current": 96.5 + } + ], + "boilerTargetTemperature": { + "SteamBoiler": 123.90000152587891, + "CoffeeBoiler1": 95 + }, + "preinfusionMode": { + "Group1": { + "groupNumber": "Group1", + "preinfusionStyle": "PreinfusionByDoseType" + } + }, + "preinfusionSettings": { + "mode": "TypeB", + "Group1": [ + { + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseB", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.2999999523162842, + "preWetHoldTime": 3.2999999523162842 + }, + { + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 2, + "preWetHoldTime": 2 + } + ] + }, + "weeklySchedulingConfig": { + "enabled": true, + "monday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "tuesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "wednesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "thursday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "friday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "saturday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "sunday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + } + }, + "clock": "1901-07-08T10:29:00", + "firmwareVersions": [ + { + "name": "machine_firmware", + "fw_version": "1.40" + }, + { + "name": "gateway_firmware", + "fw_version": "v3.1-rc4" + } + ] +} diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json new file mode 100644 index 00000000000..4367bd1e38d --- /dev/null +++ b/tests/components/lamarzocco/fixtures/current_status.json @@ -0,0 +1,61 @@ +{ + "power": true, + "global_auto": "Enabled", + "enable_prebrewing": true, + "coffee_boiler_on": true, + "steam_boiler_on": true, + "enable_preinfusion": false, + "steam_boiler_enable": true, + "steam_temp": 113, + "steam_set_temp": 128, + "coffee_temp": 93, + "coffee_set_temp": 95, + "water_reservoir_contact": true, + "brew_active": false, + "drinks_k1": 13, + "drinks_k2": 2, + "drinks_k3": 42, + "drinks_k4": 34, + "total_flushing": 69, + "mon_auto": "Disabled", + "mon_on_time": "00:00", + "mon_off_time": "00:00", + "tue_auto": "Disabled", + "tue_on_time": "00:00", + "tue_off_time": "00:00", + "wed_auto": "Disabled", + "wed_on_time": "00:00", + "wed_off_time": "00:00", + "thu_auto": "Disabled", + "thu_on_time": "00:00", + "thu_off_time": "00:00", + "fri_auto": "Disabled", + "fri_on_time": "00:00", + "fri_off_time": "00:00", + "sat_auto": "Disabled", + "sat_on_time": "00:00", + "sat_off_time": "00:00", + "sun_auto": "Disabled", + "sun_on_time": "00:00", + "sun_off_time": "00:00", + "dose_k1": 1023, + "dose_k2": 1023, + "dose_k3": 1023, + "dose_k4": 1023, + "dose_k5": 1023, + "prebrewing_ton_k1": 3, + "prebrewing_toff_k1": 5, + "prebrewing_ton_k2": 3, + "prebrewing_toff_k2": 5, + "prebrewing_ton_k3": 3, + "prebrewing_toff_k3": 5, + "prebrewing_ton_k4": 3, + "prebrewing_toff_k4": 5, + "prebrewing_ton_k5": 3, + "prebrewing_toff_k5": 5, + "preinfusion_k1": 4, + "preinfusion_k2": 4, + "preinfusion_k3": 4, + "preinfusion_k4": 4, + "preinfusion_k5": 4 +} diff --git a/tests/components/lamarzocco/fixtures/statistics.json b/tests/components/lamarzocco/fixtures/statistics.json new file mode 100644 index 00000000000..c82d02cc7c1 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/statistics.json @@ -0,0 +1,26 @@ +[ + { + "count": 1047, + "coffeeType": 0 + }, + { + "count": 560, + "coffeeType": 1 + }, + { + "count": 468, + "coffeeType": 2 + }, + { + "count": 312, + "coffeeType": 3 + }, + { + "count": 2252, + "coffeeType": 4 + }, + { + "coffeeType": -1, + "count": 1740 + } +] diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr new file mode 100644 index 00000000000..36df947bb70 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -0,0 +1,161 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lamarzocco', + 'GS01234', + ), + }), + 'is_new': False, + 'manufacturer': 'La Marzocco', + 'model': 'GS3 AV', + 'name': 'GS01234', + 'name_by_user': None, + 'serial_number': 'GS01234', + 'suggested_area': None, + 'sw_version': '1.1', + 'via_device_id': None, + }) +# --- +# name: test_switches[-set_power] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234', + 'icon': 'mdi:power', + }), + 'context': , + 'entity_id': 'switch.gs01234', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[-set_power].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:power', + 'original_name': None, + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GS01234_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Auto on/off', + 'icon': 'mdi:alarm', + }), + 'context': , + 'entity_id': 'switch.gs01234_auto_on_off', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.gs01234_auto_on_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alarm', + 'original_name': 'Auto on/off', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS01234_auto_on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_steam_boiler-set_steam] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Steam boiler', + 'icon': 'mdi:water-boiler', + }), + 'context': , + 'entity_id': 'switch.gs01234_steam_boiler', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_steam_boiler-set_steam].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234_steam_boiler', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-boiler', + 'original_name': 'Steam boiler', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_boiler', + 'unique_id': 'GS01234_steam_boiler_enable', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py new file mode 100644 index 00000000000..703a1e9d36e --- /dev/null +++ b/tests/components/lamarzocco/test_config_flow.py @@ -0,0 +1,235 @@ +"""Test the La Marzocco config flow.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant import config_entries +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import PASSWORD_SELECTION, USER_INPUT + +from tests.common import MockConfigEntry + + +async def __do_successful_user_step( + hass: HomeAssistant, result: FlowResult +) -> FlowResult: + """Successfully configure the user step.""" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + return result2 + + +async def __do_sucessful_machine_selection_step( + hass: HomeAssistant, result2: FlowResult, mock_lamarzocco: MagicMock +) -> None: + """Successfully configure the machine selection step.""" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + + assert result3["title"] == mock_lamarzocco.serial_number + assert result3["data"] == { + **USER_INPUT, + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + } + + +async def test_form(hass: HomeAssistant, mock_lamarzocco: MagicMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 + + +async def test_form_abort_already_configured( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + + mock_lamarzocco.get_all_machines.side_effect = AuthFail("") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_invalid_host( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + mock_lamarzocco.check_local_connection.return_value = False + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == {"host": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.check_local_connection.return_value = True + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test cannot connect error.""" + + mock_lamarzocco.get_all_machines.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "no_machines"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + mock_lamarzocco.get_all_machines.side_effect = RequestNotSuccessful("") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + mock_lamarzocco.get_all_machines.return_value = [ + (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) + ] + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_reauth_flow( + hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that the reauth flow.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + PASSWORD_SELECTION, + ) + + assert result2["type"] == FlowResultType.ABORT + await hass.async_block_till_done() + assert result2["reason"] == "reauth_successful" + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py new file mode 100644 index 00000000000..1302961cfc0 --- /dev/null +++ b/tests/components/lamarzocco/test_init.py @@ -0,0 +1,70 @@ +"""Test initialization of lamarzocco.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test the La Marzocco configuration entry not ready.""" + mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test auth error during setup.""" + mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "user" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py new file mode 100644 index 00000000000..70024e3e340 --- /dev/null +++ b/tests/components/lamarzocco/test_switch.py @@ -0,0 +1,91 @@ +"""Tests for La Marzocco switches.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + ("entity_name", "method_name"), + [ + ("", "set_power"), + ("_auto_on_off", "set_auto_on_off_global"), + ("_steam_boiler", "set_steam"), + ], +) +async def test_switches( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + method_name: str, +) -> None: + """Test the La Marzocco switches.""" + serial_number = mock_lamarzocco.serial_number + + control_fn = getattr(mock_lamarzocco, method_name) + + state = hass.states.get(f"switch.{serial_number}{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 1 + control_fn.assert_called_once_with(False) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 2 + control_fn.assert_called_with(True) + + +async def test_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device for one switch.""" + + state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") + assert state + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot From 3ff74fe20fc725781edceeec72e983817caa27fc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 16 Jan 2024 16:44:12 +0100 Subject: [PATCH 0673/1544] Refactor demo vacuum's to only use StateVacuum base class and features (#108150) * Demo cleanup * Refactor vacuum demo to only use state vacuum base class * Remove unneeded feature checks * Remove exclusion issue for mqtt an demo --- homeassistant/components/demo/vacuum.py | 233 ++++---------------- homeassistant/components/vacuum/__init__.py | 4 - tests/components/demo/test_vacuum.py | 185 ++++------------ 3 files changed, 89 insertions(+), 333 deletions(-) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 83216ebdba6..6ce67dffb90 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -12,7 +12,6 @@ from homeassistant.components.vacuum import ( STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, - VacuumEntity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -23,24 +22,26 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.STATUS + VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP | VacuumEntityFeature.BATTERY ) SUPPORT_MOST_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF + VacuumEntityFeature.STATE + | VacuumEntityFeature.START | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED ) SUPPORT_ALL_SERVICES = ( - VacuumEntityFeature.TURN_ON - | VacuumEntityFeature.TURN_OFF + VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP | VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME @@ -49,27 +50,17 @@ SUPPORT_ALL_SERVICES = ( | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT ) -SUPPORT_STATE_SERVICES = ( - VacuumEntityFeature.STATE - | VacuumEntityFeature.PAUSE - | VacuumEntityFeature.STOP - | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.CLEAN_SPOT - | VacuumEntityFeature.START -) - FAN_SPEEDS = ["min", "medium", "high", "max"] DEMO_VACUUM_COMPLETE = "0_Ground_floor" DEMO_VACUUM_MOST = "1_First_floor" DEMO_VACUUM_BASIC = "2_Second_floor" DEMO_VACUUM_MINIMAL = "3_Third_floor" DEMO_VACUUM_NONE = "4_Fourth_floor" -DEMO_VACUUM_STATE = "5_Fifth_floor" async def async_setup_entry( @@ -80,18 +71,17 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), - DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), - DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), - DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), - DemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), - StateDemoVacuum(DEMO_VACUUM_STATE), + StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), + StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), + StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), + StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), + StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)), ] ) -class DemoVacuum(VacuumEntity): - """Representation of a demo vacuum.""" +class StateDemoVacuum(StateVacuumEntity): + """Representation of a demo vacuum supporting states.""" _attr_should_poll = False _attr_translation_key = "model_s" @@ -100,148 +90,6 @@ class DemoVacuum(VacuumEntity): """Initialize the vacuum.""" self._attr_name = name self._attr_supported_features = supported_features - self._state = False - self._status = "Charging" - self._fan_speed = FAN_SPEEDS[1] - self._cleaned_area: float = 0 - self._battery_level = 100 - - @property - def is_on(self) -> bool: - """Return true if vacuum is on.""" - return self._state - - @property - def status(self) -> str: - """Return the status of the vacuum.""" - return self._status - - @property - def fan_speed(self) -> str: - """Return the status of the vacuum.""" - return self._fan_speed - - @property - def fan_speed_list(self) -> list[str]: - """Return the status of the vacuum.""" - return FAN_SPEEDS - - @property - def battery_level(self) -> int: - """Return the status of the vacuum.""" - return max(0, min(100, self._battery_level)) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attributes.""" - return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} - - def turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on.""" - if self.supported_features & VacuumEntityFeature.TURN_ON == 0: - return - - self._state = True - self._cleaned_area += 5.32 - self._battery_level -= 2 - self._status = "Cleaning" - self.schedule_update_ha_state() - - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off.""" - if self.supported_features & VacuumEntityFeature.TURN_OFF == 0: - return - - self._state = False - self._status = "Charging" - self.schedule_update_ha_state() - - def stop(self, **kwargs: Any) -> None: - """Stop the vacuum.""" - if self.supported_features & VacuumEntityFeature.STOP == 0: - return - - self._state = False - self._status = "Stopping the current task" - self.schedule_update_ha_state() - - def clean_spot(self, **kwargs: Any) -> None: - """Perform a spot clean-up.""" - if self.supported_features & VacuumEntityFeature.CLEAN_SPOT == 0: - return - - self._state = True - self._cleaned_area += 1.32 - self._battery_level -= 1 - self._status = "Cleaning spot" - self.schedule_update_ha_state() - - def locate(self, **kwargs: Any) -> None: - """Locate the vacuum (usually by playing a song).""" - if self.supported_features & VacuumEntityFeature.LOCATE == 0: - return - - self._status = "Hi, I'm over here!" - self.schedule_update_ha_state() - - def start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - if self.supported_features & VacuumEntityFeature.PAUSE == 0: - return - - self._state = not self._state - if self._state: - self._status = "Resuming the current task" - self._cleaned_area += 1.32 - self._battery_level -= 1 - else: - self._status = "Pausing the current task" - self.schedule_update_ha_state() - - def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: - """Set the vacuum's fan speed.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED == 0: - return - - if fan_speed in self.fan_speed_list: - self._fan_speed = fan_speed - self.schedule_update_ha_state() - - def return_to_base(self, **kwargs: Any) -> None: - """Tell the vacuum to return to its dock.""" - if self.supported_features & VacuumEntityFeature.RETURN_HOME == 0: - return - - self._state = False - self._status = "Returning home..." - self._battery_level += 5 - self.schedule_update_ha_state() - - def send_command( - self, - command: str, - params: dict[str, Any] | list[Any] | None = None, - **kwargs: Any, - ) -> None: - """Send a command to the vacuum.""" - if self.supported_features & VacuumEntityFeature.SEND_COMMAND == 0: - return - - self._status = f"Executing {command}({params})" - self._state = True - self.schedule_update_ha_state() - - -class StateDemoVacuum(StateVacuumEntity): - """Representation of a demo vacuum supporting states.""" - - _attr_should_poll = False - _attr_supported_features = SUPPORT_STATE_SERVICES - _attr_translation_key = "model_s" - - def __init__(self, name: str) -> None: - """Initialize the vacuum.""" - self._attr_name = name self._state = STATE_DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 @@ -274,9 +122,6 @@ class StateDemoVacuum(StateVacuumEntity): def start(self) -> None: """Start or resume the cleaning task.""" - if self.supported_features & VacuumEntityFeature.START == 0: - return - if self._state != STATE_CLEANING: self._state = STATE_CLEANING self._cleaned_area += 1.32 @@ -285,26 +130,17 @@ class StateDemoVacuum(StateVacuumEntity): def pause(self) -> None: """Pause the cleaning task.""" - if self.supported_features & VacuumEntityFeature.PAUSE == 0: - return - if self._state == STATE_CLEANING: self._state = STATE_PAUSED self.schedule_update_ha_state() def stop(self, **kwargs: Any) -> None: """Stop the cleaning task, do not return to dock.""" - if self.supported_features & VacuumEntityFeature.STOP == 0: - return - self._state = STATE_IDLE self.schedule_update_ha_state() def return_to_base(self, **kwargs: Any) -> None: """Return dock to charging base.""" - if self.supported_features & VacuumEntityFeature.RETURN_HOME == 0: - return - self._state = STATE_RETURNING self.schedule_update_ha_state() @@ -312,9 +148,6 @@ class StateDemoVacuum(StateVacuumEntity): def clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if self.supported_features & VacuumEntityFeature.CLEAN_SPOT == 0: - return - self._state = STATE_CLEANING self._cleaned_area += 1.32 self._battery_level -= 1 @@ -322,13 +155,35 @@ class StateDemoVacuum(StateVacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set the vacuum's fan speed.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED == 0: - return - if fan_speed in self.fan_speed_list: self._fan_speed = fan_speed self.schedule_update_ha_state() + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum's position.""" + await self.hass.services.async_call( + "notify", + "persistent_notification", + service_data={"message": "I'm here!", "title": "Locate request"}, + ) + self._state = STATE_IDLE + self.async_write_ha_state() + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Locate the vacuum's position.""" + self._state = STATE_CLEANING + self.async_write_ha_state() + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to the vacuum.""" + self._state = STATE_IDLE + self.async_write_ha_state() + def __set_state_to_dock(self, _: datetime) -> None: self._state = STATE_DOCKED self.schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 9a10da23824..f15d59e9455 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -417,10 +417,6 @@ class VacuumEntity( ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) - # Don't report core integrations known to still use the deprecated base class; - # we don't worry about demo and mqtt has it's own deprecation warnings. - if self.platform.platform_name in ("demo", "mqtt"): - return translation_key = "deprecated_vacuum_base_class" translation_placeholders = {"platform": self.platform.platform_name} issue_tracker = async_get_issue_tracker( diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index cc0fcfeb2d2..987bc6b9384 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -4,14 +4,12 @@ from unittest.mock import patch import pytest -from homeassistant.components import vacuum from homeassistant.components.demo.vacuum import ( DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL, DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, - DEMO_VACUUM_STATE, FAN_SPEEDS, ) from homeassistant.components.vacuum import ( @@ -20,7 +18,6 @@ from homeassistant.components.vacuum import ( ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, ATTR_PARAMS, - ATTR_STATUS, DOMAIN, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, @@ -34,8 +31,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, - STATE_OFF, - STATE_ON, Platform, ) from homeassistant.core import HomeAssistant @@ -51,7 +46,6 @@ ENTITY_VACUUM_COMPLETE = f"{DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() ENTITY_VACUUM_MINIMAL = f"{DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() ENTITY_VACUUM_MOST = f"{DOMAIN}.{DEMO_VACUUM_MOST}".lower() ENTITY_VACUUM_NONE = f"{DOMAIN}.{DEMO_VACUUM_NONE}".lower() -ENTITY_VACUUM_STATE = f"{DOMAIN}.{DEMO_VACUUM_STATE}".lower() @pytest.fixture @@ -74,166 +68,103 @@ async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): async def test_supported_features(hass: HomeAssistant) -> None: """Test vacuum supported features.""" state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16380 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 219 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.attributes.get(ATTR_FAN_SPEED) is None - assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FAN_SPEED) == "medium" + assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 195 - assert state.attributes.get(ATTR_STATUS) == "Charging" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 - assert state.attributes.get(ATTR_STATUS) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF + assert state.state == STATE_DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_STATUS) is None assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None - assert state.state == STATE_OFF - - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 13436 assert state.state == STATE_DOCKED - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 - assert state.attributes.get(ATTR_FAN_SPEED) == "medium" - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS async def test_methods(hass: HomeAssistant) -> None: """Test if methods call the services as expected.""" - hass.states.async_set(ENTITY_VACUUM_BASIC, STATE_ON) + await common.async_start(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_BASIC) + state = hass.states.get(ENTITY_VACUUM_BASIC) + assert state.state == STATE_CLEANING - hass.states.async_set(ENTITY_VACUUM_BASIC, STATE_OFF) + await common.async_stop(hass, ENTITY_VACUUM_BASIC) await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_BASIC) - - await common.async_turn_on(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_turn_off(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_toggle(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_start_pause(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_start_pause(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - await common.async_stop(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + state = hass.states.get(ENTITY_VACUUM_BASIC) + assert state.state == STATE_IDLE state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100 - assert state.attributes.get(ATTR_STATUS) != "Charging" + await hass.async_block_till_done() + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.state == STATE_DOCKED + await async_setup_component(hass, "notify", {}) + await hass.async_block_till_done() await common.async_locate(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "I'm over here" in state.attributes.get(ATTR_STATUS) + assert state.state == STATE_IDLE await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "Returning home" in state.attributes.get(ATTR_STATUS) + assert state.state == STATE_RETURNING await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_COMPLETE ) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.attributes.get(ATTR_FAN_SPEED) == FAN_SPEEDS[-1] - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_COMPLETE) + await common.async_clean_spot(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert "spot" in state.attributes.get(ATTR_STATUS) - assert state.state == STATE_ON - - await common.async_start(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_CLEANING - await common.async_pause(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) + await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_PAUSED - await common.async_stop(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state == STATE_IDLE - - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100 - assert state.state != STATE_DOCKED - - await common.async_return_to_base(hass, ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) + await common.async_return_to_base(hass, ENTITY_VACUUM_COMPLETE) + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_RETURNING async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() - state = hass.states.get(ENTITY_VACUUM_STATE) + state = hass.states.get(ENTITY_VACUUM_COMPLETE) assert state.state == STATE_DOCKED - await common.async_set_fan_speed( - hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_STATE - ) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.attributes.get(ATTR_FAN_SPEED) == FAN_SPEEDS[-1] - - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state == STATE_CLEANING - async def test_unsupported_methods(hass: HomeAssistant) -> None: """Test service calls for unsupported vacuums.""" - hass.states.async_set(ENTITY_VACUUM_NONE, STATE_ON) - await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): await common.async_stop(hass, ENTITY_VACUUM_NONE) - hass.states.async_set(ENTITY_VACUUM_NONE, STATE_OFF) - await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, ENTITY_VACUUM_NONE) - - with pytest.raises(HomeAssistantError): - await common.async_toggle(hass, ENTITY_VACUUM_NONE) - - # Non supported methods: - with pytest.raises(HomeAssistantError): - await common.async_start_pause(hass, ENTITY_VACUUM_NONE) - with pytest.raises(HomeAssistantError): await common.async_locate(hass, ENTITY_VACUUM_NONE) @@ -244,34 +175,14 @@ async def test_unsupported_methods(hass: HomeAssistant) -> None: await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_NONE ) + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_BASIC) - - # VacuumEntity should not support start and pause methods. - hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_ON) - await hass.async_block_till_done() - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - - with pytest.raises(AttributeError): - await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) - - hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_OFF) - await hass.async_block_till_done() - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + await common.async_pause(hass, ENTITY_VACUUM_NONE) with pytest.raises(HomeAssistantError): - await common.async_start(hass, ENTITY_VACUUM_COMPLETE) - - # StateVacuumEntity does not support on/off - with pytest.raises(HomeAssistantError): - await common.async_turn_on(hass, entity_id=ENTITY_VACUUM_STATE) - - with pytest.raises(HomeAssistantError): - await common.async_turn_off(hass, entity_id=ENTITY_VACUUM_STATE) - - with pytest.raises(HomeAssistantError): - await common.async_toggle(hass, entity_id=ENTITY_VACUUM_STATE) + await common.async_start(hass, ENTITY_VACUUM_NONE) async def test_services(hass: HomeAssistant) -> None: @@ -295,9 +206,7 @@ async def test_services(hass: HomeAssistant) -> None: # Test set fan speed set_fan_speed_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_FAN_SPEED) - await common.async_set_fan_speed( - hass, FAN_SPEEDS[0], entity_id=ENTITY_VACUUM_COMPLETE - ) + await common.async_set_fan_speed(hass, FAN_SPEEDS[0], ENTITY_VACUUM_COMPLETE) assert len(set_fan_speed_calls) == 1 call = set_fan_speed_calls[-1] @@ -309,22 +218,22 @@ async def test_services(hass: HomeAssistant) -> None: async def test_set_fan_speed(hass: HomeAssistant) -> None: """Test vacuum service to set the fan speed.""" - group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_STATE]) + group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_MOST]) old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) - old_state_state = hass.states.get(ENTITY_VACUUM_STATE) + old_state_most = hass.states.get(ENTITY_VACUUM_MOST) await common.async_set_fan_speed(hass, FAN_SPEEDS[0], entity_id=group_vacuums) new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) - new_state_state = hass.states.get(ENTITY_VACUUM_STATE) + new_state_most = hass.states.get(ENTITY_VACUUM_MOST) assert old_state_complete != new_state_complete assert old_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] assert new_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] - assert old_state_state != new_state_state - assert old_state_state.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] - assert new_state_state.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] + assert old_state_most != new_state_most + assert old_state_most.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] + assert new_state_most.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] async def test_send_command(hass: HomeAssistant) -> None: @@ -339,8 +248,4 @@ async def test_send_command(hass: HomeAssistant) -> None: new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) assert old_state_complete != new_state_complete - assert new_state_complete.state == STATE_ON - assert ( - new_state_complete.attributes[ATTR_STATUS] - == "Executing test_command({'p1': 3})" - ) + assert new_state_complete.state == STATE_IDLE From d0f478030001a66a2dd6c18fc13a944e1ef7e877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Tue, 16 Jan 2024 18:04:44 +0200 Subject: [PATCH 0674/1544] Bump vallox_websocket_api to 4.0.3 (#108109) --- homeassistant/components/vallox/__init__.py | 4 ++-- homeassistant/components/vallox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index ce40e07e294..c98e2685118 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import date import ipaddress import logging -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple from uuid import UUID from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox, ValloxApiException @@ -125,7 +125,7 @@ class ValloxState: @property def model(self) -> str | None: """Return the model, if any.""" - model = cast(str, _api_get_model(self.metric_cache)) + model = _api_get_model(self.metric_cache) if model == "Unknown": return None diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index c06bc036e4e..b45a2d598c9 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==4.0.2"] + "requirements": ["vallox-websocket-api==4.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cdf97a57504..8c1abd6ad32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2745,7 +2745,7 @@ uvcclient==0.11.0 vacuum-map-parser-roborock==0.1.1 # homeassistant.components.vallox -vallox-websocket-api==4.0.2 +vallox-websocket-api==4.0.3 # homeassistant.components.rdw vehicle==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f8daba5708..3fb7844baa9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2080,7 +2080,7 @@ uvcclient==0.11.0 vacuum-map-parser-roborock==0.1.1 # homeassistant.components.vallox -vallox-websocket-api==4.0.2 +vallox-websocket-api==4.0.3 # homeassistant.components.rdw vehicle==2.2.1 From b24222bd1ddfe17345f8bb5b705e7d96ab06a81e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 16 Jan 2024 08:05:35 -0800 Subject: [PATCH 0675/1544] Add debugging to assist in debugging already configured error (#108134) --- homeassistant/components/google/config_flow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index a9d707fe4bf..ab38e67479f 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -215,6 +215,12 @@ class OAuth2FlowHandler( _LOGGER.error("Error reading primary calendar: %s", err) return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(primary_calendar.id) + + if found := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, primary_calendar.id + ): + _LOGGER.debug("Found existing '%s' entry: %s", primary_calendar.id, found) + self._abort_if_unique_id_configured() return self.async_create_entry( title=primary_calendar.id, From 95ed1ada50ffb72e15a9494ded7736631674316a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Tue, 16 Jan 2024 19:35:50 +0100 Subject: [PATCH 0676/1544] Add late PR improvements to La Marzocco (#108162) * add Martin's suggestions * use password description * fix for reauth + test * fix invalid_auth test * Update homeassistant/components/lamarzocco/config_flow.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/lamarzocco/config_flow.py | 29 ++++++++++++------- .../components/lamarzocco/strings.json | 13 +++++++-- .../components/lamarzocco/test_config_flow.py | 10 +++++-- tests/components/lamarzocco/test_init.py | 2 +- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index b2f097b818b..7c63532104f 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -56,7 +56,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Server rejected login credentials") errors["base"] = "invalid_auth" except RequestNotSuccessful as exc: - _LOGGER.exception("Error connecting to server: %s", str(exc)) + _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: if not self._machines: @@ -65,7 +65,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: if self.reauth_entry: self.hass.config_entries.async_update_entry( - self.reauth_entry, data=user_input + self.reauth_entry, data=data ) await self.hass.config_entries.async_reload( self.reauth_entry.entry_id @@ -146,11 +146,20 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + ) + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index e3490270172..01bd3860825 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -8,9 +8,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_machines": "No machines found in account", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "machine_not_found": "The configured machine was not found in your account. Did you login to the correct account?", - "unknown": "[%key:common::config_flow::error::unknown%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { "user": { @@ -32,6 +30,15 @@ "data_description": { "host": "Local IP address of the machine" } + }, + "reauth_confirm": { + "description": "Re-authentication required. Please enter your password again.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" + } } } }, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 703a1e9d36e..e8500ee427d 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -6,11 +6,11 @@ from lmcloud.exceptions import AuthFail, RequestNotSuccessful from homeassistant import config_entries from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType -from . import PASSWORD_SELECTION, USER_INPUT +from . import USER_INPUT from tests.common import MockConfigEntry @@ -224,12 +224,16 @@ async def test_reauth_flow( data=mock_config_entry.data, ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - PASSWORD_SELECTION, + {CONF_PASSWORD: "new_password"}, ) assert result2["type"] == FlowResultType.ABORT await hass.async_block_till_done() assert result2["reason"] == "reauth_successful" assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 1302961cfc0..91243c76eaf 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -62,7 +62,7 @@ async def test_invalid_auth( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "user" + assert flow.get("step_id") == "reauth_confirm" assert flow.get("handler") == DOMAIN assert "context" in flow From ddaf194f914134ba5a676799ab1ee5ef35ae41f9 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Tue, 16 Jan 2024 20:00:29 +0100 Subject: [PATCH 0677/1544] Add Govee local light integration (#106697) * Added govee_local_api * Code cleanup * Fix discovery * Add missing supported device * Fix autodiscovery * Add missing quality scale in manifest.json * QA * QA: Moved coordinator creation to __init__.py * QA * Fix typo and update test * QA * Removed unecessary code * Fix typo * Fix typo * QA, typing and strings * Removed unsed logger in __init__.py * QA, using ColorMode for lights capabilities * Bump govee_local_api to 1.4.0 Moved capabilities to library. * Update requirements * Update library to 1.4.1 with unsupported dvice warning * Fix tests after library update * QA * Add test for retry config * Update integration name and domain * Update homeassistant/components/govee_light_local/light.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/brands/govee.json | 5 + .../components/govee_light_local/__init__.py | 44 +++ .../govee_light_local/config_flow.py | 58 +++ .../components/govee_light_local/const.py | 13 + .../govee_light_local/coordinator.py | 90 +++++ .../components/govee_light_local/light.py | 160 +++++++++ .../govee_light_local/manifest.json | 10 + .../components/govee_light_local/strings.json | 13 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/govee_light_local/__init__.py | 1 + .../components/govee_light_local/conftest.py | 37 ++ .../govee_light_local/test_config_flow.py | 74 ++++ .../govee_light_local/test_light.py | 338 ++++++++++++++++++ 17 files changed, 868 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/govee.json create mode 100644 homeassistant/components/govee_light_local/__init__.py create mode 100644 homeassistant/components/govee_light_local/config_flow.py create mode 100644 homeassistant/components/govee_light_local/const.py create mode 100644 homeassistant/components/govee_light_local/coordinator.py create mode 100644 homeassistant/components/govee_light_local/light.py create mode 100644 homeassistant/components/govee_light_local/manifest.json create mode 100644 homeassistant/components/govee_light_local/strings.json create mode 100644 tests/components/govee_light_local/__init__.py create mode 100644 tests/components/govee_light_local/conftest.py create mode 100644 tests/components/govee_light_local/test_config_flow.py create mode 100644 tests/components/govee_light_local/test_light.py diff --git a/CODEOWNERS b/CODEOWNERS index 1aac954b280..d0e0bddf0d6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -495,6 +495,8 @@ build.json @home-assistant/supervisor /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax /tests/components/govee_ble/ @bdraco @PierreAronnax +/homeassistant/components/govee_light_local/ @Galorhallen +/tests/components/govee_light_local/ @Galorhallen /homeassistant/components/gpsd/ @fabaff /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche diff --git a/homeassistant/brands/govee.json b/homeassistant/brands/govee.json new file mode 100644 index 00000000000..92091d68f58 --- /dev/null +++ b/homeassistant/brands/govee.json @@ -0,0 +1,5 @@ +{ + "domain": "govee", + "name": "Govee", + "integrations": ["govee_ble", "govee_light_local"] +} diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..ab20f4cefcd --- /dev/null +++ b/homeassistant/components/govee_light_local/__init__.py @@ -0,0 +1,44 @@ +"""The Govee Light local integration.""" +from __future__ import annotations + +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import GoveeLocalApiCoordinator + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Govee light local from a config entry.""" + + coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass) + entry.async_on_unload(coordinator.cleanup) + + await coordinator.start() + + await coordinator.async_config_entry_first_refresh() + + try: + async with asyncio.timeout(delay=5): + while not coordinator.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py new file mode 100644 index 00000000000..8ab14966828 --- /dev/null +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Govee light local.""" + +from __future__ import annotations + +import asyncio +import logging + +from govee_local_api import GoveeController + +from homeassistant.components import network +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import ( + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + adapter = await network.async_get_source_ip(hass, network.PUBLIC_TARGET_IP) + + controller: GoveeController = GoveeController( + loop=hass.loop, + logger=_LOGGER, + listening_address=adapter, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=1, + update_enabled=False, + ) + + await controller.start() + + try: + async with asyncio.timeout(delay=5): + while not controller.devices: + await asyncio.sleep(delay=1) + except asyncio.TimeoutError: + _LOGGER.debug("No devices found") + + devices_count = len(controller.devices) + controller.cleanup() + + return devices_count > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Govee light local", _async_has_devices +) diff --git a/homeassistant/components/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py new file mode 100644 index 00000000000..d9410c9c05e --- /dev/null +++ b/homeassistant/components/govee_light_local/const.py @@ -0,0 +1,13 @@ +"""Constants for the Govee light local integration.""" + +from datetime import timedelta + +DOMAIN = "govee_light_local" +MANUFACTURER = "Govee" + +CONF_MULTICAST_ADDRESS_DEFAULT = "239.255.255.250" +CONF_TARGET_PORT_DEFAULT = 4001 +CONF_LISTENING_PORT_DEFAULT = 4002 +CONF_DISCOVERY_INTERVAL_DEFAULT = 60 + +SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py new file mode 100644 index 00000000000..79b572e89ae --- /dev/null +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -0,0 +1,90 @@ +"""Coordinator for Govee light local.""" + +from collections.abc import Callable +import logging + +from govee_local_api import GoveeController, GoveeDevice + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_DISCOVERY_INTERVAL_DEFAULT, + CONF_LISTENING_PORT_DEFAULT, + CONF_MULTICAST_ADDRESS_DEFAULT, + CONF_TARGET_PORT_DEFAULT, + SCAN_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): + """Govee light local coordinator.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize my coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name="GoveeLightLocalApi", + update_interval=SCAN_INTERVAL, + ) + + self._controller = GoveeController( + loop=hass.loop, + logger=_LOGGER, + broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, + broadcast_port=CONF_TARGET_PORT_DEFAULT, + listening_port=CONF_LISTENING_PORT_DEFAULT, + discovery_enabled=True, + discovery_interval=CONF_DISCOVERY_INTERVAL_DEFAULT, + discovered_callback=None, + update_enabled=False, + ) + + async def start(self) -> None: + """Start the Govee coordinator.""" + await self._controller.start() + self._controller.send_update_message() + + async def set_discovery_callback( + self, callback: Callable[[GoveeDevice, bool], bool] + ) -> None: + """Set discovery callback for automatic Govee light discovery.""" + self._controller.set_device_discovered_callback(callback) + + def cleanup(self) -> None: + """Stop and cleanup the cooridinator.""" + self._controller.cleanup() + + async def turn_on(self, device: GoveeDevice) -> None: + """Turn on the light.""" + await device.turn_on() + + async def turn_off(self, device: GoveeDevice) -> None: + """Turn off the light.""" + await device.turn_off() + + async def set_brightness(self, device: GoveeDevice, brightness: int) -> None: + """Set light brightness.""" + await device.set_brightness(brightness) + + async def set_rgb_color( + self, device: GoveeDevice, red: int, green: int, blue: int + ) -> None: + """Set light RGB color.""" + await device.set_rgb_color(red, green, blue) + + async def set_temperature(self, device: GoveeDevice, temperature: int) -> None: + """Set light color in kelvin.""" + await device.set_temperature(temperature) + + @property + def devices(self) -> list[GoveeDevice]: + """Return a list of discovered Govee devices.""" + return self._controller.devices + + async def _async_update_data(self) -> list[GoveeDevice]: + self._controller.send_update_message() + return self._controller.devices diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py new file mode 100644 index 00000000000..fec0ff5a898 --- /dev/null +++ b/homeassistant/components/govee_light_local/light.py @@ -0,0 +1,160 @@ +"""Govee light local.""" + +from __future__ import annotations + +import logging +from typing import Any + +from govee_local_api import GoveeDevice, GoveeLightCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import GoveeLocalApiCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Govee light setup.""" + + coordinator: GoveeLocalApiCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + def discovery_callback(device: GoveeDevice, is_new: bool) -> bool: + if is_new: + async_add_entities([GoveeLight(coordinator, device)]) + return True + + async_add_entities( + GoveeLight(coordinator, device) for device in coordinator.devices + ) + + await coordinator.set_discovery_callback(discovery_callback) + + +class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): + """Govee Light.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GoveeLocalApiCoordinator, + device: GoveeDevice, + ) -> None: + """Govee Light constructor.""" + + super().__init__(coordinator) + self._device = device + device.set_update_callback(self._update_callback) + + self._attr_unique_id = device.fingerprint + + capabilities = device.capabilities + color_modes = set() + if capabilities: + if GoveeLightCapability.COLOR_RGB in capabilities: + color_modes.add(ColorMode.RGB) + if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + color_modes.add(ColorMode.COLOR_TEMP) + self._attr_max_color_temp_kelvin = 9000 + self._attr_min_color_temp_kelvin = 2000 + if GoveeLightCapability.BRIGHTNESS in capabilities: + color_modes.add(ColorMode.BRIGHTNESS) + else: + color_modes.add(ColorMode.ONOFF) + + self._attr_supported_color_modes = color_modes + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, device.fingerprint) + }, + name=device.sku, + manufacturer=MANUFACTURER, + model=device.sku, + connections={(CONNECTION_NETWORK_MAC, device.fingerprint)}, + ) + + @property + def is_on(self) -> bool: + """Return true if device is on (brightness above 0).""" + return self._device.on + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return int((self._device.brightness / 100.0) * 255.0) + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature in Kelvin.""" + return self._device.temperature_color + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color.""" + return self._device.rgb_color + + @property + def color_mode(self) -> ColorMode | str | None: + """Return the color mode.""" + if ( + self._device.temperature_color is not None + and self._device.temperature_color > 0 + ): + return ColorMode.COLOR_TEMP + if self._device.rgb_color is not None and any(self._device.rgb_color): + return ColorMode.RGB + + if ( + self._attr_supported_color_modes + and ColorMode.BRIGHTNESS in self._attr_supported_color_modes + ): + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + if not self.is_on or not kwargs: + await self.coordinator.turn_on(self._device) + + if ATTR_BRIGHTNESS in kwargs: + brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) + await self.coordinator.set_brightness(self._device, brightness) + + if ATTR_RGB_COLOR in kwargs: + self._attr_color_mode = ColorMode.RGB + red, green, blue = kwargs[ATTR_RGB_COLOR] + await self.coordinator.set_rgb_color(self._device, red, green, blue) + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + self._attr_color_mode = ColorMode.COLOR_TEMP + temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] + await self.coordinator.set_temperature(self._device, int(temperature)) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.coordinator.turn_off(self._device) + self.async_write_ha_state() + + @callback + def _update_callback(self, device: GoveeDevice) -> None: + self.async_write_ha_state() diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json new file mode 100644 index 00000000000..f34fd0b899f --- /dev/null +++ b/homeassistant/components/govee_light_local/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "govee_light_local", + "name": "Govee lights local", + "codeowners": ["@Galorhallen"], + "config_flow": true, + "dependencies": ["network"], + "documentation": "https://www.home-assistant.io/integrations/govee_light_local", + "iot_class": "local_push", + "requirements": ["govee-local-api==1.4.1"] +} diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/govee_light_local/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 22b0fcf064f..603a8e33e2c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -197,6 +197,7 @@ FLOWS = { "google_translate", "google_travel_time", "govee_ble", + "govee_light_local", "gpslogger", "gree", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ff35cf3235f..383661410cc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2266,11 +2266,22 @@ } } }, - "govee_ble": { - "name": "Govee Bluetooth", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "govee": { + "name": "Govee", + "integrations": { + "govee_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee Bluetooth" + }, + "govee_light_local": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Govee lights local" + } + } }, "gpsd": { "name": "GPSD", diff --git a/requirements_all.txt b/requirements_all.txt index 8c1abd6ad32..d58a208774a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -954,6 +954,9 @@ gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.27.3 +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 + # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fb7844baa9..4cc6cea4b0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -771,6 +771,9 @@ gotailwind==0.2.2 # homeassistant.components.govee_ble govee-ble==0.27.3 +# homeassistant.components.govee_light_local +govee-local-api==1.4.1 + # homeassistant.components.gree greeclimate==1.4.1 diff --git a/tests/components/govee_light_local/__init__.py b/tests/components/govee_light_local/__init__.py new file mode 100644 index 00000000000..b4ea9560d25 --- /dev/null +++ b/tests/components/govee_light_local/__init__.py @@ -0,0 +1 @@ +"""Tests for the Govee Light local integration.""" diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py new file mode 100644 index 00000000000..2b3690f7011 --- /dev/null +++ b/tests/components/govee_light_local/conftest.py @@ -0,0 +1,37 @@ +"""Tests configuration for Govee Local API.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeLightCapability +import pytest + +from homeassistant.components.govee_light_local.coordinator import GoveeController + + +@pytest.fixture(name="mock_govee_api") +def fixture_mock_govee_api(): + """Set up Govee Local API fixture.""" + mock_api = AsyncMock(spec=GoveeController) + mock_api.start = AsyncMock() + mock_api.turn_on_off = AsyncMock() + mock_api.set_brightness = AsyncMock() + mock_api.set_color = AsyncMock() + mock_api._async_update_data = AsyncMock() + return mock_api + + +@pytest.fixture(name="mock_setup_entry") +def fixture_mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.govee_light_local.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { + GoveeLightCapability.COLOR_RGB, + GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, + GoveeLightCapability.BRIGHTNESS, +} diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py new file mode 100644 index 00000000000..7753b40c29c --- /dev/null +++ b/tests/components/govee_light_local/test_config_flow.py @@ -0,0 +1,74 @@ +"""Test Govee light local config flow.""" +from unittest.mock import AsyncMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import DEFAULT_CAPABILITEIS + + +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_govee_api: AsyncMock +) -> None: + """Test setting up Govee with no devices.""" + + mock_govee_api.devices = [] + + with patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_govee_api, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_not_called() + + +async def test_creating_entry_has_with_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_govee_api: AsyncMock, +) -> None: + """Test setting up Govee with devices.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_govee_api, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py new file mode 100644 index 00000000000..63ffd7179a2 --- /dev/null +++ b/tests/components/govee_light_local/test_light.py @@ -0,0 +1,338 @@ +"""Test Govee light local.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from govee_local_api import GoveeDevice + +from homeassistant.components.govee_light_local.const import DOMAIN +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import DEFAULT_CAPABILITEIS + +from tests.common import MockConfigEntry + + +async def test_light_known_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert ColorMode.RGB in color_modes + assert ColorMode.BRIGHTNESS in color_modes + assert ColorMode.COLOR_TEMP in color_modes + + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None + + +async def test_light_unknown_device( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.101", + fingerprint="unkown_device", + sku="XYZK", + capabilities=None, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.XYZK") + assert light is not None + + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + + +async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None + + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_light_setup_retry( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.devices = [] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.govee_light_local.asyncio.timeout", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test adding a known device.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + + +async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with( + mock_govee_api.devices[0], 100 + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with( + mock_govee_api.devices[0], 100 + ) + + +async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test changing brightness.""" + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) From 639f06843b3816fc0f4dfe4c1604c53472fe9fe3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 20:57:23 +0100 Subject: [PATCH 0678/1544] Remove config import from surepetcare (#107971) Co-authored-by: jbouwh --- .../components/surepetcare/__init__.py | 80 +------------------ .../components/surepetcare/config_flow.py | 4 - tests/components/surepetcare/__init__.py | 12 --- tests/components/surepetcare/conftest.py | 23 ++++++ .../surepetcare/test_binary_sensor.py | 13 ++- tests/components/surepetcare/test_lock.py | 27 +++---- tests/components/surepetcare/test_sensor.py | 13 ++- 7 files changed, 49 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 9189ea38c00..0ef47a488df 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,25 +9,12 @@ from surepy.enums import EntityType, Location, LockState from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_TOKEN, - CONF_USERNAME, - Platform, -) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, -) +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -35,9 +22,6 @@ from .const import ( ATTR_LOCATION, ATTR_LOCK_STATE, ATTR_PET_NAME, - CONF_FEEDERS, - CONF_FLAPS, - CONF_PETS, DOMAIN, SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, @@ -49,66 +33,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=3) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_FEEDERS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_FLAPS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_PETS): vol.All( - cv.ensure_list, [cv.positive_int] - ), - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - }, - cv.deprecated(CONF_FEEDERS), - cv.deprecated(CONF_FLAPS), - cv.deprecated(CONF_PETS), - cv.deprecated(CONF_SCAN_INTERVAL), - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Sure Petcare integration.""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Sure Petcare", - }, - ) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 38bed2e20a9..81607b582c1 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -51,10 +51,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self._username: str | None = None - async def async_step_import(self, import_info: dict[str, Any] | None) -> FlowResult: - """Set the config entry up from yaml.""" - return await self.async_step_user(import_info) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py index 23a5830062e..9bf84889368 100644 --- a/tests/components/surepetcare/__init__.py +++ b/tests/components/surepetcare/__init__.py @@ -1,6 +1,4 @@ """Tests for Sure Petcare integration.""" -from homeassistant.components.surepetcare.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME HOUSEHOLD_ID = 987654321 HUB_ID = 123456789 @@ -82,13 +80,3 @@ MOCK_API_DATA = { "devices": [MOCK_HUB, MOCK_CAT_FLAP, MOCK_PET_FLAP, MOCK_FEEDER, MOCK_FELAQUA], "pets": [MOCK_PET], } - -MOCK_CONFIG = { - DOMAIN: { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - "feeders": [12345], - "flaps": [13579, 13576], - "pets": [24680], - }, -} diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py index dd1cd19aa0e..79c1b88d99b 100644 --- a/tests/components/surepetcare/conftest.py +++ b/tests/components/surepetcare/conftest.py @@ -4,8 +4,14 @@ from unittest.mock import patch import pytest from surepy import MESTART_RESOURCE +from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant + from . import MOCK_API_DATA +from tests.common import MockConfigEntry + async def _mock_call(method, resource): if method == "GET" and resource == MESTART_RESOURCE: @@ -21,3 +27,20 @@ async def surepetcare(): client.call = _mock_call client.get_token.return_value = "token" yield client + + +@pytest.fixture +async def mock_config_entry_setup(hass: HomeAssistant) -> MockConfigEntry: + """Help setting up a mocked config entry.""" + data = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "token", + "feeders": [12345], + "flaps": [13579, 13576], + "pets": [24680], + } + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + return entry diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py index 91677751e96..9f4018b4b65 100644 --- a/tests/components/surepetcare/test_binary_sensor.py +++ b/tests/components/surepetcare/test_binary_sensor.py @@ -1,10 +1,10 @@ """The tests for the Sure Petcare binary sensor platform.""" -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, HUB_ID, MOCK_CONFIG +from . import HOUSEHOLD_ID, HUB_ID + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "binary_sensor.pet_flap_connectivity": f"{HOUSEHOLD_ID}-13576-connectivity", @@ -15,11 +15,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_binary_sensors(hass: HomeAssistant, surepetcare) -> None: +async def test_binary_sensors( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py index 19e27bbe9a5..14a6a361793 100644 --- a/tests/components/surepetcare/test_lock.py +++ b/tests/components/surepetcare/test_lock.py @@ -2,12 +2,12 @@ import pytest from surepy.exceptions import SurePetcareError -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_CONFIG, MOCK_PET_FLAP +from . import HOUSEHOLD_ID, MOCK_CAT_FLAP, MOCK_PET_FLAP + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "lock.cat_flap_locked_in": f"{HOUSEHOLD_ID}-{MOCK_CAT_FLAP['id']}-locked_in", @@ -19,11 +19,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_locks(hass: HomeAssistant, surepetcare) -> None: +async def test_locks( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() @@ -78,11 +77,10 @@ async def test_locks(hass: HomeAssistant, surepetcare) -> None: assert surepetcare.unlock.call_count == 1 -async def test_lock_failing(hass: HomeAssistant, surepetcare) -> None: +async def test_lock_failing( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test handling of lock failing.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - surepetcare.lock_in.side_effect = SurePetcareError surepetcare.lock_out.side_effect = SurePetcareError surepetcare.lock.side_effect = SurePetcareError @@ -96,11 +94,10 @@ async def test_lock_failing(hass: HomeAssistant, surepetcare) -> None: assert state.state == "unlocked" -async def test_unlock_failing(hass: HomeAssistant, surepetcare) -> None: +async def test_unlock_failing( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test handling of unlock failing.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_id = list(EXPECTED_ENTITY_IDS)[0] await hass.services.async_call( diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index 219d23c0425..c0491908ca0 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -1,10 +1,10 @@ """Test the surepetcare sensor platform.""" -from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from . import HOUSEHOLD_ID, MOCK_CONFIG, MOCK_FELAQUA +from . import HOUSEHOLD_ID, MOCK_FELAQUA + +from tests.common import MockConfigEntry EXPECTED_ENTITY_IDS = { "sensor.pet_flap_battery_level": f"{HOUSEHOLD_ID}-13576-battery", @@ -14,11 +14,10 @@ EXPECTED_ENTITY_IDS = { } -async def test_sensors(hass: HomeAssistant, surepetcare) -> None: +async def test_sensors( + hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry +) -> None: """Test the generation of unique ids.""" - assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() From fa5af8f187a1b3cfaebb9438a55f96e535e0ff6a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 16 Jan 2024 15:00:20 -0500 Subject: [PATCH 0679/1544] Add Translation for Roborock exceptions (#105427) * add translations to exceptions * Make errors more user understandable * make command not ternary operator * removed non-user facing exceptions * Add user facing exceptions and code coverage * add match * fix linting * Apply suggestions from code review Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/roborock/__init__.py | 18 +++++++++++++--- homeassistant/components/roborock/device.py | 13 ++++++++++-- homeassistant/components/roborock/image.py | 6 +++++- .../components/roborock/strings.json | 17 +++++++++++++++ tests/components/roborock/test_vacuum.py | 21 +++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 25c1fbb041f..0b4dfa29e78 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -35,9 +35,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: home_data = await api_client.get_home_data(user_data) except RoborockInvalidCredentials as err: - raise ConfigEntryAuthFailed("Invalid credentials.") from err + raise ConfigEntryAuthFailed( + "Invalid credentials", + translation_domain=DOMAIN, + translation_key="invalid_credentials", + ) from err except RoborockException as err: - raise ConfigEntryNotReady("Failed getting Roborock home_data.") from err + raise ConfigEntryNotReady( + "Failed to get Roborock home data", + translation_domain=DOMAIN, + translation_key="home_data_fail", + ) from err _LOGGER.debug("Got home data %s", home_data) device_map: dict[str, HomeDataDevice] = { device.duid: device for device in home_data.devices + home_data.received_devices @@ -57,7 +65,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(coord, RoborockDataUpdateCoordinator) ] if len(valid_coordinators) == 0: - raise ConfigEntryNotReady("No coordinators were able to successfully setup.") + raise ConfigEntryNotReady( + "No devices were able to successfully setup", + translation_domain=DOMAIN, + translation_key="no_coordinators", + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { coordinator.roborock_device_info.device.duid: coordinator for coordinator in valid_coordinators diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 71376dd600e..17531f6c627 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator +from .const import DOMAIN class RoborockEntity(Entity): @@ -48,10 +49,18 @@ class RoborockEntity(Entity): try: response: dict = await self._api.send_command(command, params) except RoborockException as err: + if isinstance(command, RoborockCommand): + command_name = command.name + else: + command_name = command raise HomeAssistantError( - f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" + f"Error while calling {command}", + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": command_name, + }, ) from err - return response diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 5e61bb1d408..b2a14b57819 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -109,7 +109,11 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): """Create an image using the map parser.""" parsed_map = self.parser.parse(map_bytes) if parsed_map.image is None: - raise HomeAssistantError("Something went wrong creating the map.") + raise HomeAssistantError( + "Something went wrong creating the map", + translation_domain=DOMAIN, + translation_key="map_failure", + ) img_byte_arr = io.BytesIO() parsed_map.image.data.save(img_byte_arr, format="PNG") return img_byte_arr.getvalue() diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index b24b3501ed8..7c457a1935b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -276,5 +276,22 @@ } } } + }, + "exceptions": { + "command_failed": { + "message": "Error while calling {command}" + }, + "home_data_fail": { + "message": "Failed to get Roborock home data" + }, + "invalid_credentials": { + "message": "Invalid credentials." + }, + "map_failure": { + "message": "Something went wrong creating the map" + }, + "no_coordinators": { + "message": "No devices were able to successfully setup" + } } } diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index c8970517dce..ecc501cc542 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch import pytest +from roborock import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( @@ -19,6 +20,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -86,3 +88,22 @@ async def test_commands( assert mock_send_command.call_count == 1 assert mock_send_command.call_args[0][0] == command assert mock_send_command.call_args[0][1] == called_params + + +async def test_failed_user_command( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that when a user sends an invalid command, we raise HomeAssistantError.""" + data = {ATTR_ENTITY_ID: ENTITY_ID, **{"command": "fake_command"}} + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command", + side_effect=RoborockException(), + ), pytest.raises(HomeAssistantError, match="Error while calling fake_command"): + await hass.services.async_call( + Platform.VACUUM, + SERVICE_SEND_COMMAND, + data, + blocking=True, + ) From 6173bfe8737082191f7eb9e59f8111845654fba1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Jan 2024 10:05:44 -1000 Subject: [PATCH 0680/1544] Cache commonly called Integration manifest properties (#108141) --- homeassistant/loader.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a4f1a862c45..089a0b522fa 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -35,11 +35,16 @@ from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .util.json import JSON_DECODE_EXCEPTIONS, json_loads -# Typing imports that create a circular dependency if TYPE_CHECKING: + from functools import cached_property + + # The relative imports below are guarded by TYPE_CHECKING + # because they would cause a circular import otherwise. from .config_entries import ConfigEntry from .helpers import device_registry as dr from .helpers.typing import ConfigType +else: + from .backports.functools import cached_property _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -650,12 +655,12 @@ class Integration: _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) - @property + @cached_property def name(self) -> str: """Return name.""" return self.manifest["name"] - @property + @cached_property def disabled(self) -> str | None: """Return reason integration is disabled.""" return self.manifest.get("disabled") @@ -710,7 +715,7 @@ class Integration: """Return the integration IoT Class.""" return self.manifest.get("iot_class") - @property + @cached_property def integration_type( self, ) -> Literal["entity", "device", "hardware", "helper", "hub", "service", "system"]: From bc9a85405e48d8ee8a770613cd5fdf1fba4fe81b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 16 Jan 2024 21:06:58 +0100 Subject: [PATCH 0681/1544] Delete removed channel devices in Youtube (#107907) --- homeassistant/components/youtube/__init__.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index c62c533be06..46c3b0d1902 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN @@ -37,11 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = YouTubeDataUpdateCoordinator(hass, auth) await coordinator.async_config_entry_first_refresh() + + await delete_devices(hass, entry, coordinator) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { COORDINATOR: coordinator, AUTH: auth, } - await hass.config_entries.async_forward_entry_setups(entry, list(PLATFORMS)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,3 +56,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def delete_devices( + hass: HomeAssistant, entry: ConfigEntry, coordinator: YouTubeDataUpdateCoordinator +) -> None: + """Delete all devices created by integration.""" + channel_ids = list(coordinator.data) + device_registry = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for dev_entry in dev_entries: + if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) From ad35113e86717e9ee9fe0c3ba1d4dc40ce4d1790 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 21:08:05 +0100 Subject: [PATCH 0682/1544] Remove config import in Neato (#107967) Co-authored-by: jbouwh --- homeassistant/components/neato/__init__.py | 73 ++-------------------- homeassistant/components/neato/const.py | 1 - tests/components/neato/test_config_flow.py | 22 +++---- 3 files changed, 15 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index 196961652f0..b172d84533c 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -4,42 +4,19 @@ import logging import aiohttp from pybotvac import Account from pybotvac.exceptions import NeatoException -import voluptuous as vol -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import NEATO_CONFIG, NEATO_DOMAIN, NEATO_LOGIN +from .const import NEATO_DOMAIN, NEATO_LOGIN from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(NEATO_DOMAIN), - { - NEATO_DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, @@ -49,49 +26,9 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Neato component.""" - hass.data[NEATO_DOMAIN] = {} - - if NEATO_DOMAIN not in config: - return True - - hass.data[NEATO_CONFIG] = config[NEATO_DOMAIN] - await async_import_client_credential( - hass, - NEATO_DOMAIN, - ClientCredential( - config[NEATO_DOMAIN][CONF_CLIENT_ID], - config[NEATO_DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Neato integration in YAML is deprecated and " - "will be removed in a future release; Your existing OAuth " - "Application Credentials have been imported into the UI " - "automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{NEATO_DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=NEATO_DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": NEATO_DOMAIN, - "integration_title": "Neato Botvac", - }, - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" + hass.data.setdefault(NEATO_DOMAIN, {}) if CONF_TOKEN not in entry.data: raise ConfigEntryAuthFailed diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py index 0cd8fb932ce..4ec894179ea 100644 --- a/homeassistant/components/neato/const.py +++ b/homeassistant/components/neato/const.py @@ -3,7 +3,6 @@ NEATO_DOMAIN = "neato" CONF_VENDOR = "vendor" -NEATO_CONFIG = "neato_config" NEATO_LOGIN = "neato_login" NEATO_MAP_DATA = "neato_map_data" NEATO_PERSISTENT_MAPS = "neato_persistent_maps" diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 1e0ec5bb944..191721c2e74 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -4,6 +4,10 @@ from unittest.mock import patch from pybotvac.neato import Neato from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.neato.const import NEATO_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow @@ -27,12 +31,9 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "neato", - { - "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - }, + assert await setup.async_setup_component(hass, "neato", {}) + await async_import_client_credential( + hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( @@ -101,12 +102,9 @@ async def test_reauth( current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" - assert await setup.async_setup_component( - hass, - "neato", - { - "neato": {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, - }, + assert await setup.async_setup_component(hass, "neato", {}) + await async_import_client_credential( + hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) MockConfigEntry( From 60ab360fe721ae01a1c309e393052b9b6d8d453a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Jan 2024 10:37:34 -1000 Subject: [PATCH 0683/1544] Avoid bytes to string to bytes conversion in websocket api (#108139) --- homeassistant/components/api/__init__.py | 2 +- .../components/config/device_registry.py | 6 +- .../components/config/entity_registry.py | 18 +++--- .../components/history/websocket_api.py | 14 ++--- .../components/logbook/websocket_api.py | 16 ++--- .../components/recorder/websocket_api.py | 14 ++--- .../components/websocket_api/auth.py | 2 +- .../components/websocket_api/commands.py | 24 +++++--- .../components/websocket_api/connection.py | 4 +- .../components/websocket_api/http.py | 20 ++++--- .../components/websocket_api/messages.py | 60 ++++++++++++++----- homeassistant/core.py | 14 ++--- homeassistant/helpers/device_registry.py | 6 +- homeassistant/helpers/entity_registry.py | 10 ++-- .../components/websocket_api/test_messages.py | 12 ++-- tests/test_core.py | 8 +-- 16 files changed, 137 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 5e965cd370c..d380e0ce3ee 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -214,7 +214,7 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', + body=b"[" + b",".join(states) + b"]", content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 74c15da2f00..cbf3623e7a8 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -45,16 +45,16 @@ def websocket_list_devices( msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' f'"success":true,"result": [' - ) + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.json_repr for entry in registry.devices.values() if entry.json_repr is not None ) - + "]}" + + b"]}" ) connection.send_message(msg_json) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index a0e0d1877fa..f1c1fadc144 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -43,20 +43,23 @@ def websocket_list_entities( msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' '"success":true,"result": [' - ) + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.partial_json_repr for entry in registry.entities.values() if entry.partial_json_repr is not None ) - + "]}" + + b"]}" ) connection.send_message(msg_json) +_ENTITY_CATEGORIES_JSON = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) + + @websocket_api.websocket_command( {vol.Required("type"): "config/entity_registry/list_for_display"} ) @@ -69,20 +72,19 @@ def websocket_list_entities_for_display( """Handle list registry entries command.""" registry = er.async_get(hass) # Build start of response message - entity_categories = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) msg_json_prefix = ( f'{{"id":{msg["id"]},"type":"{websocket_api.const.TYPE_RESULT}","success":true,' - f'"result":{{"entity_categories":{entity_categories},"entities":[' - ) + f'"result":{{"entity_categories":{_ENTITY_CATEGORIES_JSON},"entities":[' + ).encode() # Concatenate cached entity registry item JSON serializations msg_json = ( msg_json_prefix - + ",".join( + + b",".join( entry.display_json_repr for entry in registry.entities.values() if entry.disabled_by is None and entry.display_json_repr is not None ) - + "]}}" + + b"]}}" ) connection.send_message(msg_json) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 5bc14cd4c02..25422004797 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -34,7 +34,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util @@ -72,9 +72,9 @@ def _ws_get_significant_states( significant_changes_only: bool, minimal_response: bool, no_attributes: bool, -) -> str: +) -> bytes: """Fetch history significant_states and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, history.get_significant_states( @@ -201,9 +201,9 @@ def _generate_websocket_response( start_time: dt, end_time: dt, states: MutableMapping[str, list[dict[str, Any]]], -) -> str: +) -> bytes: """Generate a websocket response.""" - return JSON_DUMP( + return json_bytes( messages.event_message( msg_id, _generate_stream_message(states, start_time, end_time) ) @@ -221,7 +221,7 @@ def _generate_historical_response( minimal_response: bool, no_attributes: bool, send_empty: bool, -) -> tuple[float, dt | None, str | None]: +) -> tuple[float, dt | None, bytes | None]: """Generate a historical response.""" states = cast( MutableMapping[str, list[dict[str, Any]]], @@ -346,7 +346,7 @@ async def _async_events_consumer( if history_states := _events_to_compressed_states(events, no_attributes): connection.send_message( - JSON_DUMP( + json_bytes( messages.event_message( msg_id, {"states": history_states}, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 4afa40cb14f..82124247adf 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes import homeassistant.util.dt as dt_util from .const import DOMAIN @@ -70,7 +70,7 @@ def _async_send_empty_response( stream_end_time = end_time or dt_util.utcnow() empty_stream_message = _generate_stream_message([], start_time, stream_end_time) empty_response = messages.event_message(msg_id, empty_stream_message) - connection.send_message(JSON_DUMP(empty_response)) + connection.send_message(json_bytes(empty_response)) async def _async_send_historical_events( @@ -165,7 +165,7 @@ async def _async_get_ws_stream_events( formatter: Callable[[int, Any], dict[str, Any]], event_processor: EventProcessor, partial: bool, -) -> tuple[str, dt | None]: +) -> tuple[bytes, dt | None]: """Async wrapper around _ws_formatted_get_events.""" return await get_instance(hass).async_add_executor_job( _ws_stream_get_events, @@ -196,7 +196,7 @@ def _ws_stream_get_events( formatter: Callable[[int, Any], dict[str, Any]], event_processor: EventProcessor, partial: bool, -) -> tuple[str, dt | None]: +) -> tuple[bytes, dt | None]: """Fetch events and convert them to json in the executor.""" events = event_processor.get_events(start_day, end_day) last_time = None @@ -209,7 +209,7 @@ def _ws_stream_get_events( # data in case the UI needs to show that historical # data is still loading in the future message["partial"] = True - return JSON_DUMP(formatter(msg_id, message)), last_time + return json_bytes(formatter(msg_id, message)), last_time async def _async_events_consumer( @@ -238,7 +238,7 @@ async def _async_events_consumer( async_event_to_row(e) for e in events ): connection.send_message( - JSON_DUMP( + json_bytes( messages.event_message( msg_id, {"events": logbook_events}, @@ -435,9 +435,9 @@ def _ws_formatted_get_events( start_time: dt, end_time: dt, event_processor: EventProcessor, -) -> str: +) -> bytes: """Fetch events and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, event_processor.get_events(start_time, end_time) ) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 733dafeba27..30c3bb31a47 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import messages from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DataRateConverter, @@ -97,9 +97,9 @@ def _ws_get_statistic_during_period( statistic_id: str, types: set[Literal["max", "mean", "min", "change"]] | None, units: dict[str, str], -) -> str: +) -> bytes: """Fetch statistics and convert them to json in the executor.""" - return JSON_DUMP( + return json_bytes( messages.result_message( msg_id, statistic_during_period( @@ -155,7 +155,7 @@ def _ws_get_statistics_during_period( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], -) -> str: +) -> bytes: """Fetch statistics and convert them to json in the executor.""" result = statistics_during_period( hass, @@ -174,7 +174,7 @@ def _ws_get_statistics_during_period( item["end"] = int(end * 1000) if (last_reset := item.get("last_reset")) is not None: item["last_reset"] = int(last_reset * 1000) - return JSON_DUMP(messages.result_message(msg_id, result)) + return json_bytes(messages.result_message(msg_id, result)) async def ws_handle_get_statistics_during_period( @@ -242,12 +242,12 @@ def _ws_get_list_statistic_ids( hass: HomeAssistant, msg_id: int, statistic_type: Literal["mean"] | Literal["sum"] | None = None, -) -> str: +) -> bytes: """Fetch a list of available statistic_id and convert them to JSON. Runs in the executor. """ - return JSON_DUMP( + return json_bytes( messages.result_message(msg_id, list_statistic_ids(hass, None, statistic_type)) ) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 2c86a26efc9..f09c2601328 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -57,7 +57,7 @@ class AuthPhase: self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], cancel_ws: CALLBACK_TYPE, request: Request, ) -> None: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 0edb6ad5261..32f59bd0c5f 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -104,7 +104,7 @@ def pong_message(iden: int) -> dict[str, Any]: @callback def _forward_events_check_permissions( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], user: User, msg_id: int, event: Event, @@ -124,7 +124,7 @@ def _forward_events_check_permissions( @callback def _forward_events_unconditional( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], msg_id: int, event: Event, ) -> None: @@ -352,17 +352,17 @@ def handle_get_states( def _send_handle_get_states_response( - connection: ActiveConnection, msg_id: int, serialized_states: list[str] + connection: ActiveConnection, msg_id: int, serialized_states: list[bytes] ) -> None: """Send handle get states response.""" connection.send_message( - construct_result_message(msg_id, f'[{",".join(serialized_states)}]') + construct_result_message(msg_id, b"[" + b",".join(serialized_states) + b"]") ) @callback def _forward_entity_changes( - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[str | bytes | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], user: User, msg_id: int, @@ -444,11 +444,19 @@ def handle_subscribe_entities( def _send_handle_entities_init_response( - connection: ActiveConnection, msg_id: int, serialized_states: list[str] + connection: ActiveConnection, msg_id: int, serialized_states: list[bytes] ) -> None: """Send handle entities init response.""" connection.send_message( - f'{{"id":{msg_id},"type":"event","event":{{"a":{{{",".join(serialized_states)}}}}}}}' + b"".join( + ( + b'{"id":', + str(msg_id).encode(), + b',"type":"event","event":{"a":{', + b",".join(serialized_states), + b"}}}", + ) + ) ) @@ -474,7 +482,7 @@ async def handle_get_services( ) -> None: """Handle get services command.""" payload = await _async_get_all_descriptions_json(hass) - connection.send_message(construct_result_message(msg["id"], payload)) + connection.send_message(construct_result_message(msg["id"], payload.encode())) @callback diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 25b6c90d1ba..e4540dfac35 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -51,7 +51,7 @@ class ActiveConnection: self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, refresh_token: RefreshToken, ) -> None: @@ -244,7 +244,7 @@ class ActiveConnection: @callback def _connect_closed_error( - self, msg: str | dict[str, Any] | Callable[[], str] + self, msg: bytes | str | dict[str, Any] | Callable[[], str] ) -> None: """Send a message when the connection is closed.""" self.logger.debug("Tried to send message %s on closed connection", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index f2f667368c3..c8c5d00cb2a 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -5,6 +5,7 @@ import asyncio from collections import deque from collections.abc import Callable import datetime as dt +from functools import partial import logging from typing import TYPE_CHECKING, Any, Final @@ -28,7 +29,7 @@ from .const import ( URL, ) from .error import Disconnect -from .messages import message_to_json +from .messages import message_to_json_bytes from .util import describe_request if TYPE_CHECKING: @@ -94,7 +95,7 @@ class WebSocketHandler: # to where messages are queued. This allows the implementation # to use a deque and an asyncio.Future to avoid the overhead of # an asyncio.Queue. - self._message_queue: deque[str | None] = deque() + self._message_queue: deque[bytes | None] = deque() self._ready_future: asyncio.Future[None] | None = None def __repr__(self) -> str: @@ -121,7 +122,10 @@ class WebSocketHandler: message_queue = self._message_queue logger = self._logger wsock = self._wsock - send_str = wsock.send_str + writer = wsock._writer # pylint: disable=protected-access + if TYPE_CHECKING: + assert writer is not None + send_str = partial(writer.send, binary=False) loop = self._hass.loop debug = logger.debug is_enabled_for = logger.isEnabledFor @@ -151,7 +155,7 @@ class WebSocketHandler: await send_str(message) continue - messages: list[str] = [message] + messages: list[bytes] = [message] while messages_remaining: # A None message is used to signal the end of the connection if (message := message_queue.popleft()) is None: @@ -159,7 +163,7 @@ class WebSocketHandler: messages.append(message) messages_remaining -= 1 - coalesced_messages = f'[{",".join(messages)}]' + coalesced_messages = b"".join((b"[", b",".join(messages), b"]")) if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) await send_str(coalesced_messages) @@ -181,7 +185,7 @@ class WebSocketHandler: self._peak_checker_unsub = None @callback - def _send_message(self, message: str | dict[str, Any]) -> None: + def _send_message(self, message: str | bytes | dict[str, Any]) -> None: """Send a message to the client. Closes connection if the client is not reading the messages. @@ -194,7 +198,9 @@ class WebSocketHandler: return if isinstance(message, dict): - message = message_to_json(message) + message = message_to_json_bytes(message) + elif isinstance(message, str): + message = message.encode("utf-8") message_queue = self._message_queue queue_size_before_add = len(message_queue) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 55144217fdc..3916cdd3af7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -16,7 +16,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data +from homeassistant.helpers.json import ( + JSON_DUMP, + find_paths_unserializable_data, + json_bytes, +) from homeassistant.util.json import format_unserializable_data from . import const @@ -44,7 +48,7 @@ BASE_ERROR_MESSAGE = { "success": False, } -INVALID_JSON_PARTIAL_MESSAGE = JSON_DUMP( +INVALID_JSON_PARTIAL_MESSAGE = json_bytes( { **BASE_ERROR_MESSAGE, "error": { @@ -60,9 +64,17 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} -def construct_result_message(iden: int, payload: str) -> str: +def construct_result_message(iden: int, payload: bytes) -> bytes: """Construct a success result message JSON.""" - return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' + return b"".join( + ( + b'{"id":', + str(iden).encode(), + b',"type":"result","success":true,"result":', + payload, + b"}", + ) + ) def error_message( @@ -96,7 +108,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) -> str: +def cached_event_message(iden: int, event: Event) -> bytes: """Return an event message. Serialize to json once per message. @@ -105,23 +117,30 @@ def cached_event_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return f'{_partial_cached_event_message(event)[:-1]},"id":{iden}}}' + return b"".join( + ( + _partial_cached_event_message(event)[:-1], + b',"id":', + str(iden).encode(), + b"}", + ) + ) @lru_cache(maxsize=128) -def _partial_cached_event_message(event: Event) -> str: +def _partial_cached_event_message(event: Event) -> bytes: """Cache and serialize the event to json. The message is constructed without the id which appended in cached_event_message. """ return ( - _message_to_json_or_none({"type": "event", "event": event.json_fragment}) + _message_to_json_bytes_or_none({"type": "event", "event": event.json_fragment}) or INVALID_JSON_PARTIAL_MESSAGE ) -def cached_state_diff_message(iden: int, event: Event) -> str: +def cached_state_diff_message(iden: int, event: Event) -> bytes: """Return an event message. Serialize to json once per message. @@ -130,18 +149,27 @@ def cached_state_diff_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return f'{_partial_cached_state_diff_message(event)[:-1]},"id":{iden}}}' + return b"".join( + ( + _partial_cached_state_diff_message(event)[:-1], + b',"id":', + str(iden).encode(), + b"}", + ) + ) @lru_cache(maxsize=128) -def _partial_cached_state_diff_message(event: Event) -> str: +def _partial_cached_state_diff_message(event: Event) -> bytes: """Cache and serialize the event to json. The message is constructed without the id which will be appended in cached_state_diff_message """ return ( - _message_to_json_or_none({"type": "event", "event": _state_diff_event(event)}) + _message_to_json_bytes_or_none( + {"type": "event", "event": _state_diff_event(event)} + ) or INVALID_JSON_PARTIAL_MESSAGE ) @@ -212,10 +240,10 @@ def _state_diff( return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} -def _message_to_json_or_none(message: dict[str, Any]) -> str | None: +def _message_to_json_bytes_or_none(message: dict[str, Any]) -> bytes | None: """Serialize a websocket message to json or return None.""" try: - return JSON_DUMP(message) + return json_bytes(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", @@ -226,9 +254,9 @@ def _message_to_json_or_none(message: dict[str, Any]) -> str | None: return None -def message_to_json(message: dict[str, Any]) -> str: +def message_to_json_bytes(message: dict[str, Any]) -> bytes: """Serialize a websocket message to json or return an error.""" - return _message_to_json_or_none(message) or JSON_DUMP( + return _message_to_json_bytes_or_none(message) or json_bytes( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/core.py b/homeassistant/core.py index e65273538a8..9c9ca335602 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -86,7 +86,7 @@ from .helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .helpers.json import json_dumps, json_fragment +from .helpers.json import json_bytes, json_fragment from .util import dt as dt_util, location from .util.async_ import ( cancelling, @@ -1039,7 +1039,7 @@ class Context: @cached_property def json_fragment(self) -> json_fragment: """Return a JSON fragment of the context.""" - return json_fragment(json_dumps(self._as_dict)) + return json_fragment(json_bytes(self._as_dict)) class EventOrigin(enum.Enum): @@ -1126,7 +1126,7 @@ class Event: @cached_property def json_fragment(self) -> json_fragment: """Return an event as a JSON fragment.""" - return json_fragment(json_dumps(self._as_dict)) + return json_fragment(json_bytes(self._as_dict)) def __repr__(self) -> str: """Return the representation.""" @@ -1512,9 +1512,9 @@ class State: return ReadOnlyDict(as_dict) @cached_property - def as_dict_json(self) -> str: + def as_dict_json(self) -> bytes: """Return a JSON string of the State.""" - return json_dumps(self._as_dict) + return json_bytes(self._as_dict) @cached_property def json_fragment(self) -> json_fragment: @@ -1550,14 +1550,14 @@ class State: return compressed_state @cached_property - def as_compressed_state_json(self) -> str: + def as_compressed_state_json(self) -> bytes: """Build a compressed JSON key value pair of a state for adds. The JSON string is a key value pair of the entity_id and the compressed state. It is used for sending multiple states in a single message. """ - return json_dumps({self.entity_id: self.as_compressed_state})[1:-1] + return json_bytes({self.entity_id: self.as_compressed_state})[1:-1] @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index cfe3b78ebab..52e779a3608 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -29,7 +29,7 @@ from .deprecation import ( dir_with_deprecated_constants, ) from .frame import report -from .json import JSON_DUMP, find_paths_unserializable_data +from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -277,11 +277,11 @@ class DeviceEntry: } @cached_property - def json_repr(self) -> str | None: + def json_repr(self) -> bytes | None: """Return a cached JSON representation of the entry.""" try: dict_repr = self.dict_repr - return JSON_DUMP(dict_repr) + return json_bytes(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 1f9da1969f2..049d136ed72 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -51,7 +51,7 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from .json import JSON_DUMP, find_paths_unserializable_data +from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -227,14 +227,14 @@ class RegistryEntry: return display_dict @cached_property - def display_json_repr(self) -> str | None: + def display_json_repr(self) -> bytes | None: """Return a cached partial JSON representation of the entry. This version only includes what's needed for display. """ try: dict_repr = self._as_display_dict - json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None + json_repr: bytes | None = json_bytes(dict_repr) if dict_repr else None return json_repr except (ValueError, TypeError): _LOGGER.error( @@ -282,11 +282,11 @@ class RegistryEntry: } @cached_property - def partial_json_repr(self) -> str | None: + def partial_json_repr(self) -> bytes | None: """Return a cached partial JSON representation of the entry.""" try: dict_repr = self.as_partial_dict - return JSON_DUMP(dict_repr) + return json_bytes(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 24387a89a29..5fc0e4ea315 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -5,7 +5,7 @@ from homeassistant.components.websocket_api.messages import ( _partial_cached_event_message as lru_event_cache, _state_diff_event, cached_event_message, - message_to_json, + message_to_json_bytes, ) from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Context, Event, HomeAssistant, State, callback @@ -282,18 +282,18 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: } -async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: +async def test_message_to_json_bytes(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" - json_str = message_to_json({"id": 1, "message": "xyz"}) + json_str = message_to_json_bytes({"id": 1, "message": "xyz"}) - assert json_str == '{"id":1,"message":"xyz"}' + assert json_str == b'{"id":1,"message":"xyz"}' - json_str2 = message_to_json({"id": 1, "message": _Unserializeable()}) + json_str2 = message_to_json_bytes({"id": 1, "message": _Unserializeable()}) assert ( json_str2 - == '{"id":1,"type":"result","success":false,"error":{"code":"unknown_error","message":"Invalid JSON in response"}}' + == b'{"id":1,"type":"result","success":false,"error":{"code":"unknown_error","message":"Invalid JSON in response"}}' ) assert "Unable to serialize to JSON" in caplog.text diff --git a/tests/test_core.py b/tests/test_core.py index c2a5a73e6ee..e6a1362a30e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -742,9 +742,9 @@ def test_state_as_dict_json() -> None: context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), ) expected = ( - '{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' - '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' - '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' + b'{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' + b'"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' + b'"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' ) as_dict_json_1 = state.as_dict_json assert as_dict_json_1 == expected @@ -852,7 +852,7 @@ def test_state_as_compressed_state_json() -> None: last_changed=last_time, context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), ) - expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' + expected = b'"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' as_compressed_state = state.as_compressed_state_json # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers From 0ba0f57439eae802d49f3e7a68d91f5cc6ce9dc5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 21:39:03 +0100 Subject: [PATCH 0684/1544] Add entity name translations to System Monitor (#107952) --- .../components/systemmonitor/sensor.py | 66 ++++++++++------ .../components/systemmonitor/strings.json | 76 +++++++++++++++++++ .../systemmonitor/snapshots/test_sensor.ambr | 30 ++++---- 3 files changed, 132 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 1a48e34d0e9..c7fdb4226d1 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -79,12 +79,14 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" mandatory_arg: bool = False + placeholder: str | None = None SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { "disk_free": SysMonitorSensorEntityDescription( key="disk_free", - name="Disk free", + translation_key="disk_free", + placeholder="mount_point", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -92,7 +94,8 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "disk_use": SysMonitorSensorEntityDescription( key="disk_use", - name="Disk use", + translation_key="disk_use", + placeholder="mount_point", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -100,49 +103,52 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "disk_use_percent": SysMonitorSensorEntityDescription( key="disk_use_percent", - name="Disk use (percent)", + translation_key="disk_use_percent", + placeholder="mount_point", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), "ipv4_address": SysMonitorSensorEntityDescription( key="ipv4_address", - name="IPv4 address", + translation_key="ipv4_address", + placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, ), "ipv6_address": SysMonitorSensorEntityDescription( key="ipv6_address", - name="IPv6 address", + translation_key="ipv6_address", + placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, ), "last_boot": SysMonitorSensorEntityDescription( key="last_boot", - name="Last boot", + translation_key="last_boot", device_class=SensorDeviceClass.TIMESTAMP, ), "load_15m": SysMonitorSensorEntityDescription( key="load_15m", - name="Load (15m)", + translation_key="load_15m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_1m": SysMonitorSensorEntityDescription( key="load_1m", - name="Load (1m)", + translation_key="load_1m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "load_5m": SysMonitorSensorEntityDescription( key="load_5m", - name="Load (5m)", + translation_key="load_5m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "memory_free": SysMonitorSensorEntityDescription( key="memory_free", - name="Memory free", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -150,7 +156,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "memory_use": SysMonitorSensorEntityDescription( key="memory_use", - name="Memory use", + translation_key="memory_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -158,14 +164,15 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "memory_use_percent": SysMonitorSensorEntityDescription( key="memory_use_percent", - name="Memory use (percent)", + translation_key="memory_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), "network_in": SysMonitorSensorEntityDescription( key="network_in", - name="Network in", + translation_key="network_in", + placeholder="interface", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:server-network", @@ -174,7 +181,8 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "network_out": SysMonitorSensorEntityDescription( key="network_out", - name="Network out", + translation_key="network_out", + placeholder="interface", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:server-network", @@ -183,21 +191,24 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "packets_in": SysMonitorSensorEntityDescription( key="packets_in", - name="Packets in", + translation_key="packets_in", + placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, ), "packets_out": SysMonitorSensorEntityDescription( key="packets_out", - name="Packets out", + translation_key="packets_out", + placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, ), "throughput_network_in": SysMonitorSensorEntityDescription( key="throughput_network_in", - name="Network throughput in", + translation_key="throughput_network_in", + placeholder="interface", native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -205,7 +216,8 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "throughput_network_out": SysMonitorSensorEntityDescription( key="throughput_network_out", - name="Network throughput out", + translation_key="throughput_network_out", + placeholder="interface", native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -213,27 +225,28 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "process": SysMonitorSensorEntityDescription( key="process", - name="Process", + translation_key="process", + placeholder="process", icon=get_cpu_icon(), mandatory_arg=True, ), "processor_use": SysMonitorSensorEntityDescription( key="processor_use", - name="Processor use", + translation_key="processor_use", native_unit_of_measurement=PERCENTAGE, icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, ), "processor_temperature": SysMonitorSensorEntityDescription( key="processor_temperature", - name="Processor temperature", + translation_key="processor_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), "swap_free": SysMonitorSensorEntityDescription( key="swap_free", - name="Swap free", + translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -241,7 +254,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "swap_use": SysMonitorSensorEntityDescription( key="swap_use", - name="Swap use", + translation_key="swap_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", @@ -249,7 +262,7 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { ), "swap_use_percent": SysMonitorSensorEntityDescription( key="swap_use_percent", - name="Swap use (percent)", + translation_key="swap_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, @@ -587,7 +600,10 @@ class SystemMonitorSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = sensor_description - self._attr_name: str = f"{sensor_description.name} {argument}".rstrip() + if self.entity_description.placeholder: + self._attr_translation_placeholders = { + self.entity_description.placeholder: argument + } self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 88ecad4b107..ff1fbc221ee 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -21,5 +21,81 @@ } } } + }, + "entity": { + "sensor": { + "disk_free": { + "name": "Disk free {mount_point}" + }, + "disk_use": { + "name": "Disk use {mount_point}" + }, + "disk_use_percent": { + "name": "Disk usage {mount_point}" + }, + "ipv4_address": { + "name": "IPv4 address {ip_address}" + }, + "ipv6_address": { + "name": "IPv6 address {ip_address}" + }, + "last_boot": { + "name": "Last boot" + }, + "load_15m": { + "name": "Load (15m)" + }, + "load_1m": { + "name": "Load (1m)" + }, + "load_5m": { + "name": "Load (5m)" + }, + "memory_free": { + "name": "Memory free" + }, + "memory_use": { + "name": "Memory use" + }, + "memory_use_percent": { + "name": "Memory usage" + }, + "network_in": { + "name": "Network in {interface}" + }, + "network_out": { + "name": "Network out {interface}" + }, + "packets_in": { + "name": "Packets in {interface}" + }, + "packets_out": { + "name": "Packets out {interface}" + }, + "throughput_network_in": { + "name": "Network throughput in {interface}" + }, + "throughput_network_out": { + "name": "Network throughput out {interface}" + }, + "process": { + "name": "Process {process}" + }, + "processor_use": { + "name": "Processor use" + }, + "processor_temperature": { + "name": "Processor temperature" + }, + "swap_free": { + "name": "Swap free" + }, + "swap_use": { + "name": "Swap use" + }, + "swap_use_percent": { + "name": "Swap usage" + } + } } } diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index be32e1f54ef..d39b23c8107 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -23,37 +23,37 @@ # name: test_sensor[System Monitor Disk free /media/share - state] '0.2' # --- -# name: test_sensor[System Monitor Disk use (percent) / - attributes] +# name: test_sensor[System Monitor Disk usage / - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Disk use (percent) /', + 'friendly_name': 'System Monitor Disk usage /', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk use (percent) / - state] +# name: test_sensor[System Monitor Disk usage / - state] '60.0' # --- -# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - attributes] +# name: test_sensor[System Monitor Disk usage /home/notexist/ - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Disk use (percent) /home/notexist/', + 'friendly_name': 'System Monitor Disk usage /home/notexist/', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk use (percent) /home/notexist/ - state] +# name: test_sensor[System Monitor Disk usage /home/notexist/ - state] '60.0' # --- -# name: test_sensor[System Monitor Disk use (percent) /media/share - attributes] +# name: test_sensor[System Monitor Disk usage /media/share - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Disk use (percent) /media/share', + 'friendly_name': 'System Monitor Disk usage /media/share', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk use (percent) /media/share - state] +# name: test_sensor[System Monitor Disk usage /media/share - state] '60.0' # --- # name: test_sensor[System Monitor Disk use / - attributes] @@ -167,15 +167,15 @@ # name: test_sensor[System Monitor Memory free - state] '40.0' # --- -# name: test_sensor[System Monitor Memory use (percent) - attributes] +# name: test_sensor[System Monitor Memory usage - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Memory use (percent)', + 'friendly_name': 'System Monitor Memory usage', 'icon': 'mdi:memory', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Memory use (percent) - state] +# name: test_sensor[System Monitor Memory usage - state] '40.0' # --- # name: test_sensor[System Monitor Memory use - attributes] @@ -374,15 +374,15 @@ # name: test_sensor[System Monitor Swap free - state] '40.0' # --- -# name: test_sensor[System Monitor Swap use (percent) - attributes] +# name: test_sensor[System Monitor Swap usage - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Swap use (percent)', + 'friendly_name': 'System Monitor Swap usage', 'icon': 'mdi:harddisk', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Swap use (percent) - state] +# name: test_sensor[System Monitor Swap usage - state] '60.0' # --- # name: test_sensor[System Monitor Swap use - attributes] From 0a758882e1c22ecd5a7a286858699f3b9041ad42 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 16 Jan 2024 22:07:22 +0100 Subject: [PATCH 0685/1544] Deprecate Python 3.11 (#108160) --- homeassistant/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a6c8cfa0405..86e4e4bcda1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -20,9 +20,9 @@ PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2024.4" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" From 7dffc9f51521d2e9c6f2d019d42e29b51ea63153 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Jan 2024 22:17:18 +0100 Subject: [PATCH 0686/1544] Remove config import from netatmo (#107972) * Remove config import from netatmo * Fix tests --- homeassistant/components/netatmo/__init__.py | 63 +------------------- tests/components/netatmo/conftest.py | 20 +++++++ tests/components/netatmo/test_config_flow.py | 24 +------- 3 files changed, 26 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 4535805915b..c514e7b890d 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,26 +8,16 @@ from typing import Any import aiohttp import pyatmo -import voluptuous as vol from homeassistant.components import cloud -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.webhook import ( async_generate_url as webhook_generate_url, async_register as webhook_register, async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -36,7 +26,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType @@ -61,20 +50,7 @@ from .webhook import async_handle_webhook _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) MAX_WEBHOOK_RETRIES = 3 @@ -90,39 +66,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_CAMERAS: {}, } - if DOMAIN not in config: - return True - - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - ), - ) - _LOGGER.warning( - "Configuration of Netatmo integration in YAML is deprecated and " - "will be removed in a future release; Your existing configuration " - "(including OAuth Application Credentials) have been imported into " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" - ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Netatmo", - }, - ) - return True diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index bfd7fa6a072..a21bb8aebe7 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -5,12 +5,32 @@ from unittest.mock import AsyncMock, patch from pyatmo.const import ALL_SCOPES import pytest +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.netatmo.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .common import fake_get_image, fake_post_request from tests.common import MockConfigEntry +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + @pytest.fixture(name="config_entry") def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 56d319b1631..afa9ed02645 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from pyatmo.const import ALL_SCOPES -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( @@ -14,17 +14,15 @@ from homeassistant.components.netatmo.const import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from .conftest import CLIENT_ID + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - VALID_CONFIG = {} @@ -65,14 +63,6 @@ async def test_full_flow( current_request_with_host: None, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - "netatmo", - { - "netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_USER} @@ -240,14 +230,6 @@ async def test_reauth( current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" - assert await setup.async_setup_component( - hass, - "netatmo", - { - "netatmo": {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) result = await hass.config_entries.flow.async_init( "netatmo", context={"source": config_entries.SOURCE_USER} From db81f4d04661067d550439bd0f6e19e92eb54c4a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 16 Jan 2024 15:43:30 -0600 Subject: [PATCH 0687/1544] Wyoming satellite ping and bugfix for local wake word (#108164) * Refactor with ping * Fix tests * Increase test coverage --- .../components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/satellite.py | 251 +++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/test_satellite.py | 487 +++++++++++++++++- 5 files changed, 627 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 7174683fd18..430e46fd890 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.4.0"], + "requirements": ["wyoming==1.5.0"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 78f57ff4b01..8e7586534f5 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -10,6 +10,7 @@ from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from wyoming.error import Error +from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.tts import Synthesize, SynthesizeVoice @@ -29,6 +30,9 @@ _LOGGER = logging.getLogger() _SAMPLES_PER_CHUNK: Final = 1024 _RECONNECT_SECONDS: Final = 10 _RESTART_SECONDS: Final = 3 +_PING_TIMEOUT: Final = 5 +_PING_SEND_DELAY: Final = 2 +_PIPELINE_FINISH_TIMEOUT: Final = 1 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -54,6 +58,7 @@ class WyomingSatellite: self._client: AsyncTcpClient | None = None self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) self._is_pipeline_running = False + self._pipeline_ended_event = asyncio.Event() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None self._muted_changed_event = asyncio.Event() @@ -77,9 +82,9 @@ class WyomingSatellite: return # Connect and run pipeline loop - await self._run_once() + await self._connect_and_loop() except asyncio.CancelledError: - raise + raise # don't restart except Exception: # pylint: disable=broad-exception-caught await self.on_restart() finally: @@ -142,8 +147,8 @@ class WyomingSatellite: # Cancel any running pipeline self._audio_queue.put_nowait(None) - async def _run_once(self) -> None: - """Run pipelines until an error occurs.""" + async def _connect_and_loop(self) -> None: + """Connect to satellite and run pipelines until an error occurs.""" self.device.set_is_active(False) while self.is_running and (not self.device.is_muted): @@ -163,27 +168,94 @@ class WyomingSatellite: # Tell satellite that we're ready await self._client.write_event(RunSatellite().event()) - # Wait until we get RunPipeline event - run_pipeline: RunPipeline | None = None + # Run until stopped or muted while self.is_running and (not self.device.is_muted): - run_event = await self._client.read_event() - if run_event is None: - raise ConnectionResetError("Satellite disconnected") + await self._run_pipeline_loop() - if RunPipeline.is_type(run_event.type): - run_pipeline = RunPipeline.from_event(run_event) - break + async def _run_pipeline_loop(self) -> None: + """Run a pipeline one or more times.""" + assert self._client is not None + run_pipeline: RunPipeline | None = None + send_ping = True - _LOGGER.debug("Unexpected event from satellite: %s", run_event) + # Read events and check for pipeline end in parallel + pipeline_ended_task = self.hass.async_create_background_task( + self._pipeline_ended_event.wait(), "satellite pipeline ended" + ) + client_event_task = self.hass.async_create_background_task( + self._client.read_event(), "satellite event read" + ) + pending = {pipeline_ended_task, client_event_task} - assert run_pipeline is not None + while self.is_running and (not self.device.is_muted): + if send_ping: + # Ensure satellite is still connected + send_ping = False + self.hass.async_create_background_task( + self._send_delayed_ping(), "ping satellite" + ) + + async with asyncio.timeout(_PING_TIMEOUT): + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + if pipeline_ended_task in done: + # Pipeline run end event was received + _LOGGER.debug("Pipeline finished") + self._pipeline_ended_event.clear() + pipeline_ended_task = self.hass.async_create_background_task( + self._pipeline_ended_event.wait(), "satellite pipeline ended" + ) + pending.add(pipeline_ended_task) + + if (run_pipeline is not None) and run_pipeline.restart_on_end: + # Automatically restart pipeline. + # Used with "always on" streaming satellites. + self._run_pipeline_once(run_pipeline) + continue + + if client_event_task not in done: + continue + + client_event = client_event_task.result() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if Pong.is_type(client_event.type): + # Satellite is still there, send next ping + send_ping = True + elif Ping.is_type(client_event.type): + # Respond to ping from satellite + ping = Ping.from_event(client_event) + await self._client.write_event(Pong(text=ping.text).event()) + elif RunPipeline.is_type(client_event.type): + # Satellite requested pipeline run + run_pipeline = RunPipeline.from_event(client_event) + self._run_pipeline_once(run_pipeline) + elif ( + AudioChunk.is_type(client_event.type) and self._is_pipeline_running + ): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + elif AudioStop.is_type(client_event.type) and self._is_pipeline_running: + # Stop pipeline + _LOGGER.debug("Client requested pipeline to stop") + self._audio_queue.put_nowait(b"") + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + # Next event + client_event_task = self.hass.async_create_background_task( + self._client.read_event(), "satellite event read" + ) + pending.add(client_event_task) + + def _run_pipeline_once(self, run_pipeline: RunPipeline) -> None: + """Run a pipeline once.""" _LOGGER.debug("Received run information: %s", run_pipeline) - if (not self.is_running) or self.device.is_muted: - # Run was cancelled or satellite was disabled while waiting for - # RunPipeline event. - return - start_stage = _STAGES.get(run_pipeline.start_stage) end_stage = _STAGES.get(run_pipeline.end_stage) @@ -193,79 +265,64 @@ class WyomingSatellite: if end_stage is None: raise ValueError(f"Invalid end stage: {end_stage}") - # Each loop is a pipeline run - while self.is_running and (not self.device.is_muted): - # Use select to get pipeline each time in case it's changed - pipeline_id = pipeline_select.get_chosen_pipeline( + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + self._pipeline_ended_event.clear() + self.hass.async_create_background_task( + assist_pipeline.async_pipeline_from_audio_stream( self.hass, - DOMAIN, - self.device.satellite_id, - ) - pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) - assert pipeline is not None + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), + device_id=self.device.device_id, + ), + name="wyoming satellite pipeline", + ) - # We will push audio in through a queue - self._audio_queue = asyncio.Queue() - stt_stream = self._stt_stream() + async def _send_delayed_ping(self) -> None: + """Send ping to satellite after a delay.""" + assert self._client is not None - # Start pipeline running - _LOGGER.debug( - "Starting pipeline %s from %s to %s", - pipeline.name, - start_stage, - end_stage, - ) - self._is_pipeline_running = True - _pipeline_task = asyncio.create_task( - assist_pipeline.async_pipeline_from_audio_stream( - self.hass, - context=Context(), - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language=pipeline.language, - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=stt_stream, - start_stage=start_stage, - end_stage=end_stage, - tts_audio_output="wav", - pipeline_id=pipeline_id, - audio_settings=assist_pipeline.AudioSettings( - noise_suppression_level=self.device.noise_suppression_level, - auto_gain_dbfs=self.device.auto_gain, - volume_multiplier=self.device.volume_multiplier, - ), - device_id=self.device.device_id, - ) - ) - - # Run until pipeline is complete or cancelled with an empty audio chunk - while self._is_pipeline_running: - client_event = await self._client.read_event() - if client_event is None: - raise ConnectionResetError("Satellite disconnected") - - if AudioChunk.is_type(client_event.type): - # Microphone audio - chunk = AudioChunk.from_event(client_event) - chunk = self._chunk_converter.convert(chunk) - self._audio_queue.put_nowait(chunk.audio) - elif AudioStop.is_type(client_event.type): - # Stop pipeline - _LOGGER.debug("Client requested pipeline to stop") - self._audio_queue.put_nowait(b"") - break - else: - _LOGGER.debug("Unexpected event from satellite: %s", client_event) - - # Ensure task finishes - await _pipeline_task - - _LOGGER.debug("Pipeline finished") + try: + await asyncio.sleep(_PING_SEND_DELAY) + await self._client.write_event(Ping().event()) + except ConnectionError: + pass # handled with timeout def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: """Translate pipeline events into Wyoming events.""" @@ -274,6 +331,7 @@ class WyomingSatellite: if event.type == assist_pipeline.PipelineEventType.RUN_END: # Pipeline run is complete self._is_pipeline_running = False + self._pipeline_ended_event.set() self.device.set_is_active(False) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.hass.add_job(self._client.write_event(Detect().event())) @@ -413,10 +471,13 @@ class WyomingSatellite: async def _stt_stream(self) -> AsyncGenerator[bytes, None]: """Yield audio chunks from a queue.""" - is_first_chunk = True - while chunk := await self._audio_queue.get(): - if is_first_chunk: - is_first_chunk = False - _LOGGER.debug("Receiving audio from satellite") + try: + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") - yield chunk + yield chunk + except asyncio.CancelledError: + pass # ignore diff --git a/requirements_all.txt b/requirements_all.txt index d58a208774a..d7df00ae9b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2821,7 +2821,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.4.0 +wyoming==1.5.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4cc6cea4b0c..b6862948c0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2141,7 +2141,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.4.0 +wyoming==1.5.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 07a6aa8925e..b6564afcfe9 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import io +from typing import Any from unittest.mock import patch import wave @@ -10,6 +12,7 @@ from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.error import Error from wyoming.event import Event +from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.tts import Synthesize @@ -100,6 +103,12 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.error_event = asyncio.Event() self.error: Error | None = None + self.pong_event = asyncio.Event() + self.pong: Pong | None = None + + self.ping_event = asyncio.Event() + self.ping: Ping | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -142,6 +151,12 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): elif Error.is_type(event.type): self.error = Error.from_event(event) self.error_event.set() + elif Pong.is_type(event.type): + self.pong = Pong.from_event(event) + self.pong_event.set() + elif Ping.is_type(event.type): + self.ping = Ping.from_event(event) + self.ping_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -150,6 +165,10 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): # Keep sending audio chunks instead of None return event or self._mic_audio_chunk + def inject_event(self, event: Event) -> None: + """Put an event in as the next response.""" + self.responses = [event] + self.responses + async def test_satellite_pipeline(hass: HomeAssistant) -> None: """Test running a pipeline with a satellite.""" @@ -157,10 +176,37 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: events = [ RunPipeline( - start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, ).event(), ] + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + audio_chunk_received = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for chunk in stt_stream: + if chunk: + audio_chunk_received.set() + break + with patch( "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, @@ -169,10 +215,11 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: SatelliteAsyncTcpClient(events), ) as mock_client, patch( "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", - ) as mock_run_pipeline, patch( + async_pipeline_from_audio_stream, + ), patch( "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", return_value=("wav", get_test_wav()), - ): + ), patch("homeassistant.components.wyoming.satellite._PING_SEND_DELAY", 0): entry = await setup_config_entry(hass) device: SatelliteDevice = hass.data[wyoming.DOMAIN][ entry.entry_id @@ -182,12 +229,39 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await mock_client.connect_event.wait() await mock_client.run_satellite_event.wait() - mock_run_pipeline.assert_called_once() - event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] - assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + # Test a ping + mock_client.inject_event(Ping("test-ping").event()) + + # Pong is expected with the same text + async with asyncio.timeout(1): + await mock_client.pong_event.wait() + + assert mock_client.pong is not None + assert mock_client.pong.text == "test-ping" + + # The client should have received the first ping + async with asyncio.timeout(1): + await mock_client.ping_event.wait() + + assert mock_client.ping is not None + + # Reset and send a pong back. + # We will get a second ping by the end of the test. + mock_client.ping_event.clear() + mock_client.ping = None + mock_client.inject_event(Pong().event()) # Start detecting wake word - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.WAKE_WORD_START ) @@ -198,8 +272,13 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert not device.is_active assert not device.is_muted + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + # Wake word is detected - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.WAKE_WORD_END, {"wake_word_output": {"wake_word_id": "test_wake_word"}}, @@ -215,7 +294,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert device.is_active # Speech-to-text started - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_START, {"metadata": {"language": "en"}}, @@ -227,8 +306,13 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.transcribe is not None assert mock_client.transcribe.language == "en" + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + # User started speaking - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} ) @@ -240,7 +324,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.voice_started.timestamp == 1234 # User stopped speaking - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} ) @@ -252,7 +336,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.voice_stopped.timestamp == 5678 # Speech-to-text transcription - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.STT_END, {"stt_output": {"text": "test transcript"}}, @@ -265,7 +349,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.transcript.text == "test transcript" # Text-to-speech text - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_START, { @@ -283,7 +367,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.synthesize.voice.name == "test voice" # Text-to-speech media - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, {"tts_output": {"media_id": "test media id"}}, @@ -302,11 +386,21 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.tts_audio_chunk.audio == b"123" # Pipeline finished - event_callback( + pipeline_event_callback( assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) ) assert not device.is_active + # The client should have received another ping by now + async with asyncio.timeout(1): + await mock_client.ping_event.wait() + + assert mock_client.ping is not None + + # Pipeline should automatically restart + async with asyncio.timeout(1): + await run_pipeline_called.wait() + # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -317,6 +411,7 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: on_muted_event = asyncio.Event() original_make_satellite = wyoming._make_satellite + original_on_muted = wyoming.satellite.WyomingSatellite.on_muted def make_muted_satellite( hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService @@ -327,6 +422,14 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: return satellite async def on_muted(self): + # Trigger original function + self._muted_changed_event.set() + await original_on_muted(self) + + # Ensure satellite stops + self.is_running = False + + # Proceed with test self.device.set_is_muted(False) on_muted_event.set() @@ -339,16 +442,23 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", on_muted, ): - await setup_config_entry(hass) + entry = await setup_config_entry(hass) async with asyncio.timeout(1): await on_muted_event.wait() + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + async def test_satellite_restart(hass: HomeAssistant) -> None: """Test pipeline loop restart after unexpected error.""" on_restart_event = asyncio.Event() + original_on_restart = wyoming.satellite.WyomingSatellite.on_restart + async def on_restart(self): + await original_on_restart(self) self.stop() on_restart_event.set() @@ -356,12 +466,12 @@ async def test_satellite_restart(hass: HomeAssistant) -> None: "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, ), patch( - "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + "homeassistant.components.wyoming.satellite.WyomingSatellite._connect_and_loop", side_effect=RuntimeError(), ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", on_restart, - ): + ), patch("homeassistant.components.wyoming.satellite._RESTART_SECONDS", 0): await setup_config_entry(hass) async with asyncio.timeout(1): await on_restart_event.wait() @@ -373,7 +483,11 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None: reconnect_event = asyncio.Event() stopped_event = asyncio.Event() + original_on_reconnect = wyoming.satellite.WyomingSatellite.on_reconnect + async def on_reconnect(self): + await original_on_reconnect(self) + nonlocal num_reconnects num_reconnects += 1 if num_reconnects >= 2: @@ -395,7 +509,7 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", on_stopped, - ): + ), patch("homeassistant.components.wyoming.satellite._RECONNECT_SECONDS", 0): await setup_config_entry(hass) async with asyncio.timeout(1): await reconnect_event.wait() @@ -519,3 +633,338 @@ async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: assert mock_client.error is not None assert mock_client.error.text == "test message" assert mock_client.error.code == "test code" + + +async def test_tts_not_wav(hass: HomeAssistant) -> None: + """Test satellite receiving non-WAV audio from text-to-speech.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + original_stream_tts = wyoming.satellite.WyomingSatellite._stream_tts + error_event = asyncio.Event() + + async def _stream_tts(self, media_id): + try: + await original_stream_tts(self, media_id) + except ValueError: + error_event.set() + + events = [ + RunPipeline(start_stage=PipelineStage.TTS, end_stage=PipelineStage.TTS).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("mp3", bytes(1)), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._stream_tts", + _stream_tts, + ): + entry = await setup_config_entry(hass) + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + + # Expect error because only WAV is supported + async with asyncio.timeout(1): + await error_event.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_pipeline_changed(hass: HomeAssistant) -> None: + """Test that changing the pipeline setting stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Change pipelines + device.set_pipeline_name("different pipeline") + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_audio_settings_changed(hass: HomeAssistant) -> None: + """Test that changing audio settings stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Change audio setting + device.set_noise_suppression_level(1) + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_invalid_stages(hass: HomeAssistant) -> None: + """Test error when providing invalid pipeline stages.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + original_run_pipeline_once = wyoming.satellite.WyomingSatellite._run_pipeline_once + start_stage_event = asyncio.Event() + end_stage_event = asyncio.Event() + + def _run_pipeline_once(self, run_pipeline): + # Set bad start stage + run_pipeline.start_stage = PipelineStage.INTENT + run_pipeline.end_stage = PipelineStage.TTS + + try: + original_run_pipeline_once(self, run_pipeline) + except ValueError: + start_stage_event.set() + + # Set bad end stage + run_pipeline.start_stage = PipelineStage.WAKE + run_pipeline.end_stage = PipelineStage.INTENT + + try: + original_run_pipeline_once(self, run_pipeline) + except ValueError: + end_stage_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_pipeline_once", + _run_pipeline_once, + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + async with asyncio.timeout(1): + await start_stage_event.wait() + await end_stage_event.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_client_stops_pipeline(hass: HomeAssistant) -> None: + """Test that an AudioStop message stops the current pipeline.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + pipeline_event_callback: Callable[ + [assist_pipeline.PipelineEvent], None + ] | None = None + run_pipeline_called = asyncio.Event() + pipeline_stopped = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for _chunk in stt_stream: + pass + + pipeline_stopped.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Pipeline has started + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Client sends stop message + mock_client.inject_event(AudioStop().event()) + + # Running pipeline should be cancelled + async with asyncio.timeout(1): + await pipeline_stopped.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 0f185a9a0958ff064769005d943bccf7167d0f5c Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 16 Jan 2024 22:46:00 +0100 Subject: [PATCH 0688/1544] Set minimal value for modules power - Forecast.solar (#108166) --- homeassistant/components/forecast_solar/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 47e1afaec7b..6066c85e74e 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -75,7 +75,9 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_AZIMUTH, default=180): vol.All( vol.Coerce(int), vol.Range(min=0, max=360) ), - vol.Required(CONF_MODULES_POWER): vol.Coerce(int), + vol.Required(CONF_MODULES_POWER): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), } ), ) @@ -126,7 +128,7 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): vol.Required( CONF_MODULES_POWER, default=self.config_entry.options[CONF_MODULES_POWER], - ): vol.Coerce(int), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional( CONF_DAMPING_MORNING, default=self.config_entry.options.get( From 030b1bc0e8e92bff8f893dc238b60b607a5c4b0b Mon Sep 17 00:00:00 2001 From: Cyrille <2franix@users.noreply.github.com> Date: Tue, 16 Jan 2024 23:01:01 +0100 Subject: [PATCH 0689/1544] Upgrade python-mpd2 to v3.1.1 (#108143) --- homeassistant/components/mpd/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mpd/manifest.json b/homeassistant/components/mpd/manifest.json index dc88443fb51..e03005fb95a 100644 --- a/homeassistant/components/mpd/manifest.json +++ b/homeassistant/components/mpd/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/mpd", "iot_class": "local_polling", "loggers": ["mpd"], - "requirements": ["python-mpd2==3.0.5"] + "requirements": ["python-mpd2==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d7df00ae9b7..5782c721f2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2225,7 +2225,7 @@ python-matter-server==5.1.1 python-miio==0.5.12 # homeassistant.components.mpd -python-mpd2==3.0.5 +python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.2.0 From d82abd93fbd6715d8a4fbaf1c83f321c58d9e0db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Jan 2024 12:15:19 -1000 Subject: [PATCH 0690/1544] Bump dbus-fast to 2.21.1 (#108176) --- 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 9dfa4b84bb8..c75524c8b3a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bluetooth-adapters==0.17.0", "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", - "dbus-fast==2.21.0", + "dbus-fast==2.21.1", "habluetooth==2.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6cef233d904..1c9876c6499 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.21.0 +dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5782c721f2d..efb18aff542 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -669,7 +669,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.21.0 +dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6862948c0f..324cabf35bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,7 +550,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.21.0 +dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 From 25f4fe4a8544392bcf74297cecd5625181ad0bcd Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 15:16:31 -0700 Subject: [PATCH 0691/1544] Bump `aiokafka` to 0.10.0 (#108165) --- homeassistant/components/apache_kafka/__init__.py | 11 +---------- homeassistant/components/apache_kafka/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/apache_kafka/conftest.py | 5 ----- tests/components/apache_kafka/test_init.py | 4 ++-- 6 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 tests/components/apache_kafka/conftest.py diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index d909fb9f51f..c49d2954424 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -3,9 +3,9 @@ from __future__ import annotations from datetime import datetime import json -import sys from typing import Any, Literal +from aiokafka import AIOKafkaProducer import voluptuous as vol from homeassistant.const import ( @@ -19,17 +19,12 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util import ssl as ssl_util -if sys.version_info < (3, 12): - from aiokafka import AIOKafkaProducer - - DOMAIN = "apache_kafka" CONF_FILTER = "filter" @@ -58,10 +53,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate the Apache Kafka integration.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Apache Kafka is not supported on Python 3.12. Please use Python 3.11." - ) conf = config[DOMAIN] kafka = hass.data[DOMAIN] = KafkaManager( diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json index 11cb0ece7ac..f6593631bc0 100644 --- a/homeassistant/components/apache_kafka/manifest.json +++ b/homeassistant/components/apache_kafka/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apache_kafka", "iot_class": "local_push", "loggers": ["aiokafka", "kafka_python"], - "requirements": ["aiokafka==0.7.2"] + "requirements": ["aiokafka==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index efb18aff542..efec679a17f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiohue==4.7.0 aioimaplib==1.0.1 # homeassistant.components.apache_kafka -aiokafka==0.7.2 +aiokafka==0.10.0 # homeassistant.components.kef aiokef==0.2.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324cabf35bb..6c0049de465 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohue==4.7.0 aioimaplib==1.0.1 # homeassistant.components.apache_kafka -aiokafka==0.7.2 +aiokafka==0.10.0 # homeassistant.components.lifx aiolifx-effects==0.3.2 diff --git a/tests/components/apache_kafka/conftest.py b/tests/components/apache_kafka/conftest.py deleted file mode 100644 index 9391ccdd380..00000000000 --- a/tests/components/apache_kafka/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/apache_kafka/test_init.py b/tests/components/apache_kafka/test_init.py index 3c7da1be48d..2f8b035cda9 100644 --- a/tests/components/apache_kafka/test_init.py +++ b/tests/components/apache_kafka/test_init.py @@ -62,7 +62,7 @@ async def test_minimal_config( config = {apache_kafka.DOMAIN: MIN_CONFIG} assert await async_setup_component(hass, apache_kafka.DOMAIN, config) await hass.async_block_till_done() - assert mock_client.start.called_once + mock_client.start.assert_called_once() async def test_full_config(hass: HomeAssistant, mock_client: MockKafkaClient) -> None: @@ -83,7 +83,7 @@ async def test_full_config(hass: HomeAssistant, mock_client: MockKafkaClient) -> assert await async_setup_component(hass, apache_kafka.DOMAIN, config) await hass.async_block_till_done() - assert mock_client.start.called_once + mock_client.start.assert_called_once() async def _setup(hass, filter_config): From bee53f6004a1cd061092714cefeec52b0ef9fa1b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 16 Jan 2024 23:18:30 +0100 Subject: [PATCH 0692/1544] Add decorator typing [yeelight] (#107598) --- homeassistant/components/yeelight/light.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index c5cd6f906f5..a9834823f5e 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine import logging import math -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar import voluptuous as vol import yeelight @@ -66,6 +67,10 @@ from .const import ( from .device import YeelightDevice from .entity import YeelightEntity +_YeelightBaseLightT = TypeVar("_YeelightBaseLightT", bound="YeelightBaseLight") +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) ATTR_MINUTES = "minutes" @@ -238,10 +243,14 @@ def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: return effects -def _async_cmd(func): +def _async_cmd( + func: Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R | None]]: """Define a wrapper to catch exceptions from the bulb.""" - async def _async_wrap(self: YeelightBaseLight, *args, **kwargs): + async def _async_wrap( + self: _YeelightBaseLightT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: for attempts in range(2): try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) @@ -269,6 +278,7 @@ def _async_cmd(func): f"Error when calling {func.__name__} for bulb " f"{self.device.name} at {self.device.host}: {str(ex) or type(ex)}" ) from ex + return None return _async_wrap From f0a63f7189a556926c5ef68d10118cbc47b3b8b9 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 15:38:20 -0700 Subject: [PATCH 0693/1544] Move Guardian coordinator to suggested location (#108182) * Move Guardian coordinator to suggested location * Fix coverage --- .coveragerc | 1 + homeassistant/components/guardian/__init__.py | 2 +- .../components/guardian/binary_sensor.py | 2 +- .../components/guardian/coordinator.py | 79 +++++++++++++++++++ homeassistant/components/guardian/util.py | 69 +--------------- 5 files changed, 85 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/guardian/coordinator.py diff --git a/.coveragerc b/.coveragerc index 38b74601214..07e31e07961 100644 --- a/.coveragerc +++ b/.coveragerc @@ -475,6 +475,7 @@ omit = homeassistant/components/guardian/__init__.py homeassistant/components/guardian/binary_sensor.py homeassistant/components/guardian/button.py + homeassistant/components/guardian/coordinator.py homeassistant/components/guardian/sensor.py homeassistant/components/guardian/switch.py homeassistant/components/guardian/util.py diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 90504f3213e..8a3ac265618 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -40,7 +40,7 @@ from .const import ( LOGGER, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) -from .util import GuardianDataUpdateCoordinator +from .coordinator import GuardianDataUpdateCoordinator DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 6b58e70e45d..c7094cf624c 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -29,9 +29,9 @@ from .const import ( DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +from .coordinator import GuardianDataUpdateCoordinator from .util import ( EntityDomainReplacementStrategy, - GuardianDataUpdateCoordinator, async_finish_entity_domain_replacements, ) diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py new file mode 100644 index 00000000000..dda0a20be69 --- /dev/null +++ b/homeassistant/components/guardian/coordinator.py @@ -0,0 +1,79 @@ +"""Define Guardian-specific utilities.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any, cast + +from aioguardian import Client +from aioguardian.errors import GuardianError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) + +SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" + + +class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Define an extended DataUpdateCoordinator with some Guardian goodies.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + *, + entry: ConfigEntry, + client: Client, + api_name: str, + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], + api_lock: asyncio.Lock, + valve_controller_uid: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=f"{valve_controller_uid}_{api_name}", + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + + self._api_coro = api_coro + self._api_lock = api_lock + self._client = client + + self.config_entry = entry + self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( + self.config_entry.entry_id + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Execute a "locked" API request against the valve controller.""" + async with self._api_lock, self._client: + try: + resp = await self._api_coro() + except GuardianError as err: + raise UpdateFailed(err) from err + return cast(dict[str, Any], resp["data"]) + + async def async_initialize(self) -> None: + """Initialize the coordinator.""" + + @callback + def async_reboot_requested() -> None: + """Respond to a reboot request.""" + self.last_update_success = False + self.async_update_listeners() + + self.config_entry.async_on_unload( + async_dispatcher_connect( + self.hass, self.signal_reboot_requested, async_reboot_requested + ) + ) diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 048f3750d32..ffa57322551 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -1,22 +1,18 @@ """Define Guardian-specific utilities.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta from functools import wraps -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -from aioguardian import Client from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER @@ -29,6 +25,8 @@ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" +_P = ParamSpec("_P") + @dataclass class EntityDomainReplacementStrategy: @@ -63,67 +61,6 @@ def async_finish_entity_domain_replacements( ent_reg.async_remove(old_entity_id) -class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): - """Define an extended DataUpdateCoordinator with some Guardian goodies.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - *, - entry: ConfigEntry, - client: Client, - api_name: str, - api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], - api_lock: asyncio.Lock, - valve_controller_uid: str, - ) -> None: - """Initialize.""" - super().__init__( - hass, - LOGGER, - name=f"{valve_controller_uid}_{api_name}", - update_interval=DEFAULT_UPDATE_INTERVAL, - ) - - self._api_coro = api_coro - self._api_lock = api_lock - self._client = client - - self.config_entry = entry - self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( - self.config_entry.entry_id - ) - - async def _async_update_data(self) -> dict[str, Any]: - """Execute a "locked" API request against the valve controller.""" - async with self._api_lock, self._client: - try: - resp = await self._api_coro() - except GuardianError as err: - raise UpdateFailed(err) from err - return cast(dict[str, Any], resp["data"]) - - async def async_initialize(self) -> None: - """Initialize the coordinator.""" - - @callback - def async_reboot_requested() -> None: - """Respond to a reboot request.""" - self.last_update_success = False - self.async_update_listeners() - - self.config_entry.async_on_unload( - async_dispatcher_connect( - self.hass, self.signal_reboot_requested, async_reboot_requested - ) - ) - - -_P = ParamSpec("_P") - - @callback def convert_exceptions_to_homeassistant_error( func: Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, Any]], From 10014838ef32ee1607e3b959c43821a887c5e3ea Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 17 Jan 2024 00:40:00 +0100 Subject: [PATCH 0694/1544] Dynamically map state class, device class and UoM in ZHA smart energy metering sensor (#107685) * Dynamically map state class, device class and UoM in ZHA smart energy metering sensor * Fix some state & device classes and add scaling * Fix added imperial gallons tests * Use entity description instead of custom class & add one entity to tests * Apply code review suggestion * Scale only when needed * Revert "Scale only when needed" This reverts commit a9e0403402e457248786ff1f231d4b380c2969ce. * Avoid second lookup of entity description * Change test to not mix sensor types --- homeassistant/components/zha/sensor.py | 193 ++++++++++++++++++++----- tests/components/zha/test_sensor.py | 28 +++- 2 files changed, 176 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3e41537c53c..c87ae9d72fb 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,6 +1,7 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import enum import functools @@ -14,6 +15,7 @@ from homeassistant.components.climate import HVACAction from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -486,6 +488,15 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000))) +@dataclass(frozen=True, kw_only=True) +class SmartEnergyMeteringEntityDescription(SensorEntityDescription): + """Dataclass that describes a Zigbee smart energy metering entity.""" + + key: str = "instantaneous_demand" + state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT + scale: int = 1 + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, @@ -494,37 +505,88 @@ class Illuminance(Sensor): class SmartEnergyMetering(PollableSensor): """Metering sensor.""" + entity_description: SmartEnergyMeteringEntityDescription _use_custom_polling: bool = False _attribute_name = "instantaneous_demand" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER - _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "instantaneous_demand" - unit_of_measure_map = { - 0x00: UnitOfPower.WATT, - 0x01: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - 0x02: UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, - 0x03: f"100 {UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR}", - 0x04: f"US {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x05: f"IMP {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x06: UnitOfPower.BTU_PER_HOUR, - 0x07: f"l/{UnitOfTime.HOURS}", - 0x08: UnitOfPressure.KPA, # gauge - 0x09: UnitOfPressure.KPA, # absolute - 0x0A: f"1000 {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", - 0x0B: "unitless", - 0x0C: f"MJ/{UnitOfTime.SECONDS}", + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + 0x01: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + ), + 0x02: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + device_class=None, # volume flow rate is not supported yet + ), + 0x03: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + scale=100, + ), + 0x04: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # US gallons per hour + device_class=None, # volume flow rate is not supported yet + ), + 0x05: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # IMP gallons per hour + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.BTU_PER_HOUR, + device_class=None, + state_class=None, + ), + 0x07: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"l/{UnitOfTime.HOURS}", + device_class=None, # volume flow rate is not supported yet + ), + 0x08: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # gauge + 0x09: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # absolute + 0x0A: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.CUBIC_FEET}/{UnitOfTime.HOURS}", # cubic feet per hour + device_class=None, # volume flow rate is not supported yet + scale=1000, + ), + 0x0B: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfEnergy.MEGA_JOULE}/{UnitOfTime.SECONDS}", + device_class=None, # needs to be None as MJ/s is not supported + ), } + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + entity_description = self._ENTITY_DESCRIPTION_MAP.get( + self._cluster_handler.unit_of_measurement + ) + if entity_description is not None: + self.entity_description = entity_description + def formatter(self, value: int) -> int | float: """Pass through cluster handler formatter.""" return self._cluster_handler.demand_formatter(value) - @property - def native_unit_of_measurement(self) -> str | None: - """Return Unit of measurement.""" - return self.unit_of_measure_map.get(self._cluster_handler.unit_of_measurement) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return device state attrs for battery sensors.""" @@ -540,6 +602,23 @@ class SmartEnergyMetering(PollableSensor): attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] return attrs + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + state = super().native_value + if hasattr(self, "entity_description") and state is not None: + return float(state) * self.entity_description.scale + + return state + + +@dataclass(frozen=True, kw_only=True) +class SmartEnergySummationEntityDescription(SmartEnergyMeteringEntityDescription): + """Dataclass that describes a Zigbee smart energy summation entity.""" + + key: str = "summation_delivered" + state_class: SensorStateClass | None = SensorStateClass.TOTAL_INCREASING + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, @@ -549,26 +628,66 @@ class SmartEnergyMetering(PollableSensor): class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" + entity_description: SmartEnergySummationEntityDescription _attribute_name = "current_summ_delivered" _unique_id_suffix = "summation_delivered" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY - _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_translation_key: str = "summation_delivered" - unit_of_measure_map = { - 0x00: UnitOfEnergy.KILO_WATT_HOUR, - 0x01: UnitOfVolume.CUBIC_METERS, - 0x02: UnitOfVolume.CUBIC_FEET, - 0x03: f"100 {UnitOfVolume.CUBIC_FEET}", - 0x04: f"US {UnitOfVolume.GALLONS}", - 0x05: f"IMP {UnitOfVolume.GALLONS}", - 0x06: "BTU", - 0x07: UnitOfVolume.LITERS, - 0x08: UnitOfPressure.KPA, # gauge - 0x09: UnitOfPressure.KPA, # absolute - 0x0A: f"1000 {UnitOfVolume.CUBIC_FEET}", - 0x0B: "unitless", - 0x0C: "MJ", + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + 0x01: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x02: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + ), + 0x03: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=100, + ), + 0x04: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.GALLONS, # US gallons + device_class=SensorDeviceClass.VOLUME, + ), + 0x05: SmartEnergySummationEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}", + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergySummationEntityDescription( + native_unit_of_measurement="BTU", device_class=None, state_class=None + ), + 0x07: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x08: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # gauge + 0x09: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # absolute + 0x0A: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=1000, + ), + 0x0B: SmartEnergySummationEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.MEGA_JOULE, + device_class=SensorDeviceClass.ENERGY, + ), } def formatter(self, value: int) -> int | float: diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index d9a61b12357..4103897a000 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -164,7 +164,7 @@ async def async_test_smart_energy_summation(hass, cluster, entity_id): await send_attributes_report( hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} ) - assert_state(hass, entity_id, "12.32", UnitOfVolume.CUBIC_METERS) + assert_state(hass, entity_id, "12.321", UnitOfEnergy.KILO_WATT_HOUR) assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" assert ( @@ -346,7 +346,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): "multiplier": 1, "status": 0x00, "summation_formatting": 0b1_0111_010, - "unit_of_measure": 0x01, + "unit_of_measure": 0x00, }, {"instaneneous_demand"}, ), @@ -779,26 +779,26 @@ async def test_unsupported_attributes_sensor( ( 1, 1232000, - "123.20", + "123.2", UnitOfVolume.CUBIC_METERS, ), ( 3, 2340, - "0.23", - f"100 {UnitOfVolume.CUBIC_FEET}", + "0.65", + UnitOfVolume.CUBIC_METERS, ), ( 3, 2360, - "0.24", - f"100 {UnitOfVolume.CUBIC_FEET}", + "0.68", + UnitOfVolume.CUBIC_METERS, ), ( 8, 23660, "2.37", - "kPa", + UnitOfPressure.KPA, ), ( 0, @@ -842,6 +842,18 @@ async def test_unsupported_attributes_sensor( "10.246", UnitOfEnergy.KILO_WATT_HOUR, ), + ( + 5, + 102456, + "10.25", + "IMP gal", + ), + ( + 7, + 50124, + "5.01", + UnitOfVolume.LITERS, + ), ), ) async def test_se_summation_uom( From 858004628eb8b15ebd52efe199ed99944f3d101c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:00:00 -0700 Subject: [PATCH 0695/1544] Remove unnecessary OpenUV entity description mixins (#108195) --- homeassistant/components/openuv/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 431fa41a288..9e337d49ba3 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -71,20 +71,13 @@ def get_uv_label(uv_index: int) -> str: return label.value -@dataclass(frozen=True) -class OpenUvSensorEntityDescriptionMixin: - """Define a mixin for OpenUV sensor descriptions.""" +@dataclass(frozen=True, kw_only=True) +class OpenUvSensorEntityDescription(SensorEntityDescription): + """Define a class that describes OpenUV sensor entities.""" value_fn: Callable[[dict[str, Any]], int | str] -@dataclass(frozen=True) -class OpenUvSensorEntityDescription( - SensorEntityDescription, OpenUvSensorEntityDescriptionMixin -): - """Define a class that describes OpenUV sensor entities.""" - - SENSOR_DESCRIPTIONS = ( OpenUvSensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, From cc9b874be3a4b8715a75e9d521db4a3750ba15f8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:00:14 -0700 Subject: [PATCH 0696/1544] Remove unnecessary SimpliSafe entity description mixins (#108197) --- homeassistant/components/simplisafe/button.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index a11ddc04d64..220ca89d170 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -19,20 +19,13 @@ from .const import DOMAIN from .typing import SystemType -@dataclass(frozen=True) -class SimpliSafeButtonDescriptionMixin: - """Define an entity description mixin for SimpliSafe buttons.""" +@dataclass(frozen=True, kw_only=True) +class SimpliSafeButtonDescription(ButtonEntityDescription): + """Describe a SimpliSafe button entity.""" push_action: Callable[[System], Awaitable] -@dataclass(frozen=True) -class SimpliSafeButtonDescription( - ButtonEntityDescription, SimpliSafeButtonDescriptionMixin -): - """Describe a SimpliSafe button entity.""" - - BUTTON_KIND_CLEAR_NOTIFICATIONS = "clear_notifications" From 0a9ec1a3514e479322b34614f57c39e57177f0bc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:00:26 -0700 Subject: [PATCH 0697/1544] Remove unnecessary PurpleAir entity description mixins (#108196) --- homeassistant/components/purpleair/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 1e78586dece..50dbb47a285 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -33,20 +33,13 @@ from .coordinator import PurpleAirDataUpdateCoordinator CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" -@dataclass(frozen=True) -class PurpleAirSensorEntityDescriptionMixin: - """Define a description mixin for PurpleAir sensor entities.""" +@dataclass(frozen=True, kw_only=True) +class PurpleAirSensorEntityDescription(SensorEntityDescription): + """Define an object to describe PurpleAir sensor entities.""" value_fn: Callable[[SensorModel], float | str | None] -@dataclass(frozen=True) -class PurpleAirSensorEntityDescription( - SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin -): - """Define an object to describe PurpleAir sensor entities.""" - - SENSOR_DESCRIPTIONS = [ PurpleAirSensorEntityDescription( key="humidity", From 70aff728fdf0bc9d0ee62cc06a25ecccce6a8b86 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:00:45 -0700 Subject: [PATCH 0698/1544] Remove unnecessary Notion entity description mixins (#108194) --- .../components/notion/binary_sensor.py | 17 +++++------------ homeassistant/components/notion/model.py | 6 +++--- homeassistant/components/notion/sensor.py | 6 +++--- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index a1c519f228f..8e4d5927152 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -30,24 +30,17 @@ from .const import ( SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED, ) -from .model import NotionEntityDescriptionMixin +from .model import NotionEntityDescription -@dataclass(frozen=True) -class NotionBinarySensorDescriptionMixin: - """Define an entity description mixin for binary and regular sensors.""" - - on_state: Literal["alarm", "leak", "low", "not_missing", "open"] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class NotionBinarySensorDescription( - BinarySensorEntityDescription, - NotionBinarySensorDescriptionMixin, - NotionEntityDescriptionMixin, + BinarySensorEntityDescription, NotionEntityDescription ): """Describe a Notion binary sensor.""" + on_state: Literal["alarm", "leak", "low", "not_missing", "open"] + BINARY_SENSOR_DESCRIPTIONS = ( NotionBinarySensorDescription( diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index cdfd6e63dad..a774bfdfad3 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -4,8 +4,8 @@ from dataclasses import dataclass from aionotion.sensor.models import ListenerKind -@dataclass(frozen=True) -class NotionEntityDescriptionMixin: - """Define an description mixin Notion entities.""" +@dataclass(frozen=True, kw_only=True) +class NotionEntityDescription: + """Define an description for Notion entities.""" listener_kind: ListenerKind diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 8c4242aec2a..1d2c81addfa 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -16,11 +16,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE -from .model import NotionEntityDescriptionMixin +from .model import NotionEntityDescription -@dataclass(frozen=True) -class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): +@dataclass(frozen=True, kw_only=True) +class NotionSensorDescription(SensorEntityDescription, NotionEntityDescription): """Describe a Notion sensor.""" From e553cf2241796e777f6bc0c2892dc3b51bb0661a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:00:55 -0700 Subject: [PATCH 0699/1544] Remove unnecessary AirVisual Pro entity description mixins (#108192) --- homeassistant/components/airvisual_pro/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 00c87b02377..2708cc5857d 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -26,22 +26,15 @@ from . import AirVisualProData, AirVisualProEntity from .const import DOMAIN -@dataclass(frozen=True) -class AirVisualProMeasurementKeyMixin: - """Define an entity description mixin to include a measurement key.""" +@dataclass(frozen=True, kw_only=True) +class AirVisualProMeasurementDescription(SensorEntityDescription): + """Describe an AirVisual Pro sensor.""" value_fn: Callable[ [dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]], float | int ] -@dataclass(frozen=True) -class AirVisualProMeasurementDescription( - SensorEntityDescription, AirVisualProMeasurementKeyMixin -): - """Describe an AirVisual Pro sensor.""" - - SENSOR_DESCRIPTIONS = ( AirVisualProMeasurementDescription( key="air_quality_index", From 9bbf098901b2dd3297b17a6f02659d5e8fc99d9c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:01:07 -0700 Subject: [PATCH 0700/1544] Remove unnecessary Ambient PWS entity description mixins (#108191) --- .../components/ambient_station/binary_sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 8bdfe0fd642..25c95b2e20e 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -63,20 +63,13 @@ TYPE_RELAY8 = "relay8" TYPE_RELAY9 = "relay9" -@dataclass(frozen=True) -class AmbientBinarySensorDescriptionMixin: - """Define an entity description mixin for binary sensors.""" +@dataclass(frozen=True, kw_only=True) +class AmbientBinarySensorDescription(BinarySensorEntityDescription): + """Describe an Ambient PWS binary sensor.""" on_state: Literal[0, 1] -@dataclass(frozen=True) -class AmbientBinarySensorDescription( - BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin -): - """Describe an Ambient PWS binary sensor.""" - - BINARY_SENSOR_DESCRIPTIONS = ( AmbientBinarySensorDescription( key=TYPE_BATTOUT, From d5c1049bfe9ab0db0c8743d624623ed316330a9c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 16 Jan 2024 18:04:32 -0700 Subject: [PATCH 0701/1544] Remove unnecessary RainMachine entity description mixins (#108190) --- .../components/rainmachine/binary_sensor.py | 13 ++++----- .../components/rainmachine/button.py | 15 +++-------- homeassistant/components/rainmachine/model.py | 27 +++---------------- .../components/rainmachine/select.py | 26 ++++++------------ .../components/rainmachine/sensor.py | 22 +++++++-------- .../components/rainmachine/switch.py | 27 ++++++++----------- 6 files changed, 40 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index f0cbfd636fa..930139acf60 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -13,10 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RainMachineData, RainMachineEntity from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, -) +from .model import RainMachineEntityDescription from .util import ( EntityDomainReplacementStrategy, async_finish_entity_domain_replacements, @@ -32,14 +29,14 @@ TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineBinarySensorDescription( - BinarySensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + BinarySensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine binary sensor.""" + data_key: str + BINARY_SENSOR_DESCRIPTIONS = ( RainMachineBinarySensorDescription( diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index a13d2069007..6309d9777a1 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -24,21 +24,14 @@ from .const import DATA_PROVISION_SETTINGS, DOMAIN from .model import RainMachineEntityDescription -@dataclass(frozen=True) -class RainMachineButtonDescriptionMixin: - """Define an entity description mixin for RainMachine buttons.""" - - push_action: Callable[[Controller], Awaitable] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineButtonDescription( - ButtonEntityDescription, - RainMachineEntityDescription, - RainMachineButtonDescriptionMixin, + ButtonEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine button description.""" + push_action: Callable[[Controller], Awaitable] + BUTTON_KIND_REBOOT = "reboot" diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index e45448c0fe4..e7f166b67dd 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -4,29 +4,8 @@ from dataclasses import dataclass from homeassistant.helpers.entity import EntityDescription -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinApiCategory: - """Define an entity description mixin to include an API category.""" +@dataclass(frozen=True, kw_only=True) +class RainMachineEntityDescription(EntityDescription): + """Describe a RainMachine entity.""" api_category: str - - -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinDataKey: - """Define an entity description mixin to include a data payload key.""" - - data_key: str - - -@dataclass(frozen=True) -class RainMachineEntityDescriptionMixinUid: - """Define an entity description mixin to include an activity UID.""" - - uid: int - - -@dataclass(frozen=True) -class RainMachineEntityDescription( - EntityDescription, RainMachineEntityDescriptionMixinApiCategory -): - """Describe a RainMachine entity.""" diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 513c02ddc19..893c1afa8da 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -15,21 +15,18 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import RainMachineData, RainMachineEntity from .const import DATA_RESTRICTIONS_UNIVERSAL, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, -) +from .model import RainMachineEntityDescription from .util import key_exists -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSelectDescription( - SelectEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + SelectEntityDescription, RainMachineEntityDescription ): """Describe a generic RainMachine select.""" + data_key: str + @dataclass class FreezeProtectionSelectOption: @@ -40,20 +37,13 @@ class FreezeProtectionSelectOption: metric_label: str -@dataclass(frozen=True) -class FreezeProtectionTemperatureMixin: - """Define an entity description mixin to include an options list.""" +@dataclass(frozen=True, kw_only=True) +class FreezeProtectionSelectDescription(RainMachineSelectDescription): + """Describe a freeze protection temperature select.""" extended_options: list[FreezeProtectionSelectOption] -@dataclass(frozen=True) -class FreezeProtectionSelectDescription( - RainMachineSelectDescription, FreezeProtectionTemperatureMixin -): - """Describe a freeze protection temperature select.""" - - TYPE_FREEZE_PROTECTION_TEMPERATURE = "freeze_protection_temperature" SELECT_DESCRIPTIONS = ( diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 624deeb46c6..ed9b8cc0142 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -21,11 +21,7 @@ from homeassistant.util.dt import utc_from_timestamp, utcnow from . import RainMachineData, RainMachineEntity from .const import DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_ZONES, DOMAIN -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, - RainMachineEntityDescriptionMixinUid, -) +from .model import RainMachineEntityDescription from .util import ( RUN_STATE_MAP, EntityDomainReplacementStrategy, @@ -48,23 +44,23 @@ TYPE_RAIN_SENSOR_RAIN_START = "rain_sensor_rain_start" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSensorDataDescription( - SensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, + SensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine sensor.""" + data_key: str -@dataclass(frozen=True) + +@dataclass(frozen=True, kw_only=True) class RainMachineSensorCompletionTimerDescription( - SensorEntityDescription, - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinUid, + SensorEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine completion timer sensor.""" + uid: int + SENSOR_DESCRIPTIONS = ( RainMachineSensorDataDescription( diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index b47396bc9e5..8450cb7d5e6 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -31,11 +31,7 @@ from .const import ( DEFAULT_ZONE_RUN, DOMAIN, ) -from .model import ( - RainMachineEntityDescription, - RainMachineEntityDescriptionMixinDataKey, - RainMachineEntityDescriptionMixinUid, -) +from .model import RainMachineEntityDescription from .util import RUN_STATE_MAP, key_exists ATTR_AREA = "area" @@ -134,27 +130,26 @@ def raise_on_request_error( return decorator -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class RainMachineSwitchDescription( - SwitchEntityDescription, - RainMachineEntityDescription, + SwitchEntityDescription, RainMachineEntityDescription ): """Describe a RainMachine switch.""" -@dataclass(frozen=True) -class RainMachineActivitySwitchDescription( - RainMachineSwitchDescription, RainMachineEntityDescriptionMixinUid -): +@dataclass(frozen=True, kw_only=True) +class RainMachineActivitySwitchDescription(RainMachineSwitchDescription): """Describe a RainMachine activity (program/zone) switch.""" + uid: int -@dataclass(frozen=True) -class RainMachineRestrictionSwitchDescription( - RainMachineSwitchDescription, RainMachineEntityDescriptionMixinDataKey -): + +@dataclass(frozen=True, kw_only=True) +class RainMachineRestrictionSwitchDescription(RainMachineSwitchDescription): """Describe a RainMachine restriction switch.""" + data_key: str + TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED = "freeze_protect_enabled" TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING = "hot_days_extra_watering" From d4f9ad9dd365232fe48953de69a2a4ba875f90ca Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 17 Jan 2024 02:07:55 +0100 Subject: [PATCH 0702/1544] Create update coordinator for Systemmonitor (#106693) --- .../components/systemmonitor/const.py | 30 +- .../components/systemmonitor/coordinator.py | 166 ++++ .../components/systemmonitor/sensor.py | 710 +++++++++--------- .../components/systemmonitor/util.py | 23 +- tests/components/systemmonitor/conftest.py | 8 +- .../systemmonitor/snapshots/test_sensor.ambr | 8 +- tests/components/systemmonitor/test_sensor.py | 131 +++- 7 files changed, 678 insertions(+), 398 deletions(-) create mode 100644 homeassistant/components/systemmonitor/coordinator.py diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py index c92647f9c8e..798cb82f8ef 100644 --- a/homeassistant/components/systemmonitor/const.py +++ b/homeassistant/components/systemmonitor/const.py @@ -5,13 +5,37 @@ DOMAIN = "systemmonitor" CONF_INDEX = "index" CONF_PROCESS = "process" -NETWORK_TYPES = [ +NET_IO_TYPES = [ "network_in", "network_out", "throughput_network_in", "throughput_network_out", "packets_in", "packets_out", - "ipv4_address", - "ipv6_address", +] + +# There might be additional keys to be added for different +# platforms / hardware combinations. +# Taken from last version of "glances" integration before they moved to +# a generic temperature sensor logic. +# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199 +CPU_SENSOR_PREFIXES = [ + "amdgpu 1", + "aml_thermal", + "Core 0", + "Core 1", + "CPU Temperature", + "CPU", + "cpu-thermal 1", + "cpu_thermal 1", + "exynos-therm 1", + "Package id 0", + "Physical id 0", + "radeon 1", + "soc-thermal 1", + "soc_thermal 1", + "Tctl", + "cpu0-thermal", + "cpu0_thermal", + "k10temp 1", ] diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py new file mode 100644 index 00000000000..9143d31f163 --- /dev/null +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -0,0 +1,166 @@ +"""DataUpdateCoordinators for the System monitor integration.""" +from __future__ import annotations + +from abc import abstractmethod +from datetime import datetime +import logging +import os +from typing import NamedTuple, TypeVar + +import psutil +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + + +class VirtualMemory(NamedTuple): + """Represents virtual memory. + + psutil defines virtual memory by platform. + Create our own definition here to be platform independent. + """ + + total: float + available: float + percent: float + used: float + free: float + + +dataT = TypeVar( + "dataT", + bound=datetime + | dict[str, list[shwtemp]] + | dict[str, list[snicaddr]] + | dict[str, snetio] + | float + | list[psutil.Process] + | sswap + | VirtualMemory + | tuple[float, float, float] + | sdiskusage, +) + + +class MonitorCoordinator(DataUpdateCoordinator[dataT]): + """A System monitor Base Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, name: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"System Monitor {name}", + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + + async def _async_update_data(self) -> dataT: + """Fetch data.""" + return await self.hass.async_add_executor_job(self.update_data) + + @abstractmethod + def update_data(self) -> dataT: + """To be extended by data update coordinators.""" + + +class SystemMonitorDiskCoordinator(MonitorCoordinator[sdiskusage]): + """A System monitor Disk Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, name: str, argument: str) -> None: + """Initialize the disk coordinator.""" + super().__init__(hass, name) + self._argument = argument + + def update_data(self) -> sdiskusage: + """Fetch data.""" + try: + return psutil.disk_usage(self._argument) + except PermissionError as err: + raise UpdateFailed(f"No permission to access {self._argument}") from err + except OSError as err: + raise UpdateFailed(f"OS error for {self._argument}") from err + + +class SystemMonitorSwapCoordinator(MonitorCoordinator[sswap]): + """A System monitor Swap Data Update Coordinator.""" + + def update_data(self) -> sswap: + """Fetch data.""" + return psutil.swap_memory() + + +class SystemMonitorMemoryCoordinator(MonitorCoordinator[VirtualMemory]): + """A System monitor Memory Data Update Coordinator.""" + + def update_data(self) -> VirtualMemory: + """Fetch data.""" + memory = psutil.virtual_memory() + return VirtualMemory( + memory.total, memory.available, memory.percent, memory.used, memory.free + ) + + +class SystemMonitorNetIOCoordinator(MonitorCoordinator[dict[str, snetio]]): + """A System monitor Network IO Data Update Coordinator.""" + + def update_data(self) -> dict[str, snetio]: + """Fetch data.""" + return psutil.net_io_counters(pernic=True) + + +class SystemMonitorNetAddrCoordinator(MonitorCoordinator[dict[str, list[snicaddr]]]): + """A System monitor Network Address Data Update Coordinator.""" + + def update_data(self) -> dict[str, list[snicaddr]]: + """Fetch data.""" + return psutil.net_if_addrs() + + +class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float]]): + """A System monitor Load Data Update Coordinator.""" + + def update_data(self) -> tuple[float, float, float]: + """Fetch data.""" + return os.getloadavg() + + +class SystemMonitorProcessorCoordinator(MonitorCoordinator[float]): + """A System monitor Processor Data Update Coordinator.""" + + def update_data(self) -> float: + """Fetch data.""" + return psutil.cpu_percent(interval=None) + + +class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]): + """A System monitor Processor Data Update Coordinator.""" + + def update_data(self) -> datetime: + """Fetch data.""" + return dt_util.utc_from_timestamp(psutil.boot_time()) + + +class SystemMonitorProcessCoordinator(MonitorCoordinator[list[psutil.Process]]): + """A System monitor Process Data Update Coordinator.""" + + def update_data(self) -> list[psutil.Process]: + """Fetch data.""" + processes = psutil.process_iter() + return list(processes) + + +class SystemMonitorCPUtempCoordinator(MonitorCoordinator[dict[str, list[shwtemp]]]): + """A System monitor CPU Temperature Data Update Coordinator.""" + + def update_data(self) -> dict[str, list[shwtemp]]: + """Fetch data.""" + try: + return psutil.sensors_temperatures() + except AttributeError as err: + raise UpdateFailed("OS does not provide temperature sensors") from err diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index c7fdb4226d1..b5cccc20b6a 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,17 +1,18 @@ """Support for monitoring the local system.""" from __future__ import annotations -import asyncio +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta -from functools import cache, lru_cache +from datetime import datetime +from functools import lru_cache import logging -import os import socket import sys -from typing import Any, Literal +import time +from typing import Any, Generic, Literal import psutil +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap import voluptuous as vol from homeassistant.components.sensor import ( @@ -26,7 +27,6 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_RESOURCES, CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_OFF, STATE_ON, @@ -35,22 +35,31 @@ from homeassistant.const import ( UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -import homeassistant.util.dt as dt_util -from .const import CONF_PROCESS, DOMAIN, NETWORK_TYPES -from .util import get_all_disk_mounts, get_all_network_interfaces +from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES +from .coordinator import ( + MonitorCoordinator, + SystemMonitorBootTimeCoordinator, + SystemMonitorCPUtempCoordinator, + SystemMonitorDiskCoordinator, + SystemMonitorLoadCoordinator, + SystemMonitorMemoryCoordinator, + SystemMonitorNetAddrCoordinator, + SystemMonitorNetIOCoordinator, + SystemMonitorProcessCoordinator, + SystemMonitorProcessorCoordinator, + SystemMonitorSwapCoordinator, + VirtualMemory, + dataT, +) +from .util import get_all_disk_mounts, get_all_network_interfaces, read_cpu_temperature _LOGGER = logging.getLogger(__name__) @@ -74,16 +83,92 @@ def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: return "mdi:cpu-32-bit" -@dataclass(frozen=True) -class SysMonitorSensorEntityDescription(SensorEntityDescription): - """Description for System Monitor sensor entities.""" +def get_processor_temperature( + entity: SystemMonitorSensor[dict[str, list[shwtemp]]], +) -> float | None: + """Return processor temperature.""" + return read_cpu_temperature(entity.coordinator.data) + +def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> str: + """Return process.""" + state = STATE_OFF + for proc in entity.coordinator.data: + try: + _LOGGER.debug("process %s for argument %s", proc.name(), entity.argument) + if entity.argument == proc.name(): + state = STATE_ON + break + except psutil.NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with ID: %s, old name: %s", + err.pid, + err.name, + ) + return state + + +def get_network(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return network in and out.""" + counters = entity.coordinator.data + if entity.argument in counters: + counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + return round(counter / 1024**2, 1) + return None + + +def get_packets(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return packets in and out.""" + counters = entity.coordinator.data + if entity.argument in counters: + return counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + return None + + +def get_throughput(entity: SystemMonitorSensor[dict[str, snetio]]) -> float | None: + """Return network throughput in and out.""" + counters = entity.coordinator.data + state = None + if entity.argument in counters: + counter = counters[entity.argument][IO_COUNTER[entity.entity_description.key]] + now = time.monotonic() + if ( + (value := entity.value) + and (update_time := entity.update_time) + and value < counter + ): + state = round( + (counter - value) / 1000**2 / (now - update_time), + 3, + ) + entity.update_time = now + entity.value = counter + return state + + +def get_ip_address( + entity: SystemMonitorSensor[dict[str, list[snicaddr]]], +) -> str | None: + """Return network ip address.""" + addresses = entity.coordinator.data + if entity.argument in addresses: + for addr in addresses[entity.argument]: + if addr.family == IF_ADDRS_FAMILY[entity.entity_description.key]: + return addr.address + return None + + +@dataclass(frozen=True, kw_only=True) +class SysMonitorSensorEntityDescription(SensorEntityDescription, Generic[dataT]): + """Describes System Monitor sensor entities.""" + + value_fn: Callable[[SystemMonitorSensor[dataT]], StateType | datetime] mandatory_arg: bool = False placeholder: str | None = None -SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { - "disk_free": SysMonitorSensorEntityDescription( +SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = { + "disk_free": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_free", translation_key="disk_free", placeholder="mount_point", @@ -91,8 +176,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.free / 1024**3, 1), ), - "disk_use": SysMonitorSensorEntityDescription( + "disk_use": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_use", translation_key="disk_use", placeholder="mount_point", @@ -100,76 +186,91 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.used / 1024**3, 1), ), - "disk_use_percent": SysMonitorSensorEntityDescription( + "disk_use_percent": SysMonitorSensorEntityDescription[sdiskusage]( key="disk_use_percent", translation_key="disk_use_percent", placeholder="mount_point", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), - "ipv4_address": SysMonitorSensorEntityDescription( + "ipv4_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]]( key="ipv4_address", translation_key="ipv4_address", placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, + value_fn=get_ip_address, ), - "ipv6_address": SysMonitorSensorEntityDescription( + "ipv6_address": SysMonitorSensorEntityDescription[dict[str, list[snicaddr]]]( key="ipv6_address", translation_key="ipv6_address", placeholder="ip_address", icon="mdi:ip-network", mandatory_arg=True, + value_fn=get_ip_address, ), - "last_boot": SysMonitorSensorEntityDescription( + "last_boot": SysMonitorSensorEntityDescription[datetime]( key="last_boot", translation_key="last_boot", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda entity: entity.coordinator.data, ), - "load_15m": SysMonitorSensorEntityDescription( + "load_15m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_15m", translation_key="load_15m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[2], 2), ), - "load_1m": SysMonitorSensorEntityDescription( + "load_1m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_1m", translation_key="load_1m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[0], 2), ), - "load_5m": SysMonitorSensorEntityDescription( + "load_5m": SysMonitorSensorEntityDescription[tuple[float, float, float]]( key="load_5m", translation_key="load_5m", icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data[1], 2), ), - "memory_free": SysMonitorSensorEntityDescription( + "memory_free": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_free", translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.available / 1024**2, 1), ), - "memory_use": SysMonitorSensorEntityDescription( + "memory_use": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_use", translation_key="memory_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round( + (entity.coordinator.data.total - entity.coordinator.data.available) + / 1024**2, + 1, + ), ), - "memory_use_percent": SysMonitorSensorEntityDescription( + "memory_use_percent": SysMonitorSensorEntityDescription[VirtualMemory]( key="memory_use_percent", translation_key="memory_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), - "network_in": SysMonitorSensorEntityDescription( + "network_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="network_in", translation_key="network_in", placeholder="interface", @@ -178,8 +279,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_network, ), - "network_out": SysMonitorSensorEntityDescription( + "network_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="network_out", translation_key="network_out", placeholder="interface", @@ -188,24 +290,27 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_network, ), - "packets_in": SysMonitorSensorEntityDescription( + "packets_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="packets_in", translation_key="packets_in", placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_packets, ), - "packets_out": SysMonitorSensorEntityDescription( + "packets_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="packets_out", translation_key="packets_out", placeholder="interface", icon="mdi:server-network", state_class=SensorStateClass.TOTAL_INCREASING, mandatory_arg=True, + value_fn=get_packets, ), - "throughput_network_in": SysMonitorSensorEntityDescription( + "throughput_network_in": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="throughput_network_in", translation_key="throughput_network_in", placeholder="interface", @@ -213,8 +318,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, mandatory_arg=True, + value_fn=get_throughput, ), - "throughput_network_out": SysMonitorSensorEntityDescription( + "throughput_network_out": SysMonitorSensorEntityDescription[dict[str, snetio]]( key="throughput_network_out", translation_key="throughput_network_out", placeholder="interface", @@ -222,50 +328,59 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, mandatory_arg=True, + value_fn=get_throughput, ), - "process": SysMonitorSensorEntityDescription( + "process": SysMonitorSensorEntityDescription[list[psutil.Process]]( key="process", translation_key="process", placeholder="process", icon=get_cpu_icon(), mandatory_arg=True, + value_fn=get_process, ), - "processor_use": SysMonitorSensorEntityDescription( + "processor_use": SysMonitorSensorEntityDescription[float]( key="processor_use", translation_key="processor_use", native_unit_of_measurement=PERCENTAGE, icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data), ), - "processor_temperature": SysMonitorSensorEntityDescription( + "processor_temperature": SysMonitorSensorEntityDescription[ + dict[str, list[shwtemp]] + ]( key="processor_temperature", translation_key="processor_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + value_fn=get_processor_temperature, ), - "swap_free": SysMonitorSensorEntityDescription( + "swap_free": SysMonitorSensorEntityDescription[sswap]( key="swap_free", translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.free / 1024**2, 1), ), - "swap_use": SysMonitorSensorEntityDescription( + "swap_use": SysMonitorSensorEntityDescription[sswap]( key="swap_use", translation_key="swap_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: round(entity.coordinator.data.used / 1024**2, 1), ), - "swap_use_percent": SysMonitorSensorEntityDescription( + "swap_use_percent": SysMonitorSensorEntityDescription[sswap]( key="swap_use_percent", translation_key="swap_use_percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.coordinator.data.percent, ), } @@ -320,46 +435,8 @@ IO_COUNTER = { "throughput_network_out": 0, "throughput_network_in": 1, } - IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6} -# There might be additional keys to be added for different -# platforms / hardware combinations. -# Taken from last version of "glances" integration before they moved to -# a generic temperature sensor logic. -# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199 -CPU_SENSOR_PREFIXES = [ - "amdgpu 1", - "aml_thermal", - "Core 0", - "Core 1", - "CPU Temperature", - "CPU", - "cpu-thermal 1", - "cpu_thermal 1", - "exynos-therm 1", - "Package id 0", - "Physical id 0", - "radeon 1", - "soc-thermal 1", - "soc_thermal 1", - "Tctl", - "cpu0-thermal", - "cpu0_thermal", - "k10temp 1", -] - - -@dataclass -class SensorData: - """Data for a sensor.""" - - argument: Any - state: str | datetime | None - value: Any | None - update_time: datetime | None - last_exception: BaseException | None - async def async_setup_platform( hass: HomeAssistant, @@ -399,33 +476,69 @@ async def async_setup_platform( ) -async def async_setup_entry( +async def async_setup_entry( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up System Montor sensors based on a config entry.""" - entities = [] - sensor_registry: dict[tuple[str, str], SensorData] = {} + entities: list[SystemMonitorSensor] = [] legacy_resources: set[str] = set(entry.options.get("resources", [])) loaded_resources: set[str] = set() - disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) - network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) - cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) + + def get_arguments() -> dict[str, Any]: + """Return startup information.""" + disk_arguments = get_all_disk_mounts() + network_arguments = get_all_network_interfaces() + cpu_temperature = read_cpu_temperature() + return { + "disk_arguments": disk_arguments, + "network_arguments": network_arguments, + "cpu_temperature": cpu_temperature, + } + + startup_arguments = await hass.async_add_executor_job(get_arguments) + + disk_coordinators: dict[str, SystemMonitorDiskCoordinator] = {} + for argument in startup_arguments["disk_arguments"]: + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) + swap_coordinator = SystemMonitorSwapCoordinator(hass, "Swap coordinator") + memory_coordinator = SystemMonitorMemoryCoordinator(hass, "Memory coordinator") + net_io_coordinator = SystemMonitorNetIOCoordinator(hass, "Net IO coordnator") + net_addr_coordinator = SystemMonitorNetAddrCoordinator( + hass, "Net address coordinator" + ) + system_load_coordinator = SystemMonitorLoadCoordinator( + hass, "System load coordinator" + ) + processor_coordinator = SystemMonitorProcessorCoordinator( + hass, "Processor coordinator" + ) + boot_time_coordinator = SystemMonitorBootTimeCoordinator( + hass, "Boot time coordinator" + ) + process_coordinator = SystemMonitorProcessCoordinator(hass, "Process coordinator") + cpu_temp_coordinator = SystemMonitorCPUtempCoordinator( + hass, "CPU temperature coordinator" + ) + + for argument in startup_arguments["disk_arguments"]: + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) _LOGGER.debug("Setup from options %s", entry.options) for _type, sensor_description in SENSOR_TYPES.items(): if _type.startswith("disk_"): - for argument in disk_arguments: - sensor_registry[(_type, argument)] = SensorData( - argument, None, None, None, None - ) + for argument in startup_arguments["disk_arguments"]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( - sensor_registry, + disk_coordinators[argument], sensor_description, entry.entry_id, argument, @@ -434,18 +547,15 @@ async def async_setup_entry( ) continue - if _type in NETWORK_TYPES: - for argument in network_arguments: - sensor_registry[(_type, argument)] = SensorData( - argument, None, None, None, None - ) + if _type.startswith("ipv"): + for argument in startup_arguments["network_arguments"]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(slugify(f"{_type}_{argument}")) + loaded_resources.add(f"{_type}_{argument}") entities.append( SystemMonitorSensor( - sensor_registry, + net_addr_coordinator, sensor_description, entry.entry_id, argument, @@ -454,22 +564,74 @@ async def async_setup_entry( ) continue - # Verify if we can retrieve CPU / processor temperatures. - # If not, do not create the entity and add a warning to the log - if _type == "processor_temperature" and cpu_temperature is None: - _LOGGER.warning("Cannot read CPU / processor temperature information") + if _type == "last_boot": + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + boot_time_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("load_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + system_load_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("memory_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + memory_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + + if _type in NET_IO_TYPES: + for argument in startup_arguments["network_arguments"]: + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + net_io_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) continue if _type == "process": - _entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {}) + _entry = entry.options.get(SENSOR_DOMAIN, {}) for argument in _entry.get(CONF_PROCESS, []): - sensor_registry[(_type, argument)] = SensorData( - argument, None, None, None, None - ) loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( - sensor_registry, + process_coordinator, sensor_description, entry.entry_id, argument, @@ -478,18 +640,52 @@ async def async_setup_entry( ) continue - sensor_registry[(_type, "")] = SensorData("", None, None, None, None) - is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) - loaded_resources.add(f"{_type}_") - entities.append( - SystemMonitorSensor( - sensor_registry, - sensor_description, - entry.entry_id, - "", - is_enabled, + if _type == "processor_use": + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + processor_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type == "processor_temperature": + if not startup_arguments["cpu_temperature"]: + # Don't load processor temperature sensor if we can't read it. + continue + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + cpu_temp_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type.startswith("swap_"): + argument = "" + is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + swap_coordinator, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) ) - ) # Ensure legacy imported disk_* resources are loaded if they are not part # of mount points automatically discovered @@ -506,12 +702,13 @@ async def async_setup_entry( _type = resource[:split_index] argument = resource[split_index + 1 :] _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) - sensor_registry[(_type, argument)] = SensorData( - argument, None, None, None, None - ) + if not disk_coordinators.get(argument): + disk_coordinators[argument] = SystemMonitorDiskCoordinator( + hass, f"Disk {argument} coordinator", argument + ) entities.append( SystemMonitorSensor( - sensor_registry, + disk_coordinators[argument], SENSOR_TYPES[_type], entry.entry_id, argument, @@ -519,94 +716,45 @@ async def async_setup_entry( ) ) - scan_interval = DEFAULT_SCAN_INTERVAL - await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) + # No gathering to avoid swamping the executor + for coordinator in disk_coordinators.values(): + await coordinator.async_request_refresh() + await boot_time_coordinator.async_request_refresh() + await cpu_temp_coordinator.async_request_refresh() + await memory_coordinator.async_request_refresh() + await net_addr_coordinator.async_request_refresh() + await net_io_coordinator.async_request_refresh() + await process_coordinator.async_request_refresh() + await processor_coordinator.async_request_refresh() + await swap_coordinator.async_request_refresh() + await system_load_coordinator.async_request_refresh() + async_add_entities(entities) -async def async_setup_sensor_registry_updates( - hass: HomeAssistant, - sensor_registry: dict[tuple[str, str], SensorData], - scan_interval: timedelta, -) -> None: - """Update the registry and create polling.""" - - _update_lock = asyncio.Lock() - - def _update_sensors() -> None: - """Update sensors and store the result in the registry.""" - for (type_, argument), data in sensor_registry.items(): - try: - state, value, update_time = _update(type_, data) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Error updating sensor: %s (%s)", type_, argument) - data.last_exception = ex - else: - data.state = state - data.value = value - data.update_time = update_time - data.last_exception = None - - # Only fetch these once per iteration as we use the same - # data source multiple times in _update - _disk_usage.cache_clear() - _swap_memory.cache_clear() - _virtual_memory.cache_clear() - _net_io_counters.cache_clear() - _net_if_addrs.cache_clear() - _getloadavg.cache_clear() - - async def _async_update_data(*_: Any) -> None: - """Update all sensors in one executor jump.""" - if _update_lock.locked(): - _LOGGER.warning( - ( - "Updating systemmonitor took longer than the scheduled update" - " interval %s" - ), - scan_interval, - ) - return - - async with _update_lock: - await hass.async_add_executor_job(_update_sensors) - async_dispatcher_send(hass, SIGNAL_SYSTEMMONITOR_UPDATE) - - polling_remover = async_track_time_interval(hass, _async_update_data, scan_interval) - - @callback - def _async_stop_polling(*_: Any) -> None: - polling_remover() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_polling) - - await _async_update_data() - - -class SystemMonitorSensor(SensorEntity): +class SystemMonitorSensor(CoordinatorEntity[MonitorCoordinator[dataT]], SensorEntity): """Implementation of a system monitor sensor.""" - should_poll = False _attr_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: SysMonitorSensorEntityDescription def __init__( self, - sensor_registry: dict[tuple[str, str], SensorData], + coordinator: MonitorCoordinator, sensor_description: SysMonitorSensorEntityDescription, entry_id: str, - argument: str = "", + argument: str, legacy_enabled: bool = False, ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = sensor_description if self.entity_description.placeholder: self._attr_translation_placeholders = { self.entity_description.placeholder: argument } self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") - self._sensor_registry = sensor_registry - self._argument: str = argument self._attr_entity_registry_enabled_default = legacy_enabled self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -614,177 +762,11 @@ class SystemMonitorSensor(SensorEntity): manufacturer="System Monitor", name="System Monitor", ) + self.argument = argument + self.value: int | None = None + self.update_time: float | None = None @property - def native_value(self) -> str | datetime | None: - """Return the state of the device.""" - return self.data.state - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.data.last_exception is None - - @property - def data(self) -> SensorData: - """Return registry entry for the data.""" - return self._sensor_registry[(self.entity_description.key, self._argument)] - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SYSTEMMONITOR_UPDATE, self.async_write_ha_state - ) - ) - - -def _update( # noqa: C901 - type_: str, data: SensorData -) -> tuple[str | datetime | None, str | None, datetime | None]: - """Get the latest system information.""" - state = None - value = None - update_time = None - - if type_ == "disk_use_percent": - state = _disk_usage(data.argument).percent - elif type_ == "disk_use": - state = round(_disk_usage(data.argument).used / 1024**3, 1) - elif type_ == "disk_free": - state = round(_disk_usage(data.argument).free / 1024**3, 1) - elif type_ == "memory_use_percent": - state = _virtual_memory().percent - elif type_ == "memory_use": - virtual_memory = _virtual_memory() - state = round((virtual_memory.total - virtual_memory.available) / 1024**2, 1) - elif type_ == "memory_free": - state = round(_virtual_memory().available / 1024**2, 1) - elif type_ == "swap_use_percent": - state = _swap_memory().percent - elif type_ == "swap_use": - state = round(_swap_memory().used / 1024**2, 1) - elif type_ == "swap_free": - state = round(_swap_memory().free / 1024**2, 1) - elif type_ == "processor_use": - state = round(psutil.cpu_percent(interval=None)) - elif type_ == "processor_temperature": - state = _read_cpu_temperature() - elif type_ == "process": - state = STATE_OFF - for proc in psutil.process_iter(): - try: - if data.argument == proc.name(): - state = STATE_ON - break - except psutil.NoSuchProcess as err: - _LOGGER.warning( - "Failed to load process with ID: %s, old name: %s", - err.pid, - err.name, - ) - elif type_ in ("network_out", "network_in"): - counters = _net_io_counters() - if data.argument in counters: - counter = counters[data.argument][IO_COUNTER[type_]] - state = round(counter / 1024**2, 1) - else: - state = None - elif type_ in ("packets_out", "packets_in"): - counters = _net_io_counters() - if data.argument in counters: - state = counters[data.argument][IO_COUNTER[type_]] - else: - state = None - elif type_ in ("throughput_network_out", "throughput_network_in"): - counters = _net_io_counters() - if data.argument in counters: - counter = counters[data.argument][IO_COUNTER[type_]] - now = dt_util.utcnow() - if data.value and data.value < counter: - state = round( - (counter - data.value) - / 1000**2 - / (now - (data.update_time or now)).total_seconds(), - 3, - ) - else: - state = None - update_time = now - value = counter - else: - state = None - elif type_ in ("ipv4_address", "ipv6_address"): - addresses = _net_if_addrs() - if data.argument in addresses: - for addr in addresses[data.argument]: - if addr.family == IF_ADDRS_FAMILY[type_]: - state = addr.address - else: - state = None - elif type_ == "last_boot": - # Only update on initial setup - if data.state is None: - state = dt_util.utc_from_timestamp(psutil.boot_time()) - else: - state = data.state - elif type_ == "load_1m": - state = round(_getloadavg()[0], 2) - elif type_ == "load_5m": - state = round(_getloadavg()[1], 2) - elif type_ == "load_15m": - state = round(_getloadavg()[2], 2) - - return state, value, update_time - - -@cache -def _disk_usage(path: str) -> Any: - return psutil.disk_usage(path) - - -@cache -def _swap_memory() -> Any: - return psutil.swap_memory() - - -@cache -def _virtual_memory() -> Any: - return psutil.virtual_memory() - - -@cache -def _net_io_counters() -> Any: - return psutil.net_io_counters(pernic=True) - - -@cache -def _net_if_addrs() -> Any: - return psutil.net_if_addrs() - - -@cache -def _getloadavg() -> tuple[float, float, float]: - return os.getloadavg() - - -def _read_cpu_temperature() -> float | None: - """Attempt to read CPU / processor temperature.""" - try: - temps = psutil.sensors_temperatures() - except AttributeError: - # Linux, macOS - return None - - for name, entries in temps.items(): - for i, entry in enumerate(entries, start=1): - # In case the label is empty (e.g. on Raspberry PI 4), - # construct it ourself here based on the sensor key name. - _label = f"{name} {i}" if not entry.label else entry.label - # check both name and label because some systems embed cpu# in the - # name, which makes label not match because label adds cpu# at end. - if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES: - return round(entry.current, 1) - - return None + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 75b437c19eb..293492b90e8 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -1,9 +1,11 @@ """Utils for System Monitor.""" - import logging import os import psutil +from psutil._common import shwtemp + +from .const import CPU_SENSOR_PREFIXES _LOGGER = logging.getLogger(__name__) @@ -61,3 +63,22 @@ def get_all_running_processes() -> set[str]: processes.add(proc.name()) _LOGGER.debug("Running processes: %s", ", ".join(processes)) return processes + + +def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float | None: + """Attempt to read CPU / processor temperature.""" + if not temps: + temps = psutil.sensors_temperatures() + entry: shwtemp + + for name, entries in temps.items(): + for i, entry in enumerate(entries, start=1): + # In case the label is empty (e.g. on Raspberry PI 4), + # construct it ourself here based on the sensor key name. + _label = f"{name} {i}" if not entry.label else entry.label + # check both name and label because some systems embed cpu# in the + # name, which makes label not match because label adds cpu# at end. + if _label in CPU_SENSOR_PREFIXES or name in CPU_SENSOR_PREFIXES: + return round(entry.current, 1) + + return None diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index b349e5cf5e1..c03c3fff2ca 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -115,11 +115,11 @@ def mock_process() -> list[MockProcess]: def mock_psutil(mock_process: list[MockProcess]) -> Mock: """Mock psutil.""" with patch( - "homeassistant.components.systemmonitor.sensor.psutil", + "homeassistant.components.systemmonitor.coordinator.psutil", autospec=True, ) as mock_psutil: mock_psutil.disk_usage.return_value = sdiskusage( - 500 * 1024**2, 300 * 1024**2, 200 * 1024**2, 60.0 + 500 * 1024**3, 300 * 1024**3, 200 * 1024**3, 60.0 ) mock_psutil.swap_memory.return_value = sswap( 100 * 1024**2, 60 * 1024**2, 40 * 1024**2, 60.0, 1, 1 @@ -240,7 +240,9 @@ def mock_util(mock_process) -> Mock: @pytest.fixture def mock_os() -> Mock: """Mock os.""" - with patch("homeassistant.components.systemmonitor.sensor.os") as mock_os, patch( + with patch( + "homeassistant.components.systemmonitor.coordinator.os" + ) as mock_os, patch( "homeassistant.components.systemmonitor.util.os" ) as mock_os_util: mock_os_util.name = "nt" diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index d39b23c8107..3708ca1e53a 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -9,7 +9,7 @@ }) # --- # name: test_sensor[System Monitor Disk free / - state] - '0.2' + '200.0' # --- # name: test_sensor[System Monitor Disk free /media/share - attributes] ReadOnlyDict({ @@ -21,7 +21,7 @@ }) # --- # name: test_sensor[System Monitor Disk free /media/share - state] - '0.2' + '200.0' # --- # name: test_sensor[System Monitor Disk usage / - attributes] ReadOnlyDict({ @@ -66,7 +66,7 @@ }) # --- # name: test_sensor[System Monitor Disk use / - state] - '0.3' + '300.0' # --- # name: test_sensor[System Monitor Disk use /media/share - attributes] ReadOnlyDict({ @@ -78,7 +78,7 @@ }) # --- # name: test_sensor[System Monitor Disk use /media/share - state] - '0.3' + '300.0' # --- # name: test_sensor[System Monitor IPv4 address eth0 - attributes] ReadOnlyDict({ diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 35ee331c699..8beeddbefdc 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -4,14 +4,11 @@ import socket from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory -from psutil._common import shwtemp, snetio, snicaddr +from psutil._common import sdiskusage, shwtemp, snetio, snicaddr import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.systemmonitor.sensor import ( - _read_cpu_temperature, - get_cpu_icon, -) +from homeassistant.components.systemmonitor.sensor import get_cpu_icon from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -218,11 +215,11 @@ async def test_sensor_process_fails( async def test_sensor_network_sensors( + freezer: FrozenDateTimeFactory, hass: HomeAssistant, entity_registry_enabled_by_default: None, mock_added_config_entry: ConfigEntry, mock_psutil: Mock, - freezer: FrozenDateTimeFactory, ) -> None: """Test process not exist failure.""" network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") @@ -306,41 +303,129 @@ async def test_missing_cpu_temperature( mock_psutil.sensors_temperatures.return_value = { "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] } + mock_util.sensors_temperatures.return_value = { + "not_exist": [shwtemp("not_exist", 50.0, 60.0, 70.0)] + } mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert "Cannot read CPU / processor temperature information" in caplog.text + # assert "Cannot read CPU / processor temperature information" in caplog.text temp_sensor = hass.states.get("sensor.system_monitor_processor_temperature") assert temp_sensor is None -async def test_processor_temperature() -> None: +async def test_processor_temperature( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_util: Mock, + mock_psutil: Mock, + mock_os: Mock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: """Test the disk failures.""" - with patch("sys.platform", "linux"), patch( - "homeassistant.components.systemmonitor.sensor.psutil" - ) as mock_psutil: + with patch("sys.platform", "linux"): mock_psutil.sensors_temperatures.return_value = { "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] } - temperature = _read_cpu_temperature() - assert temperature == 50.0 + mock_psutil.sensors_temperatures.side_effect = None + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == "50.0" + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("sys.platform", "nt"), patch( - "homeassistant.components.systemmonitor.sensor.psutil", - ) as mock_psutil: + with patch("sys.platform", "nt"): + mock_psutil.sensors_temperatures.return_value = None mock_psutil.sensors_temperatures.side_effect = AttributeError( "sensors_temperatures not exist" ) - temperature = _read_cpu_temperature() - assert temperature is None + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == STATE_UNAVAILABLE + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("sys.platform", "darwin"), patch( - "homeassistant.components.systemmonitor.sensor.psutil" - ) as mock_psutil: + with patch("sys.platform", "darwin"): mock_psutil.sensors_temperatures.return_value = { "cpu0-thermal": [shwtemp("cpu0-thermal", 50.0, 60.0, 70.0)] } - temperature = _read_cpu_temperature() - assert temperature == 50.0 + mock_psutil.sensors_temperatures.side_effect = None + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + temp_entity = hass.states.get("sensor.system_monitor_processor_temperature") + assert temp_entity.state == "50.0" + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_exception_handling_disk_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + mock_psutil: Mock, + mock_added_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor.""" + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == "200.0" # GiB + + mock_psutil.disk_usage.return_value = None + mock_psutil.disk_usage.side_effect = OSError("Could not update /") + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + "Error fetching System Monitor Disk / coordinator data: OS error for /" + in caplog.text + ) + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == STATE_UNAVAILABLE + + mock_psutil.disk_usage.return_value = None + mock_psutil.disk_usage.side_effect = PermissionError("No access to /") + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + "Error fetching System Monitor Disk / coordinator data: OS error for /" + in caplog.text + ) + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == STATE_UNAVAILABLE + + mock_psutil.disk_usage.return_value = sdiskusage( + 500 * 1024**3, 350 * 1024**3, 150 * 1024**3, 70.0 + ) + mock_psutil.disk_usage.side_effect = None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + disk_sensor = hass.states.get("sensor.system_monitor_disk_free") + assert disk_sensor is not None + assert disk_sensor.state == "150.0" + assert disk_sensor.attributes["unit_of_measurement"] == "GiB" + + disk_sensor = hass.states.get("sensor.system_monitor_disk_usage") + assert disk_sensor is not None + assert disk_sensor.state == "70.0" + assert disk_sensor.attributes["unit_of_measurement"] == "%" From db9312cf9c847f17732cef6219396804a9adfc02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jan 2024 08:23:35 +0100 Subject: [PATCH 0703/1544] Bump actions/cache from 3.3.3 to 4.0.0 (#108209) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 008dacd57a5..45a100092d3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -231,7 +231,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.0 with: path: venv key: >- @@ -246,7 +246,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -276,7 +276,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -285,7 +285,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -316,7 +316,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -325,7 +325,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -355,7 +355,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -364,7 +364,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -454,7 +454,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.0 with: path: venv lookup-only: true @@ -463,7 +463,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.0 with: path: ${{ env.PIP_CACHE }} key: >- @@ -517,7 +517,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -549,7 +549,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -582,7 +582,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -633,7 +633,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -641,7 +641,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.0 with: path: .mypy_cache key: >- @@ -708,7 +708,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -860,7 +860,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true @@ -984,7 +984,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.3 + uses: actions/cache/restore@v4.0.0 with: path: venv fail-on-cache-miss: true From 3a26bc3ee0acf5fd3a11ce55dba91ed68816ab1f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 17 Jan 2024 17:25:25 +1000 Subject: [PATCH 0704/1544] Fix translation keys in Tessie (#108203) --- homeassistant/components/tessie/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index be5be229c81..d8ccf47bb73 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -130,16 +130,16 @@ "climate_state_passenger_temp_setting": { "name": "Passenger temperature setting" }, - "active_route_traffic_minutes_delay": { + "drive_state_active_route_traffic_minutes_delay": { "name": "Traffic delay" }, - "active_route_energy_at_arrival": { + "drive_state_active_route_energy_at_arrival": { "name": "State of charge at arrival" }, - "active_route_miles_to_arrival": { + "drive_state_active_route_miles_to_arrival": { "name": "Distance to arrival" }, - "active_route_time_to_arrival": { + "drive_state_active_route_time_to_arrival": { "name": "Time to arrival" }, "drive_state_active_route_destination": { From a8b67d5a0ab0aa700e19fa3bd5ee591d1e3bff49 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 09:12:49 +0100 Subject: [PATCH 0705/1544] Add sensor platform to La Marzocco integration (#108157) * add sensor * remove switch * requested changes * property instead of function * add missing snapshot * rename var, fixture --- .../components/lamarzocco/__init__.py | 1 + .../components/lamarzocco/coordinator.py | 3 + homeassistant/components/lamarzocco/entity.py | 10 + homeassistant/components/lamarzocco/sensor.py | 113 ++++++++ .../components/lamarzocco/strings.json | 17 ++ tests/components/lamarzocco/conftest.py | 24 +- .../lamarzocco/snapshots/test_sensor.ambr | 244 ++++++++++++++++++ tests/components/lamarzocco/test_sensor.py | 72 ++++++ 8 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lamarzocco/sensor.py create mode 100644 tests/components/lamarzocco/snapshots/test_sensor.ambr create mode 100644 tests/components/lamarzocco/test_sensor.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 8bf48c3bf91..599721ced3a 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -8,6 +8,7 @@ from .const import DOMAIN from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 9b6341e0858..438c4e42634 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -32,6 +32,9 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): self.lm = LaMarzoccoClient( callback_websocket_notify=self.async_update_listeners, ) + self.local_connection_configured = ( + self.config_entry.data.get(CONF_HOST) is not None + ) async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index b2cb6dc2bff..4b8ccc86688 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -1,7 +1,9 @@ """Base class for the La Marzocco entities.""" +from collections.abc import Callable from dataclasses import dataclass +from lmcloud import LMCloud as LaMarzoccoClient from lmcloud.const import LaMarzoccoModel from homeassistant.helpers.device_registry import DeviceInfo @@ -16,6 +18,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" + available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True supported_models: tuple[LaMarzoccoModel, ...] = ( LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP, @@ -30,6 +33,13 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): entity_description: LaMarzoccoEntityDescription _attr_has_entity_name = True + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.lm + ) + def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py new file mode 100644 index 00000000000..bb811eaa890 --- /dev/null +++ b/homeassistant/components/lamarzocco/sensor.py @@ -0,0 +1,113 @@ +"""Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSensorEntityDescription( + LaMarzoccoEntityDescription, + SensorEntityDescription, +): + """Description of a La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoClient], float | int] + + +ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="drink_stats_coffee", + translation_key="drink_stats_coffee", + icon="mdi:chart-line", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("drinks_k1", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="drink_stats_flushing", + translation_key="drink_stats_flushing", + icon="mdi:chart-line", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("total_flushing", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="shot_timer", + translation_key="shot_timer", + icon="mdi:timer", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda lm: lm.current_status.get("brew_active_duration", 0), + available_fn=lambda lm: lm.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_coffee", + translation_key="current_temp_coffee", + icon="mdi:thermometer", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_steam", + translation_key="current_temp_steam", + icon="mdi:thermometer", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("steam_temp", 0), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[LaMarzoccoSensorEntity] = [] + for description in ENTITIES: + if coordinator.lm.model_name in description.supported_models: + if ( + description.key == "shot_timer" + and not coordinator.local_connection_configured + ): + continue + entities.append(LaMarzoccoSensorEntity(coordinator, description)) + + async_add_entities(entities) + + +class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor representing espresso machine temperature data.""" + + entity_description: LaMarzoccoSensorEntityDescription + + @property + def native_value(self) -> int | float: + """State of the sensor.""" + return self.entity_description.value_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 01bd3860825..759a9e327dc 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -43,6 +43,23 @@ } }, "entity": { + "sensor": { + "current_temp_coffee": { + "name": "Current coffee temperature" + }, + "current_temp_steam": { + "name": "Current steam temperature" + }, + "drink_stats_coffee": { + "name": "Total coffees made" + }, + "drink_stats_flushing": { + "name": "Total flushes made" + }, + "shot_timer": { + "name": "Shot timer" + } + }, "switch": { "auto_on_off": { "name": "Auto on/off" diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 98baac22d33..cc2d121e632 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -7,6 +7,7 @@ from lmcloud.const import LaMarzoccoModel import pytest from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from . import USER_INPUT, async_init_integration @@ -24,7 +25,8 @@ def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry: return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - data=USER_INPUT | {CONF_MACHINE: mock_lamarzocco.serial_number}, + data=USER_INPUT + | {CONF_MACHINE: mock_lamarzocco.serial_number, CONF_HOST: "host"}, unique_id=mock_lamarzocco.serial_number, ) @@ -100,5 +102,25 @@ def mock_lamarzocco( ] lamarzocco.check_local_connection.return_value = True lamarzocco.initialized = False + lamarzocco.websocket_connected = True + + async def websocket_connect_mock( + callback: MagicMock, use_sigterm_handler: MagicMock + ) -> None: + """Mock the websocket connect method.""" + return None + + lamarzocco.lm_local_api.websocket_connect = websocket_connect_mock yield lamarzocco + + +@pytest.fixture +def remove_local_connection( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Remove the local connection.""" + data = mock_config_entry.data.copy() + del data[CONF_HOST] + hass.config_entries.async_update_entry(mock_config_entry, data=data) + return mock_config_entry diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e3719a25a33 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -0,0 +1,244 @@ +# serializer version: 1 +# name: test_sensors[GS01234_current_coffee_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.gs01234_current_coffee_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Current coffee temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_coffee', + 'unique_id': 'GS01234_current_temp_coffee', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_coffee_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current coffee temperature', + 'icon': 'mdi:thermometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'last_changed': , + 'last_updated': , + 'state': '93', + }) +# --- +# name: test_sensors[GS01234_current_steam_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.gs01234_current_steam_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Current steam temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_steam', + 'unique_id': 'GS01234_current_temp_steam', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_steam_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current steam temperature', + 'icon': 'mdi:thermometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'last_changed': , + 'last_updated': , + 'state': '113', + }) +# --- +# name: test_sensors[GS01234_shot_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_shot_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer', + 'original_name': 'Shot timer', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shot_timer', + 'unique_id': 'GS01234_shot_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_shot_timer-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Shot timer', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_shot_timer', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_total_coffees_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:chart-line', + 'original_name': 'Total coffees made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee', + 'unique_id': 'GS01234_drink_stats_coffee', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total coffees made', + 'icon': 'mdi:chart-line', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_coffees_made', + 'last_changed': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_total_flushes_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:chart-line', + 'original_name': 'Total flushes made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_flushing', + 'unique_id': 'GS01234_drink_stats_flushing', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total flushes made', + 'icon': 'mdi:chart-line', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_flushes_made', + 'last_changed': , + 'last_updated': , + 'state': '69', + }) +# --- diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py new file mode 100644 index 00000000000..3333fed1464 --- /dev/null +++ b/tests/components/lamarzocco/test_sensor.py @@ -0,0 +1,72 @@ +"""Tests for La Marzocco sensors.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +SENSORS = ( + "total_coffees_made", + "total_flushes_made", + "shot_timer", + "current_coffee_temperature", + "current_steam_temperature", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco sensors.""" + + serial_number = mock_lamarzocco.serial_number + + await async_init_integration(hass, mock_config_entry) + + for sensor in SENSORS: + state = hass.states.get(f"sensor.{serial_number}_{sensor}") + assert state + assert state == snapshot(name=f"{serial_number}_{sensor}-sensor") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"{serial_number}_{sensor}-entry") + + +@pytest.mark.usefixtures("remove_local_connection") +async def test_shot_timer_not_exists( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco shot timer doesn't exist if host not set.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state is None + + +async def test_shot_timer_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco brew_active becomes unavailable.""" + + mock_lamarzocco.websocket_connected = False + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state + assert state.state == STATE_UNAVAILABLE From 44f2b8e6a3c1eef8b095d8989ca9eb06760b2316 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Wed, 17 Jan 2024 05:04:35 -0500 Subject: [PATCH 0706/1544] Implement TechnoVE integration (#106029) * Implement TechnoVE integration Only the basic sensors for now. * Add technoVE to strict typing * Implement TechnoVE PR suggestions * Remove Diagnostic from TechnoVE initial PR * Switch status sensor to Enum device class * Revert zeroconf for adding it back in subsequent PR * Implement changes from feedback in TechnoVE PR * Update homeassistant/components/technove/models.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/technove/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/technove/models.py Co-authored-by: Joost Lekkerkerker * Remove unnecessary translation keys * Fix existing technoVE tests * Use snapshot testing for TechnoVE sensors * Improve unit tests for TechnoVE * Add missing coverage for technoVE config flow * Add TechnoVE coordinator tests * Modify device_fixture for TechnoVE from PR Feedback * Change CONF_IP_ADDRESS to CONF_HOST for TechnoVE * Update homeassistant/components/technove/config_flow.py Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Update homeassistant/components/technove/models.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/technove/models.py Co-authored-by: Joost Lekkerkerker * Implement feedback from TechnoVE PR * Add test_sensor_update_failure to TechnoVE sensor tests * Add test for error recovery during config flow of TechnoVE * Remove test_coordinator.py from TechnoVE --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/technove/__init__.py | 31 + .../components/technove/config_flow.py | 50 ++ homeassistant/components/technove/const.py | 8 + .../components/technove/coordinator.py | 40 ++ homeassistant/components/technove/entity.py | 26 + .../components/technove/manifest.json | 10 + homeassistant/components/technove/sensor.py | 161 +++++ .../components/technove/strings.json | 56 ++ 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/technove/__init__.py | 1 + tests/components/technove/conftest.py | 66 ++ .../technove/fixtures/station_charging.json | 27 + .../technove/snapshots/test_sensor.ambr | 635 ++++++++++++++++++ tests/components/technove/test_config_flow.py | 104 +++ tests/components/technove/test_init.py | 36 + tests/components/technove/test_sensor.py | 97 +++ 22 files changed, 1374 insertions(+) create mode 100644 homeassistant/components/technove/__init__.py create mode 100644 homeassistant/components/technove/config_flow.py create mode 100644 homeassistant/components/technove/const.py create mode 100644 homeassistant/components/technove/coordinator.py create mode 100644 homeassistant/components/technove/entity.py create mode 100644 homeassistant/components/technove/manifest.json create mode 100644 homeassistant/components/technove/sensor.py create mode 100644 homeassistant/components/technove/strings.json create mode 100644 tests/components/technove/__init__.py create mode 100644 tests/components/technove/conftest.py create mode 100644 tests/components/technove/fixtures/station_charging.json create mode 100644 tests/components/technove/snapshots/test_sensor.ambr create mode 100644 tests/components/technove/test_config_flow.py create mode 100644 tests/components/technove/test_init.py create mode 100644 tests/components/technove/test_sensor.py diff --git a/.strict-typing b/.strict-typing index a4238292b6b..e5de22ce608 100644 --- a/.strict-typing +++ b/.strict-typing @@ -404,6 +404,7 @@ homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.text.* homeassistant.components.threshold.* diff --git a/CODEOWNERS b/CODEOWNERS index d0e0bddf0d6..73110f757fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1328,6 +1328,8 @@ build.json @home-assistant/supervisor /tests/components/tasmota/ @emontnemery /homeassistant/components/tautulli/ @ludeeus @tkdrob /tests/components/tautulli/ @ludeeus @tkdrob +/homeassistant/components/technove/ @Moustachauve +/tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py new file mode 100644 index 00000000000..12ca604af45 --- /dev/null +++ b/homeassistant/components/technove/__init__.py @@ -0,0 +1,31 @@ +"""The TechnoVE integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up TechnoVE from a config entry.""" + coordinator = TechnoVEDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py new file mode 100644 index 00000000000..a08d3030018 --- /dev/null +++ b/homeassistant/components/technove/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for TechnoVE.""" + +from typing import Any + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for TechnoVE.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + try: + station = await self._async_get_station(user_input[CONF_HOST]) + except TechnoVEConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(station.info.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( + title=station.info.name, + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def _async_get_station(self, host: str) -> TechnoVEStation: + """Get information from a TechnoVE station.""" + api = TechnoVE(host, session=async_get_clientsession(self.hass)) + return await api.update() diff --git a/homeassistant/components/technove/const.py b/homeassistant/components/technove/const.py new file mode 100644 index 00000000000..6dd7d567353 --- /dev/null +++ b/homeassistant/components/technove/const.py @@ -0,0 +1,8 @@ +"""Constants for the TechnoVE integration.""" +from datetime import timedelta +import logging + +DOMAIN = "technove" + +LOGGER = logging.getLogger(__package__) +SCAN_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py new file mode 100644 index 00000000000..66ec7d979f3 --- /dev/null +++ b/homeassistant/components/technove/coordinator.py @@ -0,0 +1,40 @@ +"""DataUpdateCoordinator for TechnoVE.""" +from __future__ import annotations + +from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class TechnoVEDataUpdateCoordinator(DataUpdateCoordinator[TechnoVEStation]): + """Class to manage fetching TechnoVE data from single endpoint.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize global TechnoVE data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.technove = TechnoVE( + self.config_entry.data[CONF_HOST], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> TechnoVEStation: + """Fetch data from TechnoVE.""" + try: + station = await self.technove.update() + except TechnoVEError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error + + return station diff --git a/homeassistant/components/technove/entity.py b/homeassistant/components/technove/entity.py new file mode 100644 index 00000000000..964f2941301 --- /dev/null +++ b/homeassistant/components/technove/entity.py @@ -0,0 +1,26 @@ +"""Entity for TechnoVE.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator + + +class TechnoVEEntity(CoordinatorEntity[TechnoVEDataUpdateCoordinator]): + """Defines a base TechnoVE entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TechnoVEDataUpdateCoordinator, key: str) -> None: + """Initialize a base TechnoVE entity.""" + super().__init__(coordinator) + info = self.coordinator.data.info + self._attr_unique_id = f"{info.mac_address}_{key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, info.mac_address)}, + identifiers={(DOMAIN, info.mac_address)}, + name=info.name, + manufacturer="TechnoVE", + model=f"TechnoVE i{info.max_station_current}", + sw_version=info.version, + ) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json new file mode 100644 index 00000000000..c5177d047f9 --- /dev/null +++ b/homeassistant/components/technove/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "technove", + "name": "TechnoVE", + "codeowners": ["@Moustachauve"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/technove", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["python-technove==1.1.1"] +} diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py new file mode 100644 index 00000000000..99cdc62ceee --- /dev/null +++ b/homeassistant/components/technove/sensor.py @@ -0,0 +1,161 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from technove import Station as TechnoVEStation, Status + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity + +STATUS_TYPE = [s.value for s in Status] + + +@dataclass(frozen=True, kw_only=True) +class TechnoVESensorEntityDescription(SensorEntityDescription): + """Describes TechnoVE sensor entity.""" + + value_fn: Callable[[TechnoVEStation], StateType] + + +SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( + TechnoVESensorEntityDescription( + key="voltage_in", + translation_key="voltage_in", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_in, + ), + TechnoVESensorEntityDescription( + key="voltage_out", + translation_key="voltage_out", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.voltage_out, + ), + TechnoVESensorEntityDescription( + key="max_current", + translation_key="max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.max_current, + ), + TechnoVESensorEntityDescription( + key="max_station_current", + translation_key="max_station_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.max_station_current, + ), + TechnoVESensorEntityDescription( + key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.current, + ), + TechnoVESensorEntityDescription( + key="energy_total", + translation_key="energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_total, + ), + TechnoVESensorEntityDescription( + key="energy_session", + translation_key="energy_session", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.energy_session, + ), + TechnoVESensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.rssi, + ), + TechnoVESensorEntityDescription( + key="ssid", + translation_key="ssid", + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.network_ssid, + ), + TechnoVESensorEntityDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=STATUS_TYPE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.status.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TechnoVESensorEntity(coordinator, description) for description in SENSORS + ) + + +class TechnoVESensorEntity(TechnoVEEntity, SensorEntity): + """Defines a TechnoVE sensor entity.""" + + entity_description: TechnoVESensorEntityDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVESensorEntityDescription, + ) -> None: + """Initialize a TechnoVE sensor entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json new file mode 100644 index 00000000000..98813fd3cc8 --- /dev/null +++ b/homeassistant/components/technove/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up your TechnoVE station to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TechnoVE station." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "voltage_in": { + "name": "Input voltage" + }, + "voltage_out": { + "name": "Output voltage" + }, + "max_current": { + "name": "Max current" + }, + "max_station_current": { + "name": "Max station current" + }, + "energy_total": { + "name": "Total energy usage" + }, + "energy_session": { + "name": "Last session energy usage" + }, + "ssid": { + "name": "Wi-Fi network name" + }, + "status": { + "name": "Status", + "state": { + "unplugged": "Unplugged", + "plugged_waiting": "Plugged, waiting", + "plugged_charging": "Plugged, charging" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 603a8e33e2c..91a572e1514 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -508,6 +508,7 @@ FLOWS = { "tankerkoenig", "tasmota", "tautulli", + "technove", "tedee", "tellduslive", "tesla_wall_connector", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 383661410cc..2aa315a2daf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5837,6 +5837,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "technove": { + "name": "TechnoVE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "ted5000": { "name": "The Energy Detective TED5000", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 1b352a72f18..84e0a494f65 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3802,6 +3802,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.technove.*] +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.tedee.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index efec679a17f..3b7b6136abb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,6 +2264,9 @@ python-songpal==0.16 # homeassistant.components.tado python-tado==0.17.3 +# homeassistant.components.technove +python-technove==1.1.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c0049de465..6bbcf048e30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1725,6 +1725,9 @@ python-songpal==0.16 # homeassistant.components.tado python-tado==0.17.3 +# homeassistant.components.technove +python-technove==1.1.1 + # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/tests/components/technove/__init__.py b/tests/components/technove/__init__.py new file mode 100644 index 00000000000..e98470b8e2a --- /dev/null +++ b/tests/components/technove/__init__.py @@ -0,0 +1 @@ +"""Tests for the TechnoVE integration.""" diff --git a/tests/components/technove/conftest.py b/tests/components/technove/conftest.py new file mode 100644 index 00000000000..03ee9fd9663 --- /dev/null +++ b/tests/components/technove/conftest.py @@ -0,0 +1,66 @@ +"""Fixtures for TechnoVE integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from technove import Station as TechnoVEStation + +from homeassistant.components.technove.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123"}, + unique_id="AA:AA:AA:AA:AA:BB", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.technove.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def device_fixture() -> TechnoVEStation: + """Return the device fixture for a specific device.""" + return TechnoVEStation(load_json_object_fixture("station_charging.json", DOMAIN)) + + +@pytest.fixture +def mock_technove(device_fixture: TechnoVEStation) -> Generator[MagicMock, None, None]: + """Return a mocked TechnoVE client.""" + with patch( + "homeassistant.components.technove.coordinator.TechnoVE", autospec=True + ) as technove_mock, patch( + "homeassistant.components.technove.config_flow.TechnoVE", new=technove_mock + ): + technove = technove_mock.return_value + technove.update.return_value = device_fixture + technove.ip_address = "127.0.0.1" + yield technove + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> MockConfigEntry: + """Set up the TechnoVE integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/technove/fixtures/station_charging.json b/tests/components/technove/fixtures/station_charging.json new file mode 100644 index 00000000000..ea98dc0b071 --- /dev/null +++ b/tests/components/technove/fixtures/station_charging.json @@ -0,0 +1,27 @@ +{ + "voltageIn": 238, + "voltageOut": 238, + "maxStationCurrent": 32, + "maxCurrent": 24, + "current": 23.75, + "network_ssid": "Connecting...", + "id": "AA:AA:AA:AA:AA:BB", + "auto_charge": true, + "highChargePeriodActive": false, + "normalPeriodActive": false, + "maxChargePourcentage": 0.9, + "isBatteryProtected": false, + "inSharingMode": true, + "energySession": 12.34, + "energyTotal": 1234, + "version": "1.82", + "rssi": -82, + "name": "TechnoVE Station", + "lastCharge": "1701072080,0,17.39\n", + "time": 1701000000, + "isUpToDate": true, + "isSessionActive": true, + "conflictInSharingConfig": false, + "isStaticIp": false, + "status": 67 +} diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e0549c1dad1 --- /dev/null +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_sensors[sensor.technove_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_current', + 'last_changed': , + 'last_updated': , + 'state': '23.75', + }) +# --- +# name: test_sensors[sensor.technove_station_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_current', + 'last_changed': , + 'last_updated': , + 'state': '23.75', + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_in', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_in', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_input_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Input voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_input_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last session energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_session', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_session', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Last session energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.34', + }) +# --- +# name: test_sensors[sensor.technove_station_last_session_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Last session energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_last_session_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.34', + }) +# --- +# name: test_sensors[sensor.technove_station_max_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_max_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_max_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_current', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.technove_station_max_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_current', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max station current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_station_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_station_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max station current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.technove_station_max_station_current] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Max station current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_max_station_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output voltage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_out', + 'unique_id': 'AA:AA:AA:AA:AA:BB_voltage_out', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Output voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_output_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'TechnoVE Station Output voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_output_voltage', + 'last_changed': , + 'last_updated': , + 'state': '238', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TechnoVE Station Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'last_changed': , + 'last_updated': , + 'state': '-82', + }) +# --- +# name: test_sensors[sensor.technove_station_signal_strength] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'TechnoVE Station Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.technove_station_signal_strength', + 'last_changed': , + 'last_updated': , + 'state': '-82', + }) +# --- +# name: test_sensors[sensor.technove_station_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'AA:AA:AA:AA:AA:BB_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'TechnoVE Station Status', + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.technove_station_status', + 'last_changed': , + 'last_updated': , + 'state': 'plugged_charging', + }) +# --- +# name: test_sensors[sensor.technove_station_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'TechnoVE Station Status', + 'options': list([ + 'unplugged', + 'plugged_waiting', + 'plugged_charging', + ]), + }), + 'context': , + 'entity_id': 'sensor.technove_station_status', + 'last_changed': , + 'last_updated': , + 'state': 'plugged_charging', + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy usage', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_total', + 'unique_id': 'AA:AA:AA:AA:AA:BB_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Total energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.technove_station_total_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'TechnoVE Station Total energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.technove_station_total_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Wi-Fi network name', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': 'AA:AA:AA:AA:AA:BB_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Wi-Fi network name', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'last_changed': , + 'last_updated': , + 'state': 'Connecting...', + }) +# --- +# name: test_sensors[sensor.technove_station_wi_fi_network_name] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Wi-Fi network name', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'sensor.technove_station_wi_fi_network_name', + 'last_changed': , + 'last_updated': , + 'state': 'Connecting...', + }) +# --- diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py new file mode 100644 index 00000000000..7a631580ff4 --- /dev/null +++ b/tests/components/technove/test_config_flow.py @@ -0,0 +1,104 @@ +"""Tests for the TechnoVE config flow.""" + +from unittest.mock import MagicMock + +import pytest +from technove import TechnoVEConnectionError + +from homeassistant.components.technove.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_technove") +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test we abort the config flow if TechnoVE station is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_connection_error(hass: HomeAssistant, mock_technove: MagicMock) -> None: + """Test we show user form on TechnoVE connection error.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.com"}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_user_flow_with_error( + hass: HomeAssistant, mock_technove: MagicMock +) -> None: + """Test the full manual user flow from start to finish with some errors in the middle.""" + mock_technove.update.side_effect = TechnoVEConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + mock_technove.update.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" diff --git a/tests/components/technove/test_init.py b/tests/components/technove/test_init.py new file mode 100644 index 00000000000..0b5d68e405d --- /dev/null +++ b/tests/components/technove/test_init.py @@ -0,0 +1,36 @@ +"""Tests for the TechnoVE integration.""" + +from unittest.mock import MagicMock + +from technove import TechnoVEConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test a successful setup entry and unload.""" + + init_integration.add_to_hass(hass) + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_connection_error( + hass: HomeAssistant, + mock_technove: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a connection error after setup.""" + mock_technove.update.side_effect = TechnoVEConnectionError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py new file mode 100644 index 00000000000..d7010b9451c --- /dev/null +++ b/tests/components/technove/test_sensor.py @@ -0,0 +1,97 @@ +"""Tests for the TechnoVE sensor platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from technove import Status, TechnoVEError + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE sensors.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.technove_station_signal_strength", + "sensor.technove_station_wi_fi_network_name", + ), +) +@pytest.mark.usefixtures("init_integration") +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> None: + """Test the disabled by default TechnoVE sensors.""" + assert hass.states.get(entity_id) is None + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_wifi_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_technove: MagicMock, +) -> None: + """Test missing Wi-Fi information from TechnoVE device.""" + # Remove Wi-Fi info + device = mock_technove.update.return_value + device.info.network_ssid = None + + # Setup + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.technove_station_wi_fi_network_name")) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_update_failure( + hass: HomeAssistant, + mock_technove: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update failure.""" + entity_id = "sensor.technove_station_status" + + assert hass.states.get(entity_id).state == Status.PLUGGED_CHARGING.value + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + mock_technove.update.side_effect = TechnoVEError("Test error") + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From a25653e167dccc47236fc3c6dd7cd924010f9eb4 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:07:43 +0100 Subject: [PATCH 0707/1544] Change the way an entity is supported in La Marzocco (#108216) * refactor supported * refactor supported --- homeassistant/components/lamarzocco/entity.py | 8 +------- homeassistant/components/lamarzocco/sensor.py | 17 ++++++----------- homeassistant/components/lamarzocco/switch.py | 2 +- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 4b8ccc86688..6918741f1d3 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import dataclass from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import LaMarzoccoModel from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -19,12 +18,7 @@ class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True - supported_models: tuple[LaMarzoccoModel, ...] = ( - LaMarzoccoModel.GS3_AV, - LaMarzoccoModel.GS3_MP, - LaMarzoccoModel.LINEA_MICRA, - LaMarzoccoModel.LINEA_MINI, - ) + supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index bb811eaa890..63292b95ae3 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -59,6 +59,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( value_fn=lambda lm: lm.current_status.get("brew_active_duration", 0), available_fn=lambda lm: lm.websocket_connected, entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda coordinator: coordinator.local_connection_configured, ), LaMarzoccoSensorEntityDescription( key="current_temp_coffee", @@ -89,17 +90,11 @@ async def async_setup_entry( """Set up sensor entities.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LaMarzoccoSensorEntity] = [] - for description in ENTITIES: - if coordinator.lm.model_name in description.supported_models: - if ( - description.key == "shot_timer" - and not coordinator.local_connection_configured - ): - continue - entities.append(LaMarzoccoSensorEntity(coordinator, description)) - - async_add_entities(entities) + async_add_entities( + LaMarzoccoSensorEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 4c39bd2c5f0..fe9c6daa9cf 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -67,7 +67,7 @@ async def async_setup_entry( async_add_entities( LaMarzoccoSwitchEntity(coordinator, description) for description in ENTITIES - if coordinator.lm.model_name in description.supported_models + if description.supported_fn(coordinator) ) From e811cf1ae816009a23fad2dcb0c3e8a559bfb542 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 11:42:22 +0100 Subject: [PATCH 0708/1544] Add binary sensor platforms to La Marzocco (#108212) * add sensor * remove switch * requested changes * property instead of function * add missing snapshot * rename var, fixture * add binary sensors * reorder strings * rename sensor * switch to supported_fn --- .../components/lamarzocco/__init__.py | 1 + .../components/lamarzocco/binary_sensor.py | 90 ++++++++++++++++++ .../components/lamarzocco/strings.json | 8 ++ .../snapshots/test_binary_sensor.ambr | 91 +++++++++++++++++++ .../lamarzocco/test_binary_sensor.py | 71 +++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 homeassistant/components/lamarzocco/binary_sensor.py create mode 100644 tests/components/lamarzocco/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/lamarzocco/test_binary_sensor.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 599721ced3a..a5ebf727071 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -8,6 +8,7 @@ from .const import DOMAIN from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py new file mode 100644 index 00000000000..a0f4033710c --- /dev/null +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -0,0 +1,90 @@ +"""Binary Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoBinarySensorEntityDescription( + LaMarzoccoEntityDescription, + BinarySensorEntityDescription, +): + """Description of a La Marzocco binary sensor.""" + + is_on_fn: Callable[[LaMarzoccoClient], bool] + icon_on: str + icon_off: str + + +ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( + LaMarzoccoBinarySensorEntityDescription( + key="water_tank", + translation_key="water_tank", + device_class=BinarySensorDeviceClass.PROBLEM, + icon_on="mdi:water-remove", + icon_off="mdi:water-check", + is_on_fn=lambda lm: not lm.current_status.get("water_reservoir_contact"), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda coordinator: coordinator.local_connection_configured, + ), + LaMarzoccoBinarySensorEntityDescription( + key="brew_active", + translation_key="brew_active", + device_class=BinarySensorDeviceClass.RUNNING, + icon_off="mdi:cup-off", + icon_on="mdi:cup-water", + is_on_fn=lambda lm: bool(lm.current_status.get("brew_active")), + available_fn=lambda lm: lm.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoBinarySensorEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): + """Binary Sensor representing espresso machine water reservoir status.""" + + entity_description: LaMarzoccoBinarySensorEntityDescription + + @property + def icon(self) -> str | None: + """Return the icon.""" + return ( + self.entity_description.icon_on + if self.is_on + else self.entity_description.icon_off + ) + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 759a9e327dc..db4f443b18b 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -43,6 +43,14 @@ } }, "entity": { + "binary_sensor": { + "brew_active": { + "name": "Brewing active" + }, + "water_tank": { + "name": "Water tank empty" + } + }, "sensor": { "current_temp_coffee": { "name": "Current coffee temperature" diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..4fb8c3cb828 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -0,0 +1,91 @@ +# serializer version: 1 +# name: test_binary_sensors[GS01234_brewing_active-binary_sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'GS01234 Brewing active', + 'icon': 'mdi:cup-off', + }), + 'context': , + 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[GS01234_brewing_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gs01234_brewing_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cup-off', + 'original_name': 'Brewing active', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brew_active', + 'unique_id': 'GS01234_brew_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[GS01234_water_tank_empty-binary_sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'GS01234 Water tank empty', + 'icon': 'mdi:water-check', + }), + 'context': , + 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[GS01234_water_tank_empty-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gs01234_water_tank_empty', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:water-check', + 'original_name': 'Water tank empty', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_tank', + 'unique_id': 'GS01234_water_tank', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py new file mode 100644 index 00000000000..e475e663768 --- /dev/null +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -0,0 +1,71 @@ +"""Tests for La Marzocco binary sensors.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +BINARY_SENSORS = ( + "brewing_active", + "water_tank_empty", +) + + +async def test_binary_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco binary sensors.""" + + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + + for binary_sensor in BINARY_SENSORS: + state = hass.states.get(f"binary_sensor.{serial_number}_{binary_sensor}") + assert state + assert state == snapshot(name=f"{serial_number}_{binary_sensor}-binary_sensor") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"{serial_number}_{binary_sensor}-entry") + + +@pytest.mark.usefixtures("remove_local_connection") +async def test_brew_active_does_not_exists( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") + assert state is None + + +async def test_brew_active_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco currently_making_coffee becomes unavailable.""" + + mock_lamarzocco.websocket_connected = False + await async_init_integration(hass, mock_config_entry) + state = hass.states.get( + f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" + ) + assert state + assert state.state == STATE_UNAVAILABLE From bdda38f27448f4edc4a3022d51b3f5159f933b4a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 17 Jan 2024 11:54:13 +0100 Subject: [PATCH 0709/1544] Allow selecting camera in Trafikverket Camera (#105927) * Allow selecting camera in Trafikverket Camera * Final config flow * Add tests * Fix load_int * naming --- .../trafikverket_camera/config_flow.py | 91 +++++++++++++------ .../trafikverket_camera/strings.json | 8 +- .../trafikverket_camera/conftest.py | 59 ++++++++++++ .../trafikverket_camera/test_config_flow.py | 82 ++++++++++++----- 4 files changed, 187 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index a5257455e7a..9db27eda622 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -4,12 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pytrafikverket.exceptions import ( - InvalidAuthentication, - MultipleCamerasFound, - NoCameraFound, - UnknownError, -) +from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol @@ -17,7 +12,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) from .const import DOMAIN @@ -28,34 +29,28 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 3 entry: config_entries.ConfigEntry | None + cameras: list[CameraInfo] + api_key: str async def validate_input( self, sensor_api: str, location: str - ) -> tuple[dict[str, str], str | None, str | None]: + ) -> tuple[dict[str, str], list[CameraInfo] | None]: """Validate input from user input.""" errors: dict[str, str] = {} - camera_info: CameraInfo | None = None - camera_location: str | None = None - camera_id: str | None = None + cameras: list[CameraInfo] | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) try: - camera_info = await camera_api.async_get_camera(location) + cameras = await camera_api.async_get_cameras(location) except NoCameraFound: errors["location"] = "invalid_location" - except MultipleCamerasFound: - errors["location"] = "more_locations" except InvalidAuthentication: errors["base"] = "invalid_auth" except UnknownError: errors["base"] = "cannot_connect" - if camera_info: - camera_id = camera_info.camera_id - camera_location = camera_info.camera_name or "Trafikverket Camera" - - return (errors, camera_location, camera_id) + return (errors, cameras) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -73,7 +68,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) + errors, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -106,17 +101,18 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors, camera_location, camera_id = await self.validate_input( - api_key, location - ) + errors, cameras = await self.validate_input(api_key, location) - if not errors: - assert camera_location - await self.async_set_unique_id(f"{DOMAIN}-{camera_id}") + if not errors and cameras: + if len(cameras) > 1: + self.cameras = cameras + self.api_key = api_key + return await self.async_step_multiple_cameras() + await self.async_set_unique_id(f"{DOMAIN}-{cameras[0].camera_id}") self._abort_if_unique_id_configured() return self.async_create_entry( - title=camera_location, - data={CONF_API_KEY: api_key, CONF_ID: camera_id}, + title=cameras[0].camera_name or "Trafikverket Camera", + data={CONF_API_KEY: api_key, CONF_ID: cameras[0].camera_id}, ) return self.async_show_form( @@ -129,3 +125,42 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_multiple_cameras( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle when multiple cameras.""" + + if user_input: + errors, cameras = await self.validate_input( + self.api_key, user_input[CONF_ID] + ) + + if not errors and cameras: + await self.async_set_unique_id(f"{DOMAIN}-{cameras[0].camera_id}") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=cameras[0].camera_name or "Trafikverket Camera", + data={CONF_API_KEY: self.api_key, CONF_ID: cameras[0].camera_id}, + ) + + camera_choices = [ + SelectOptionDict( + value=f"{camera_info.camera_id}", + label=f"{camera_info.camera_id} - {camera_info.camera_name} - {camera_info.location}", + ) + for camera_info in self.cameras + ] + + return self.async_show_form( + step_id="multiple_cameras", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=camera_choices, mode=SelectSelectorMode.LIST + ) + ), + } + ), + ) diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 35dbbb1f540..e3a1ceec4c0 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -17,7 +17,13 @@ "location": "[%key:common::config_flow::data::location%]" }, "data_description": { - "location": "Equal or part of name, description or camera id" + "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result" + } + }, + "multiple_cameras": { + "description": "Result came back with multiple cameras", + "data": { + "id": "Choose camera" } } } diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a5eeb707b34..92693ccf3c2 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -70,6 +70,65 @@ def fixture_get_camera() -> CameraInfo: ) +@pytest.fixture(name="get_camera2") +def fixture_get_camera2() -> CameraInfo: + """Construct Camera Mock 2.""" + + return CameraInfo( + camera_name="Test Camera2", + camera_id="5678", + active=True, + deleted=False, + description="Test Camera for testing2", + direction="180", + fullsizephoto=True, + location="Test location2", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo2.jpg", + status="Running", + camera_type="Road", + ) + + +@pytest.fixture(name="get_cameras") +def fixture_get_cameras() -> CameraInfo: + """Construct Camera Mock with multiple cameras.""" + + return [ + CameraInfo( + camera_name="Test Camera", + camera_id="1234", + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location="Test location", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ), + CameraInfo( + camera_name="Test Camera2", + camera_id="5678", + active=True, + deleted=False, + description="Test Camera for testing2", + direction="180", + fullsizephoto=True, + location="Test location2", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo2.jpg", + status="Running", + camera_type="Road", + ), + ] + + @pytest.fixture(name="get_camera_no_location") def fixture_get_camera_no_location() -> CameraInfo: """Construct Camera Mock.""" diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index ca1d8554c4a..005c6006d81 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -4,12 +4,7 @@ from __future__ import annotations from unittest.mock import patch import pytest -from pytrafikverket.exceptions import ( - InvalidAuthentication, - MultipleCamerasFound, - NoCameraFound, - UnknownError, -) +from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries @@ -31,8 +26,8 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", - return_value=get_camera, + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera], ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -56,6 +51,55 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: assert result2["result"].unique_id == "trafikverket_camera-1234" +async def test_form_multiple_cameras( + hass: HomeAssistant, get_cameras: list[CameraInfo], get_camera2: CameraInfo +) -> None: + """Test we get the form with multiple cameras.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=get_cameras, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test loc", + }, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera2], + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Camera2" + assert result["data"] == { + "api_key": "1234567890", + "id": "5678", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result["result"].unique_id == "trafikverket_camera-5678" + + async def test_form_no_location_data( hass: HomeAssistant, get_camera_no_location: CameraInfo ) -> None: @@ -68,8 +112,8 @@ async def test_form_no_location_data( assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", - return_value=get_camera_no_location, + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", + return_value=[get_camera_no_location], ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -106,11 +150,6 @@ async def test_form_no_location_data( "location", "invalid_location", ), - ( - MultipleCamerasFound, - "location", - "more_locations", - ), ( UnknownError, "base", @@ -130,7 +169,7 @@ async def test_flow_fails( assert result4["step_id"] == config_entries.SOURCE_USER with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", side_effect=side_effect, ): result4 = await hass.config_entries.flow.async_configure( @@ -171,7 +210,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -203,11 +242,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "location", "invalid_location", ), - ( - MultipleCamerasFound, - "location", - "more_locations", - ), ( UnknownError, "base", @@ -242,7 +276,7 @@ async def test_reauth_flow_error( ) with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( @@ -256,7 +290,7 @@ async def test_reauth_flow_error( assert result2["errors"] == {error_key: p_error} with patch( - "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_cameras", ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, From 1b2a4d2bf3445eb777cc5aaa7469343b789b9ca9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:11:34 +0100 Subject: [PATCH 0710/1544] Improve aurora typing (#108217) --- homeassistant/components/aurora/binary_sensor.py | 4 +++- homeassistant/components/aurora/config_flow.py | 2 +- homeassistant/components/aurora/coordinator.py | 5 +++-- homeassistant/components/aurora/sensor.py | 4 +++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index d817ea51988..49e25e55950 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -1,4 +1,6 @@ """Support for Aurora Forecast binary sensor.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -27,6 +29,6 @@ class AuroraSensor(AuroraEntity, BinarySensorEntity): """Implementation of an aurora sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if aurora is visible.""" return self.coordinator.data > self.coordinator.threshold diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 95e66ff226e..a1971884ead 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -51,7 +51,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: longitude = user_input[CONF_LONGITUDE] diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 9d4eb0aa681..8195f6d30ec 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,4 +1,5 @@ """The aurora component.""" +from __future__ import annotations from datetime import timedelta import logging @@ -12,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) -class AuroraDataUpdateCoordinator(DataUpdateCoordinator): +class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): """Class to manage fetching data from the NOAA Aurora API.""" def __init__( @@ -37,7 +38,7 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): self.longitude = int(longitude) self.threshold = int(threshold) - async def _async_update_data(self): + async def _async_update_data(self) -> int: """Fetch the data from the NOAA Aurora Forecast.""" try: diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index f44cc23f832..3ad36591f15 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,4 +1,6 @@ """Support for Aurora Forecast sensor.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE @@ -31,6 +33,6 @@ class AuroraSensor(AuroraEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT @property - def native_value(self): + def native_value(self) -> int: """Return % chance the aurora is visible.""" return self.coordinator.data From 74d53a4231eb52fb2571f643615b6ae39a496a3a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 13:21:33 +0100 Subject: [PATCH 0711/1544] Add select platform to La Marzocco integration (#108222) * add select * change check, icons * fix docstrings, use [] --- .../components/lamarzocco/__init__.py | 1 + homeassistant/components/lamarzocco/select.py | 92 ++++++++ .../components/lamarzocco/strings.json | 18 ++ .../lamarzocco/fixtures/current_status.json | 1 + .../lamarzocco/snapshots/test_select.ambr | 221 ++++++++++++++++++ tests/components/lamarzocco/test_select.py | 124 ++++++++++ 6 files changed, 457 insertions(+) create mode 100644 homeassistant/components/lamarzocco/select.py create mode 100644 tests/components/lamarzocco/snapshots/test_select.ambr create mode 100644 tests/components/lamarzocco/test_select.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index a5ebf727071..6d2802fb218 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -9,6 +9,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py new file mode 100644 index 00000000000..f29dabae529 --- /dev/null +++ b/homeassistant/components/lamarzocco/select.py @@ -0,0 +1,92 @@ +"""Select platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoModel + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSelectEntityDescription( + LaMarzoccoEntityDescription, + SelectEntityDescription, +): + """Description of a La Marzocco select entity.""" + + current_option_fn: Callable[[LaMarzoccoClient], str] + select_option_fn: Callable[ + [LaMarzoccoUpdateCoordinator, str], Coroutine[Any, Any, bool] + ] + + +ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( + LaMarzoccoSelectEntityDescription( + key="steam_temp_select", + translation_key="steam_temp_select", + icon="mdi:water-thermometer", + options=["1", "2", "3"], + select_option_fn=lambda coordinator, option: coordinator.lm.set_steam_level( + int(option) + ), + current_option_fn=lambda lm: lm.current_status["steam_level_set"], + supported_fn=lambda coordinator: coordinator.lm.model_name + == LaMarzoccoModel.LINEA_MICRA, + ), + LaMarzoccoSelectEntityDescription( + key="prebrew_infusion_select", + translation_key="prebrew_infusion_select", + icon="mdi:water-plus", + options=["disabled", "prebrew", "preinfusion"], + select_option_fn=lambda coordinator, + option: coordinator.lm.select_pre_brew_infusion_mode(option.capitalize()), + current_option_fn=lambda lm: lm.pre_brew_infusion_mode.lower(), + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.LINEA_MICRA, + LaMarzoccoModel.LINEA_MINI, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoSelectEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): + """La Marzocco select entity.""" + + entity_description: LaMarzoccoSelectEntityDescription + + @property + def current_option(self) -> str: + """Return the current selected option.""" + return str(self.entity_description.current_option_fn(self.coordinator.lm)) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_option_fn(self.coordinator, option) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index db4f443b18b..57f14030a6d 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -51,6 +51,24 @@ "name": "Water tank empty" } }, + "select": { + "prebrew_infusion_select": { + "name": "Prebrew/-infusion mode", + "state": { + "disabled": "Disabled", + "prebrew": "Prebrew", + "preinfusion": "Preinfusion" + } + }, + "steam_temp_select": { + "name": "Steam level", + "state": { + "1": "1", + "2": "2", + "3": "3" + } + } + }, "sensor": { "current_temp_coffee": { "name": "Current coffee temperature" diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json index 4367bd1e38d..4f208607c17 100644 --- a/tests/components/lamarzocco/fixtures/current_status.json +++ b/tests/components/lamarzocco/fixtures/current_status.json @@ -8,6 +8,7 @@ "steam_boiler_enable": true, "steam_temp": 113, "steam_set_temp": 128, + "steam_level_set": 3, "coffee_temp": 93, "coffee_set_temp": 95, "water_reservoir_contact": true, diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr new file mode 100644 index 00000000000..e35b721436c --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -0,0 +1,221 @@ +# serializer version: 1 +# name: test_pre_brew_infusion_select[GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Prebrew/-infusion mode', + 'icon': 'mdi:water-plus', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.gs01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-plus', + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'GS01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_pre_brew_infusion_select[Linea Mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LM01234 Prebrew/-infusion mode', + 'icon': 'mdi:water-plus', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[Linea Mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.lm01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-plus', + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'LM01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_pre_brew_infusion_select[Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR01234 Prebrew/-infusion mode', + 'icon': 'mdi:water-plus', + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'context': , + 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_pre_brew_infusion_select[Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'prebrew', + 'preinfusion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mr01234_prebrew_infusion_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-plus', + 'original_name': 'Prebrew/-infusion mode', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_infusion_select', + 'unique_id': 'MR01234_prebrew_infusion_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_steam_boiler_level[Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MR01234 Steam level', + 'icon': 'mdi:water-thermometer', + 'options': list([ + '1', + '2', + '3', + ]), + }), + 'context': , + 'entity_id': 'select.mr01234_steam_level', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_steam_boiler_level[Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.mr01234_steam_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-thermometer', + 'original_name': 'Steam level', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp_select', + 'unique_id': 'MR01234_steam_temp_select', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py new file mode 100644 index 00000000000..a2e4248f0af --- /dev/null +++ b/tests/components/lamarzocco/test_select.py @@ -0,0 +1,124 @@ +"""Tests for the La Marzocco select entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoModel +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.LINEA_MICRA]) +async def test_steam_boiler_level( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco Steam Level Select (only for Micra Models).""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"select.{serial_number}_steam_level") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # on/off service calls + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.{serial_number}_steam_level", + ATTR_OPTION: "1", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_steam_level.mock_calls) == 1 + mock_lamarzocco.set_steam_level.assert_called_once_with(level=1) + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP, LaMarzoccoModel.LINEA_MINI], +) +async def test_steam_boiler_level_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + serial_number = mock_lamarzocco.serial_number + state = hass.states.get(f"select.{serial_number}_steam_level") + + assert state is None + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.GS3_AV, LaMarzoccoModel.LINEA_MINI], +) +async def test_pre_brew_infusion_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Prebrew/-infusion select.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + # on/off service calls + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.{serial_number}_prebrew_infusion_mode", + ATTR_OPTION: "preinfusion", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.select_pre_brew_infusion_mode.mock_calls) == 1 + mock_lamarzocco.select_pre_brew_infusion_mode.assert_called_once_with( + mode="Preinfusion" + ) + + +@pytest.mark.parametrize( + "device_fixture", + [LaMarzoccoModel.GS3_MP], +) +async def test_pre_brew_infusion_select_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" + serial_number = mock_lamarzocco.serial_number + state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") + + assert state is None From 2cd828b2d01390086ab097f3bb654828d3e94bc8 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:15:48 +0100 Subject: [PATCH 0712/1544] Add number platform to La Marzocco (#108229) * add number * remove key entities * remove key numbers * rename entities * rename sensors --- .../components/lamarzocco/__init__.py | 1 + homeassistant/components/lamarzocco/number.py | 123 ++++++++ .../components/lamarzocco/strings.json | 11 + .../lamarzocco/snapshots/test_number.ambr | 276 ++++++++++++++++++ tests/components/lamarzocco/test_number.py | 128 ++++++++ 5 files changed, 539 insertions(+) create mode 100644 homeassistant/components/lamarzocco/number.py create mode 100644 tests/components/lamarzocco/snapshots/test_number.ambr create mode 100644 tests/components/lamarzocco/test_number.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 6d2802fb218..0ef40a231cc 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -9,6 +9,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py new file mode 100644 index 00000000000..c14f04f05d8 --- /dev/null +++ b/homeassistant/components/lamarzocco/number.py @@ -0,0 +1,123 @@ +"""Number platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoModel + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoNumberEntityDescription( + LaMarzoccoEntityDescription, + NumberEntityDescription, +): + """Description of a La Marzocco number entity.""" + + native_value_fn: Callable[[LaMarzoccoClient], float | int] + set_value_fn: Callable[ + [LaMarzoccoUpdateCoordinator, float | int], Coroutine[Any, Any, bool] + ] + + +ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( + LaMarzoccoNumberEntityDescription( + key="coffee_temp", + translation_key="coffee_temp", + icon="mdi:coffee-maker", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_step=PRECISION_TENTHS, + native_min_value=85, + native_max_value=104, + set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp(temp), + native_value_fn=lambda lm: lm.current_status["coffee_set_temp"], + ), + LaMarzoccoNumberEntityDescription( + key="steam_temp", + translation_key="steam_temp", + icon="mdi:kettle-steam", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_step=PRECISION_WHOLE, + native_min_value=126, + native_max_value=131, + set_value_fn=lambda coordinator, temp: coordinator.lm.set_steam_temp(int(temp)), + native_value_fn=lambda lm: lm.current_status["steam_set_temp"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), + LaMarzoccoNumberEntityDescription( + key="tea_water_duration", + translation_key="tea_water_duration", + icon="mdi:water-percent", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=30, + set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( + value=int(value) + ), + native_value_fn=lambda lm: lm.current_status["dose_k5"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoNumberEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): + """La Marzocco number entity.""" + + entity_description: LaMarzoccoNumberEntityDescription + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn(self.coordinator.lm) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn(self.coordinator, value) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 57f14030a6d..150356d600f 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -51,6 +51,17 @@ "name": "Water tank empty" } }, + "number": { + "coffee_temp": { + "name": "Coffee target temperature" + }, + "steam_temp": { + "name": "Steam target temperature" + }, + "tea_water_duration": { + "name": "Tea water duration" + } + }, "select": { "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr new file mode 100644 index 00000000000..d20801aed90 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -0,0 +1,276 @@ +# serializer version: 1 +# name: test_coffee_boiler + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Coffee target temperature', + 'icon': 'mdi:coffee-maker', + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '95', + }) +# --- +# name: test_coffee_boiler.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:coffee-maker', + 'original_name': 'Coffee target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'coffee_temp', + 'unique_id': 'GS01234_coffee_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'icon': 'mdi:kettle-steam', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:kettle-steam', + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'icon': 'mdi:kettle-steam', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:kettle-steam', + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'icon': 'mdi:water-percent', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:water-percent', + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'icon': 'mdi:water-percent', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:water-percent', + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py new file mode 100644 index 00000000000..7a9eb334637 --- /dev/null +++ b/tests/components/lamarzocco/test_number.py @@ -0,0 +1,128 @@ +"""Tests for the La Marzocco number entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoModel +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_coffee_boiler( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco coffee temperature Number.""" + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_coffee_target_temperature", + ATTR_VALUE: 95, + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_coffee_temp.mock_calls) == 1 + mock_lamarzocco.set_coffee_temp.assert_called_once_with(temperature=95) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP] +) +@pytest.mark.parametrize( + ("entity_name", "value", "func_name", "kwargs"), + [ + ("steam_target_temperature", 131, "set_steam_temp", {"temperature": 131}), + ("tea_water_duration", 15, "set_dose_hot_water", {"value": 15}), + ], +) +async def test_gs3_exclusive( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + value: float, + func_name: str, + kwargs: dict[str, float], +) -> None: + """Test exclusive entities for GS3 AV/MP.""" + + serial_number = mock_lamarzocco.serial_number + + func = getattr(mock_lamarzocco, func_name) + + state = hass.states.get(f"number.{serial_number}_{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", + ATTR_VALUE: value, + }, + blocking=True, + ) + + assert len(func.mock_calls) == 1 + func.assert_called_once_with(**kwargs) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI] +) +async def test_gs3_exclusive_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure GS3 exclusive is None for unsupported models.""" + + ENTITIES = ("steam_target_temperature", "tea_water_duration") + + serial_number = mock_lamarzocco.serial_number + for entity in ENTITIES: + state = hass.states.get(f"number.{serial_number}_{entity}") + assert state is None From 15384f4661cca62bece2a4c10a53f0b067021f97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 03:45:04 -1000 Subject: [PATCH 0713/1544] Remove unused entity_sources argument from shared_attrs_bytes_from_event (#108210) --- homeassistant/components/recorder/db_schema.py | 10 ++++++---- .../recorder/table_managers/state_attributes.py | 6 +----- tests/components/recorder/test_models.py | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index dff26214d67..7c7d9a743f3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -40,7 +40,6 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State -from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -563,7 +562,6 @@ class StateAttributes(Base): @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, EntityInfo], dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" @@ -571,9 +569,13 @@ class StateAttributes(Base): # None state means the state was removed from the state machine if state is None: return b"{}" - exclude_attrs = set(ALL_DOMAIN_EXCLUDE_ATTRS) if state_info := state.state_info: - exclude_attrs |= state_info["unrecorded_attributes"] + exclude_attrs = { + *ALL_DOMAIN_EXCLUDE_ATTRS, + *state_info["unrecorded_attributes"], + } + else: + exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes bytes_result = encoder( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 725bacae71c..ddaf8cb4fca 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session from homeassistant.core import Event -from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from ..db_schema import StateAttributes @@ -37,15 +36,12 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) self.active = True # always active - self._entity_sources = entity_sources(recorder.hass) def serialize_from_event(self, event: Event) -> bytes | None: """Serialize event data.""" try: return StateAttributes.shared_attrs_bytes_from_event( - event, - self._entity_sources, - self.recorder.dialect_name, + event, self.recorder.dialect_name ) except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 8536481dd1f..639efd0678d 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -77,7 +77,7 @@ def test_from_event_to_db_state_attributes() -> None: dialect = SupportedDialect.MYSQL db_attrs.shared_attrs = StateAttributes.shared_attrs_bytes_from_event( - event, {}, dialect + event, dialect ) assert db_attrs.to_native() == attrs From 3eb1283fa543e9b22c3e1ec727b66d9e4dcddfe0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 Jan 2024 14:47:03 +0100 Subject: [PATCH 0714/1544] Disable Python 3.12 incompatible integrations (#108163) --- .../components/cisco_webex_teams/manifest.json | 1 + homeassistant/components/metoffice/manifest.json | 1 + requirements_all.txt | 6 ------ requirements_test_all.txt | 3 --- tests/components/metoffice/conftest.py | 11 ++++++----- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 6f4e1ead956..822919213c2 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -2,6 +2,7 @@ "domain": "cisco_webex_teams", "name": "Cisco Webex Teams", "codeowners": ["@fbradyirl"], + "disabled": "Integration library not compatible with Python 3.12", "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", "iot_class": "cloud_push", "loggers": ["webexteamssdk"], diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index 9291f22f3b7..401f2c9d265 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -3,6 +3,7 @@ "name": "Met Office", "codeowners": ["@MrHarcombe", "@avee87"], "config_flow": true, + "disabled": "Integration library not compatible with Python 3.12", "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], diff --git a/requirements_all.txt b/requirements_all.txt index 3b7b6136abb..fdf023a09ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -665,9 +665,6 @@ crownstone-uart==2.1.0 # homeassistant.components.datadog datadog==0.15.0 -# homeassistant.components.metoffice -datapoint==0.9.8;python_version<'3.12' - # homeassistant.components.bluetooth dbus-fast==2.21.1 @@ -2799,9 +2796,6 @@ watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 -# homeassistant.components.cisco_webex_teams -webexteamssdk==1.1.1;python_version<'3.12' - # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bbcf048e30..ad6601904df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -546,9 +546,6 @@ crownstone-uart==2.1.0 # homeassistant.components.datadog datadog==0.15.0 -# homeassistant.components.metoffice -datapoint==0.9.8;python_version<'3.12' - # homeassistant.components.bluetooth dbus-fast==2.21.1 diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py index 1633fae5ee8..117bfe417e3 100644 --- a/tests/components/metoffice/conftest.py +++ b/tests/components/metoffice/conftest.py @@ -1,13 +1,14 @@ """Fixtures for Met Office weather integration tests.""" -import sys from unittest.mock import patch import pytest -if sys.version_info < (3, 12): - from datapoint.exceptions import APIException -else: - collect_ignore_glob = ["test_*.py"] +# All tests are marked as disabled, as the integration is disabled in the +# integration manifest. `datapoint` isn't compatible with Python 3.12 +# +# from datapoint.exceptions import APIException +APIException = Exception +collect_ignore_glob = ["test_*.py"] @pytest.fixture From ee44e9d4d620e14c295c641515355eb572b6ffa7 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:49:08 +0100 Subject: [PATCH 0715/1544] Add update platform to La Marzocco (#108235) * add update * requested changes * improve * docstring * docstring --- .../components/lamarzocco/__init__.py | 1 + .../components/lamarzocco/strings.json | 8 ++ homeassistant/components/lamarzocco/update.py | 105 +++++++++++++++++ tests/components/lamarzocco/conftest.py | 3 +- .../lamarzocco/snapshots/test_update.ambr | 111 ++++++++++++++++++ tests/components/lamarzocco/test_update.py | 76 ++++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lamarzocco/update.py create mode 100644 tests/components/lamarzocco/snapshots/test_update.ambr create mode 100644 tests/components/lamarzocco/test_update.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 0ef40a231cc..ba37a7f90d7 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 150356d600f..fc326b41666 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -104,6 +104,14 @@ "steam_boiler": { "name": "Steam boiler" } + }, + "update": { + "machine_firmware": { + "name": "Machine firmware" + }, + "gateway_firmware": { + "name": "Gateway firmware" + } } } } diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py new file mode 100644 index 00000000000..0e811019cbb --- /dev/null +++ b/homeassistant/components/lamarzocco/update.py @@ -0,0 +1,105 @@ +"""Support for La Marzocco update entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoUpdateableComponent + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoUpdateEntityDescription( + LaMarzoccoEntityDescription, + UpdateEntityDescription, +): + """Description of a La Marzocco update entities.""" + + current_fw_fn: Callable[[LaMarzoccoClient], str] + latest_fw_fn: Callable[[LaMarzoccoClient], str] + component: LaMarzoccoUpdateableComponent + + +ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( + LaMarzoccoUpdateEntityDescription( + key="machine_firmware", + translation_key="machine_firmware", + device_class=UpdateDeviceClass.FIRMWARE, + icon="mdi:cloud-download", + current_fw_fn=lambda lm: lm.firmware_version, + latest_fw_fn=lambda lm: lm.latest_firmware_version, + component=LaMarzoccoUpdateableComponent.MACHINE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoUpdateEntityDescription( + key="gateway_firmware", + translation_key="gateway_firmware", + device_class=UpdateDeviceClass.FIRMWARE, + icon="mdi:cloud-download", + current_fw_fn=lambda lm: lm.gateway_version, + latest_fw_fn=lambda lm: lm.latest_gateway_version, + component=LaMarzoccoUpdateableComponent.GATEWAY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create update entities.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoUpdateEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): + """Entity representing the update state.""" + + entity_description: LaMarzoccoUpdateEntityDescription + _attr_supported_features = UpdateEntityFeature.INSTALL + + @property + def installed_version(self) -> str | None: + """Return the current firmware version.""" + return self.entity_description.current_fw_fn(self.coordinator.lm) + + @property + def latest_version(self) -> str: + """Return the latest firmware version.""" + return self.entity_description.latest_fw_fn(self.coordinator.lm) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self._attr_in_progress = True + self.async_write_ha_state() + success = await self.coordinator.lm.update_firmware( + self.entity_description.component + ) + if not success: + raise HomeAssistantError("Update failed") + self._attr_in_progress = False + self.async_write_ha_state() diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index cc2d121e632..7bb7e849ef1 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -87,9 +87,10 @@ def mock_lamarzocco( lamarzocco.serial_number = serial_number lamarzocco.firmware_version = "1.1" - lamarzocco.latest_firmware_version = "1.1" + lamarzocco.latest_firmware_version = "1.2" lamarzocco.gateway_version = "v2.2-rc0" lamarzocco.latest_gateway_version = "v3.1-rc4" + lamarzocco.update_firmware.return_value = True lamarzocco.current_status = load_json_object_fixture( "current_status.json", DOMAIN diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr new file mode 100644 index 00000000000..29d09278ea2 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_update_entites[gateway_firmware-gateway] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', + 'friendly_name': 'GS01234 Gateway firmware', + 'icon': 'mdi:cloud-download', + 'in_progress': False, + 'installed_version': 'v2.2-rc0', + 'latest_version': 'v3.1-rc4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.gs01234_gateway_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entites[gateway_firmware-gateway].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.gs01234_gateway_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cloud-download', + 'original_name': 'Gateway firmware', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'gateway_firmware', + 'unique_id': 'GS01234_gateway_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entites[machine_firmware-machine] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', + 'friendly_name': 'GS01234 Machine firmware', + 'icon': 'mdi:cloud-download', + 'in_progress': False, + 'installed_version': '1.1', + 'latest_version': '1.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.gs01234_machine_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entites[machine_firmware-machine].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.gs01234_machine_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:cloud-download', + 'original_name': 'Machine firmware', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'machine_firmware', + 'unique_id': 'GS01234_machine_firmware', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py new file mode 100644 index 00000000000..55c5bb0da3d --- /dev/null +++ b/tests/components/lamarzocco/test_update.py @@ -0,0 +1,76 @@ +"""Tests for the La Marzocco Update Entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoUpdateableComponent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + ("entity_name", "component"), + [ + ("machine_firmware", LaMarzoccoUpdateableComponent.MACHINE), + ("gateway_firmware", LaMarzoccoUpdateableComponent.GATEWAY), + ], +) +async def test_update_entites( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + component: LaMarzoccoUpdateableComponent, +) -> None: + """Test the La Marzocco update entities.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"update.{serial_number}_{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}", + }, + blocking=True, + ) + + mock_lamarzocco.update_firmware.assert_called_once_with(component) + + +async def test_update_error( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Test error during update.""" + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware") + assert state + + mock_lamarzocco.update_firmware.return_value = False + + with pytest.raises(HomeAssistantError, match="Update failed"): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware", + }, + blocking=True, + ) From 90f4900f2c3a0a6eb8f59b83ce6a331d0154b281 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 15:20:24 +0100 Subject: [PATCH 0716/1544] Add button platform to La Marzocco (#108236) * add button * Update homeassistant/components/lamarzocco/button.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lamarzocco/strings.json Co-authored-by: Joost Lekkerkerker * update snapshot --------- Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/__init__.py | 1 + homeassistant/components/lamarzocco/button.py | 60 +++++++++++++++++++ .../components/lamarzocco/strings.json | 5 ++ .../lamarzocco/snapshots/test_button.ambr | 45 ++++++++++++++ tests/components/lamarzocco/test_button.py | 45 ++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 homeassistant/components/lamarzocco/button.py create mode 100644 tests/components/lamarzocco/snapshots/test_button.ambr create mode 100644 tests/components/lamarzocco/test_button.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index ba37a7f90d7..0adfc4bebfe 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -9,6 +9,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py new file mode 100644 index 00000000000..689250fa37b --- /dev/null +++ b/homeassistant/components/lamarzocco/button.py @@ -0,0 +1,60 @@ +"""Button platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient + +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 .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoButtonEntityDescription( + LaMarzoccoEntityDescription, + ButtonEntityDescription, +): + """Description of a La Marzocco button.""" + + press_fn: Callable[[LaMarzoccoClient], Coroutine[Any, Any, None]] + + +ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( + LaMarzoccoButtonEntityDescription( + key="start_backflush", + translation_key="start_backflush", + icon="mdi:water-sync", + press_fn=lambda lm: lm.start_backflush(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoButtonEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): + """La Marzocco Button Entity.""" + + entity_description: LaMarzoccoButtonEntityDescription + + async def async_press(self) -> None: + """Press button.""" + await self.entity_description.press_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index fc326b41666..7537405c6cd 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -51,6 +51,11 @@ "name": "Water tank empty" } }, + "button": { + "start_backflush": { + "name": "Start backflush" + } + }, "number": { "coffee_temp": { "name": "Coffee target temperature" diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr new file mode 100644 index 00000000000..e092032e8f5 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_start_backflush + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Start backflush', + 'icon': 'mdi:water-sync', + }), + 'context': , + 'entity_id': 'button.gs01234_start_backflush', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_start_backflush.1 + 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.gs01234_start_backflush', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-sync', + 'original_name': 'Start backflush', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_backflush', + 'unique_id': 'GS01234_start_backflush', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py new file mode 100644 index 00000000000..7d910a57561 --- /dev/null +++ b/tests/components/lamarzocco/test_button.py @@ -0,0 +1,45 @@ +"""Tests for the La Marzocco Buttons.""" + + +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_start_backflush( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco backflush button.""" + + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"button.{serial_number}_start_backflush") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.start_backflush.mock_calls) == 1 + mock_lamarzocco.start_backflush.assert_called_once() From 91815ed5f913d2775d8e208ec0798be0a822370f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 17 Jan 2024 15:47:06 +0100 Subject: [PATCH 0717/1544] Assert default response from conversation trigger (#108231) --- tests/components/conversation/test_trigger.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 4fe9fed6bb2..69a93b4a7c9 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -44,14 +44,16 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None }, ) - await hass.services.async_call( + service_response = await hass.services.async_call( "conversation", "process", { "text": "Ha ha ha", }, blocking=True, + return_response=True, ) + assert service_response["response"]["speech"]["plain"]["speech"] == "Done" await hass.async_block_till_done() assert len(calls) == 1 From c47fb5d161b1ede68158de80c4ade102bdeaa22e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 17 Jan 2024 15:55:46 +0100 Subject: [PATCH 0718/1544] Remove deprecated redundant dry and fan modes from `zwave_js` climates (#108124) Remove deprecated redundant dry and fan modes from zwave_js climates --- homeassistant/components/zwave_js/climate.py | 29 +----- .../components/zwave_js/strings.json | 11 --- tests/components/zwave_js/test_climate.py | 98 ------------------- 3 files changed, 1 insertion(+), 137 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index d511a030fb1..2506db13f6d 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -37,10 +37,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity @@ -243,11 +242,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # treat value as hvac mode if hass_mode := ZW_HVAC_MODE_MAP.get(mode_id): all_modes[hass_mode] = mode_id - # Dry and Fan modes are in the process of being migrated from - # presets to hvac modes. In the meantime, we will set them as - # both, presets and hvac modes, to maintain backwards compatibility - if mode_id in (ThermostatMode.DRY, ThermostatMode.FAN): - all_presets[mode_name] = mode_id else: # treat value as hvac preset all_presets[mode_name] = mode_id @@ -503,27 +497,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") - # Dry and Fan preset modes are deprecated as of Home Assistant 2023.8. - # Please use Dry and Fan HVAC modes instead. - if preset_mode_value in (ThermostatMode.DRY, ThermostatMode.FAN): - LOGGER.warning( - "Dry and Fan preset modes are deprecated and will be removed in Home " - "Assistant 2024.2. Please use the corresponding Dry and Fan HVAC " - "modes instead" - ) - async_create_issue( - self.hass, - DOMAIN, - f"dry_fan_presets_deprecation_{self.entity_id}", - breaks_in_ha_version="2024.2.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="dry_fan_presets_deprecation", - translation_placeholders={ - "entity_id": self.entity_id, - }, - ) await self._async_set_value(self._current_mode, preset_mode_value) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 19a47450080..ee6a7c3d0b7 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -151,17 +151,6 @@ "title": "Newer version of Z-Wave JS Server needed", "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue." }, - "dry_fan_presets_deprecation": { - "title": "Dry and Fan preset modes will be removed: {entity_id}", - "fix_flow": { - "step": { - "confirm": { - "title": "Dry and Fan preset modes will be removed: {entity_id}", - "description": "You are using the Dry or Fan preset modes in your entity `{entity_id}`.\n\nDry and Fan preset modes are deprecated and will be removed. Please update your automations to use the corresponding Dry and Fan **HVAC modes** instead.\n\nClick on SUBMIT below once you have manually fixed this issue." - } - } - } - }, "device_config_file_changed": { "title": "Device configuration file changed: {device_name}", "fix_flow": { diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e4550b7f961..b2e7c313916 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -18,7 +18,6 @@ from homeassistant.components.climate import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, @@ -41,10 +40,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir from .common import ( - CLIMATE_AIDOO_HVAC_UNIT_ENTITY, CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, CLIMATE_FLOOR_THERMOSTAT_ENTITY, @@ -769,98 +766,3 @@ async def test_thermostat_unknown_values( state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) assert ATTR_HVAC_ACTION not in state.attributes - - -async def test_thermostat_dry_and_fan_both_hvac_mode_and_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, -) -> None: - """Test that dry and fan modes are both available as hvac mode and preset.""" - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - assert state.attributes[ATTR_HVAC_MODES] == [ - HVACMode.OFF, - HVACMode.HEAT, - HVACMode.COOL, - HVACMode.FAN_ONLY, - HVACMode.DRY, - HVACMode.HEAT_COOL, - ] - assert state.attributes[ATTR_PRESET_MODES] == [ - PRESET_NONE, - "Fan", - "Dry", - ] - - -async def test_thermostat_raise_repair_issue_and_warning_when_setting_dry_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test raise of repair issue and warning when setting Dry preset.""" - client.async_send_command.return_value = {"result": {"status": 1}} - - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - { - ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, - ATTR_PRESET_MODE: "Dry", - }, - blocking=True, - ) - - issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" - issue_registry = ir.async_get(hass) - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=issue_id, - ) - assert ( - "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" - in caplog.text - ) - - -async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset( - hass: HomeAssistant, - client, - climate_airzone_aidoo_control_hvac_unit, - integration, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test raise of repair issue and warning when setting Fan preset.""" - client.async_send_command.return_value = {"result": {"status": 1}} - state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) - assert state - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - { - ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, - ATTR_PRESET_MODE: "Fan", - }, - blocking=True, - ) - - issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" - issue_registry = ir.async_get(hass) - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=issue_id, - ) - assert ( - "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" - in caplog.text - ) From 9d5f714e29fa822283b38c40ec2549e719cf688f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 17 Jan 2024 16:07:02 +0100 Subject: [PATCH 0719/1544] Decrease fitbit logging verbosity on connection error (#108228) * Add test for connection error * Decrease fitbit connection error log verbosity --- homeassistant/components/fitbit/api.py | 4 ++++ tests/components/fitbit/test_sensor.py | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 49e51a0fd98..0f49c0858f5 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -7,6 +7,7 @@ from typing import Any, TypeVar, cast from fitbit import Fitbit from fitbit.exceptions import HTTPException, HTTPUnauthorized +from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -132,6 +133,9 @@ class FitbitApi(ABC): """Run client command.""" try: return await self._hass.async_add_executor_job(func) + except RequestsConnectionError as err: + _LOGGER.debug("Connection error to fitbit API: %s", err) + raise FitbitApiException("Connection error to fitbit API") from err except HTTPUnauthorized as err: _LOGGER.debug("Unauthorized error from fitbit API: %s", err) raise FitbitAuthException("Authentication error from fitbit API") from err diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 91aafd944b0..59405e3ea91 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -6,6 +6,7 @@ from http import HTTPStatus from typing import Any import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion @@ -599,10 +600,11 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes", "server_status"), + ("scopes", "request_condition"), [ - (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), - (["heartrate"], HTTPStatus.BAD_REQUEST), + (["heartrate"], {"status_code": HTTPStatus.INTERNAL_SERVER_ERROR}), + (["heartrate"], {"status_code": HTTPStatus.BAD_REQUEST}), + (["heartrate"], {"exc": RequestsConnectionError}), ], ) async def test_sensor_update_failed( @@ -610,14 +612,14 @@ async def test_sensor_update_failed( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, - server_status: HTTPStatus, + request_condition: dict[str, Any], ) -> None: """Test a failed sensor update when talking to the API.""" requests_mock.register_uri( "GET", TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), - status_code=server_status, + **request_condition, ) assert await integration_setup() From 64496b802aa5eec5b41f85084706bf76eb45377a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:26:15 +0100 Subject: [PATCH 0720/1544] Fix state after La Marzocco update (#108244) request a refresh from coordinator after update --- homeassistant/components/lamarzocco/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 0e811019cbb..6c0a5a990ad 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -102,4 +102,4 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): if not success: raise HomeAssistantError("Update failed") self._attr_in_progress = False - self.async_write_ha_state() + await self.coordinator.async_request_refresh() From 3d410a1d6ed8ef4fbcec995f0535964e89e9770b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 17 Jan 2024 18:19:10 +0100 Subject: [PATCH 0721/1544] Improve systemmonitor generic typing (#108220) --- homeassistant/components/systemmonitor/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index b5cccc20b6a..e751ffebb12 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -737,12 +737,12 @@ class SystemMonitorSensor(CoordinatorEntity[MonitorCoordinator[dataT]], SensorEn _attr_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC - entity_description: SysMonitorSensorEntityDescription + entity_description: SysMonitorSensorEntityDescription[dataT] def __init__( self, - coordinator: MonitorCoordinator, - sensor_description: SysMonitorSensorEntityDescription, + coordinator: MonitorCoordinator[dataT], + sensor_description: SysMonitorSensorEntityDescription[dataT], entry_id: str, argument: str, legacy_enabled: bool = False, From 802f0da493cc3c59f0240ad6d80cc4dd3f561b98 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 17 Jan 2024 19:08:33 +0100 Subject: [PATCH 0722/1544] Switch for swiss_public_transport to unique_id instead of unique_entry (#107910) * use unique_id instead of unique_entry * move entry mock out of patch context --- .../swiss_public_transport/config_flow.py | 22 ++++-------- .../test_config_flow.py | 34 ++++++++++--------- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index ceb6f46806d..e864f31cd6c 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -39,12 +39,10 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Async user step to set up the connection.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - { - CONF_START: user_input[CONF_START], - CONF_DESTINATION: user_input[CONF_DESTINATION], - } + await self.async_set_unique_id( + f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" ) + self._abort_if_unique_id_configured() session = async_get_clientsession(self.hass) opendata = OpendataTransport( @@ -60,9 +58,6 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: - await self.async_set_unique_id( - f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" - ) return self.async_create_entry( title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", data=user_input, @@ -77,12 +72,10 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: """Async import step to set up the connection.""" - self._async_abort_entries_match( - { - CONF_START: import_input[CONF_START], - CONF_DESTINATION: import_input[CONF_DESTINATION], - } + await self.async_set_unique_id( + f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" ) + self._abort_if_unique_id_configured() session = async_get_clientsession(self.hass) opendata = OpendataTransport( @@ -102,9 +95,6 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="unknown") - await self.async_set_unique_id( - f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" - ) return self.async_create_entry( title=import_input[CONF_NAME], data=import_input, diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 55ad51c45c4..5870f6f0555 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -65,7 +65,7 @@ async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: (IndexError(), "unknown"), ], ) -async def test_flow_user_init_data_unknown_error_and_recover( +async def test_flow_user_init_data_error_and_recover( hass: HomeAssistant, raise_error, text_error ) -> None: """Test unknown errors.""" @@ -88,9 +88,6 @@ async def test_flow_user_init_data_unknown_error_and_recover( # Recover mock_OpendataTransport.side_effect = None mock_OpendataTransport.return_value = True - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_DATA_STEP, @@ -108,20 +105,26 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No entry = MockConfigEntry( domain=config_flow.DOMAIN, data=MOCK_DATA_STEP, + unique_id=f"{MOCK_DATA_STEP[CONF_START]} {MOCK_DATA_STEP[CONF_DESTINATION]}", ) entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_DATA_STEP, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" MOCK_DATA_IMPORT = { @@ -161,9 +164,7 @@ async def test_import( (IndexError(), "unknown"), ], ) -async def test_import_cannot_connect_error( - hass: HomeAssistant, raise_error, text_error -) -> None: +async def test_import_error(hass: HomeAssistant, raise_error, text_error) -> None: """Test import flow cannot_connect error.""" with patch( "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", @@ -187,6 +188,7 @@ async def test_import_already_configured(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=config_flow.DOMAIN, data=MOCK_DATA_IMPORT, + unique_id=f"{MOCK_DATA_IMPORT[CONF_START]} {MOCK_DATA_IMPORT[CONF_DESTINATION]}", ) entry.add_to_hass(hass) From 97956702c9f8c3c364cdfeca0cfea3836b06053f Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 17 Jan 2024 20:54:13 +0100 Subject: [PATCH 0723/1544] Bump PyTado to 0.17.4 (#108255) Bump to 17.4 --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index a4ef561b6ea..0f3288ba904 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.3"] + "requirements": ["python-tado==0.17.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fdf023a09ed..7f94b1d2976 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2259,7 +2259,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.17.3 +python-tado==0.17.4 # homeassistant.components.technove python-technove==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad6601904df..4ccb62c97d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1720,7 +1720,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.17.3 +python-tado==0.17.4 # homeassistant.components.technove python-technove==1.1.1 From a385ca93bd0d5b7de4b8b20fe7cc34c006ab1aed Mon Sep 17 00:00:00 2001 From: John Allen Date: Wed, 17 Jan 2024 15:06:11 -0500 Subject: [PATCH 0724/1544] Send target temp to Shelly TRV in F when needed (#108188) --- homeassistant/components/shelly/climate.py | 15 ++++++++++++++ tests/components/shelly/test_climate.py | 23 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 7cc0027bbaf..64129131d0a 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -316,6 +316,21 @@ class BlockSleepingClimate( """Set new target temperature.""" if (current_temp := kwargs.get(ATTR_TEMPERATURE)) is None: return + + # Shelly TRV accepts target_t in Fahrenheit or Celsius, but you must + # send the units that the device expects + if self.block is not None and self.block.channel is not None: + therm = self.coordinator.device.settings["thermostats"][ + int(self.block.channel) + ] + LOGGER.debug("Themostat settings: %s", therm) + if therm.get("target_t", {}).get("units", "C") == "F": + current_temp = TemperatureConverter.convert( + cast(float, current_temp), + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + await self.set_state_full_path(target_t_enabled=1, target_t=f"{current_temp}") async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 980981de754..28235325af4 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -146,6 +146,29 @@ async def test_climate_set_temperature( mock_block_device.http_request.assert_called_once_with( "get", "thermostat/0", {"target_t_enabled": 1, "target_t": "23.0"} ) + mock_block_device.http_request.reset_mock() + + # Test conversion from C to F + monkeypatch.setattr( + mock_block_device, + "settings", + { + "thermostats": [ + {"target_t": {"units": "F"}}, + ] + }, + ) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20}, + blocking=True, + ) + + mock_block_device.http_request.assert_called_once_with( + "get", "thermostat/0", {"target_t_enabled": 1, "target_t": "68.0"} + ) async def test_climate_set_preset_mode( From c827bba78082b789300340b0d97d4d5adf2f99ad Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 17 Jan 2024 21:42:20 +0100 Subject: [PATCH 0725/1544] Let zigpy decide on default manufacturer id (#108257) * zha: let cluster set default manufacturer id * zha: allow forcing manufacturer id off --- homeassistant/components/zha/strings.json | 2 +- homeassistant/components/zha/websocket_api.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 8909af8a5ba..a4a53b3c1b4 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -328,7 +328,7 @@ }, "manufacturer": { "name": "Manufacturer", - "description": "Manufacturer code." + "description": "Manufacturer code. Use a value of \"-1\" to force no code to be set." } } }, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 51941248f03..447aa5efd0f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -161,7 +161,9 @@ SERVICE_SCHEMAS = { vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( @@ -210,7 +212,9 @@ SERVICE_SCHEMAS = { vol.Required(ATTR_COMMAND_TYPE): cv.string, vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), cv.deprecated(ATTR_ARGS), @@ -223,7 +227,9 @@ SERVICE_SCHEMAS = { vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, vol.Required(ATTR_COMMAND): cv.positive_int, vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), } ), } @@ -819,8 +825,6 @@ async def websocket_read_zigbee_cluster_attributes( success = {} failure = {} if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code cluster = zha_device.async_get_cluster( endpoint_id, cluster_id, cluster_type=cluster_type ) @@ -1300,8 +1304,6 @@ def async_load_api(hass: HomeAssistant) -> None: zha_device = zha_gateway.get_device(ieee) response = None if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code response = await zha_device.write_zigbee_attribute( endpoint_id, cluster_id, From a27eea9b9f22275bc81a4dc8495384d34c482c38 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 17 Jan 2024 22:28:15 +0100 Subject: [PATCH 0726/1544] Bump reolink_aio to 0.8.7 (#108248) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5670aea87ad..40e85b9680b 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.6"] + "requirements": ["reolink-aio==0.8.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f94b1d2976..421f501a315 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2397,7 +2397,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.6 +reolink-aio==0.8.7 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ccb62c97d6..b36e58214c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1825,7 +1825,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.6 +reolink-aio==0.8.7 # homeassistant.components.rflink rflink==0.0.65 From f704a1a05a81c47d57ece1a92f7ef96c86f9d53a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 17 Jan 2024 23:19:49 +0100 Subject: [PATCH 0727/1544] Remove legacy VacuumEntity base class support (#108189) --- homeassistant/components/vacuum/__init__.py | 320 ++++--------------- homeassistant/components/vacuum/strings.json | 10 - tests/components/vacuum/test_init.py | 166 ++-------- 3 files changed, 101 insertions(+), 395 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index f15d59e9455..1bd9719c51c 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,13 +1,12 @@ """Support for vacuum cleaner robots (botvacs).""" from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import TYPE_CHECKING, Any, final +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -22,28 +21,18 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API STATE_ON, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import ( - Entity, - EntityDescription, - ToggleEntity, - ToggleEntityDescription, -) +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import ( - async_get_issue_tracker, - async_suggest_report_issue, - bind_hass, -) +from homeassistant.loader import bind_hass if TYPE_CHECKING: from functools import cached_property @@ -131,38 +120,12 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the vacuum component.""" - component = hass.data[DOMAIN] = EntityComponent[_BaseVacuum]( + component = hass.data[DOMAIN] = EntityComponent[StateVacuumEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) - component.async_register_entity_service( - SERVICE_TURN_ON, - {}, - "async_turn_on", - [VacuumEntityFeature.TURN_ON], - ) - component.async_register_entity_service( - SERVICE_TURN_OFF, - {}, - "async_turn_off", - [VacuumEntityFeature.TURN_OFF], - ) - component.async_register_entity_service( - SERVICE_TOGGLE, - {}, - "async_toggle", - [VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON], - ) - # start_pause is a legacy service, only supported by VacuumEntity, and only needs - # VacuumEntityFeature.PAUSE - component.async_register_entity_service( - SERVICE_START_PAUSE, - {}, - "async_start_pause", - [VacuumEntityFeature.PAUSE], - ) component.async_register_entity_service( SERVICE_START, {}, @@ -220,30 +183,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] return await component.async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[_BaseVacuum] = hass.data[DOMAIN] + component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] return await component.async_unload_entry(entry) -BASE_CACHED_PROPERTIES_WITH_ATTR_ = { +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes vacuum entities.""" + + +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { "supported_features", "battery_level", "battery_icon", "fan_speed", "fan_speed_list", + "state", } -class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): - """Representation of a base vacuum. +class StateVacuumEntity( + Entity, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): + """Representation of a vacuum cleaner robot that supports states.""" - Contains common properties and functions for all vacuum devices. - """ + entity_description: StateVacuumEntityDescription _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) @@ -251,8 +220,60 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): _attr_battery_level: int | None = None _attr_fan_speed: str | None = None _attr_fan_speed_list: list[str] + _attr_state: str | None = None _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + @cached_property + def battery_level(self) -> int | None: + """Return the battery level of the vacuum cleaner.""" + return self._attr_battery_level + + @property + def battery_icon(self) -> str: + """Return the battery icon for the vacuum cleaner.""" + charging = bool(self.state == STATE_DOCKED) + + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging + ) + + @property + def capability_attributes(self) -> Mapping[str, Any] | None: + """Return capability attributes.""" + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} + return None + + @cached_property + def fan_speed(self) -> str | None: + """Return the fan speed of the vacuum cleaner.""" + return self._attr_fan_speed + + @cached_property + def fan_speed_list(self) -> list[str]: + """Get the list of available fan speed steps of the vacuum cleaner.""" + return self._attr_fan_speed_list + + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the vacuum cleaner.""" + data: dict[str, Any] = {} + supported_features = self.supported_features_compat + + if VacuumEntityFeature.BATTERY in supported_features: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if VacuumEntityFeature.FAN_SPEED in supported_features: + data[ATTR_FAN_SPEED] = self.fan_speed + + return data + + @cached_property + def state(self) -> str | None: + """Return the state of the vacuum cleaner.""" + return self._attr_state + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" @@ -271,48 +292,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): return new_features return features - @cached_property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - return self._attr_battery_level - - @cached_property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return self._attr_battery_icon - - @cached_property - def fan_speed(self) -> str | None: - """Return the fan speed of the vacuum cleaner.""" - return self._attr_fan_speed - - @cached_property - def fan_speed_list(self) -> list[str]: - """Get the list of available fan speed steps of the vacuum cleaner.""" - return self._attr_fan_speed_list - - @property - def capability_attributes(self) -> Mapping[str, Any] | None: - """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: - return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} - return None - - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the vacuum cleaner.""" - data: dict[str, Any] = {} - supported_features = self.supported_features_compat - - if VacuumEntityFeature.BATTERY in supported_features: - data[ATTR_BATTERY_LEVEL] = self.battery_level - data[ATTR_BATTERY_ICON] = self.battery_icon - - if VacuumEntityFeature.FAN_SPEED in supported_features: - data[ATTR_FAN_SPEED] = self.fan_speed - - return data - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError() @@ -393,163 +372,6 @@ class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): partial(self.send_command, command, params=params, **kwargs) ) - -class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { - "status", -} - - -class VacuumEntity( - _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ -): - """Representation of a vacuum cleaner robot.""" - - @callback - def add_to_platform_start( - self, - hass: HomeAssistant, - platform: EntityPlatform, - parallel_updates: asyncio.Semaphore | None, - ) -> None: - """Start adding an entity to a platform.""" - super().add_to_platform_start(hass, platform, parallel_updates) - translation_key = "deprecated_vacuum_base_class" - translation_placeholders = {"platform": self.platform.platform_name} - issue_tracker = async_get_issue_tracker( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - if issue_tracker: - translation_placeholders["issue_tracker"] = issue_tracker - translation_key = "deprecated_vacuum_base_class_url" - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_vacuum_base_class_{self.platform.platform_name}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - report_issue = async_suggest_report_issue( - hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s is extending the deprecated base class VacuumEntity instead of " - "StateVacuumEntity, this is not valid and will be unsupported " - "from Home Assistant 2024.2. Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - report_issue, - ) - - entity_description: VacuumEntityDescription - _attr_status: str | None = None - - @cached_property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._attr_status - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = False - if self.status is not None: - charging = "charg" in self.status.lower() - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - - @final - @property - def state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the vacuum cleaner.""" - data = super().state_attributes - - if VacuumEntityFeature.STATUS in self.supported_features_compat: - data[ATTR_STATUS] = self.status - - return data - - def turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning.""" - raise NotImplementedError() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the vacuum on and start cleaning. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_on, **kwargs)) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home.""" - raise NotImplementedError() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.turn_off, **kwargs)) - - def start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task.""" - raise NotImplementedError() - - async def async_start_pause(self, **kwargs: Any) -> None: - """Start, pause or resume the cleaning task. - - This method must be run in the event loop. - """ - await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) - - -class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): - """A class that describes vacuum entities.""" - - -STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { - "state", -} - - -class StateVacuumEntity( - _BaseVacuum, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ -): - """Representation of a vacuum cleaner robot that supports states.""" - - entity_description: StateVacuumEntityDescription - _attr_state: str | None = None - - @cached_property - def state(self) -> str | None: - """Return the state of the vacuum cleaner.""" - return self._attr_state - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - charging = bool(self.state == STATE_DOCKED) - - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging - ) - def start(self) -> None: """Start or resume the cleaning task.""" raise NotImplementedError() diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 15ba2076060..673c76b7f8d 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -29,16 +29,6 @@ } } }, - "issues": { - "deprecated_vacuum_base_class": { - "title": "The {platform} custom integration is using deprecated vacuum feature", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - }, - "deprecated_vacuum_base_class_url": { - "title": "[%key:component::vacuum::issues::deprecated_vacuum_base_class::title%]", - "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." - } - }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 0b44476989b..0da4470c762 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -1,147 +1,41 @@ """The tests for the Vacuum entity integration.""" from __future__ import annotations -from collections.abc import Generator - -import pytest - -from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, - VacuumEntity, - VacuumEntityFeature, -) -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from tests.common import ( - MockConfigEntry, - MockModule, - MockPlatform, - mock_config_flow, - mock_integration, - mock_platform, -) - -TEST_DOMAIN = "test" -class MockFlow(ConfigFlow): - """Test flow.""" +async def test_supported_features_compat(hass: HomeAssistant) -> None: + """Test StateVacuumEntity using deprecated feature constants features.""" - -@pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: - """Mock config flow.""" - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - - with mock_config_flow(TEST_DOMAIN, MockFlow): - yield - - -ISSUE_TRACKER = "https://blablabla.com" - - -@pytest.mark.parametrize( - ("manifest_extra", "translation_key", "translation_placeholders_extra"), - [ - ( - {}, - "deprecated_vacuum_base_class", - {}, - ), - ( - {"issue_tracker": ISSUE_TRACKER}, - "deprecated_vacuum_base_class_url", - {"issue_tracker": ISSUE_TRACKER}, - ), - ], -) -async def test_deprecated_base_class( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - manifest_extra: dict[str, str], - translation_key: str, - translation_placeholders_extra: dict[str, str], -) -> None: - """Test warnings when adding VacuumEntity to the state machine.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, VACUUM_DOMAIN) - return True - - mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - partial_manifest=manifest_extra, - ), - built_in=False, + features = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - entity1 = VacuumEntity() - entity1.entity_id = "vacuum.test1" + class _LegacyConstantsStateVacuum(StateVacuumEntity): + _attr_supported_features = int(features) + _attr_fan_speed_list = ["silent", "normal", "pet hair"] - async def async_setup_entry_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test vacuum platform via config entry.""" - async_add_entities([entity1]) - - mock_platform( - hass, - f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", - MockPlatform(async_setup_entry=async_setup_entry_platform), + entity = _LegacyConstantsStateVacuum() + assert isinstance(entity.supported_features, int) + assert entity.supported_features == int(features) + assert entity.supported_features_compat is ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE ) - - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert hass.states.get(entity1.entity_id) - - assert ( - "test::VacuumEntity is extending the deprecated base class VacuumEntity" - in caplog.text - ) - - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( - VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - ) - assert issue.issue_domain == TEST_DOMAIN - assert issue.issue_id == f"deprecated_vacuum_base_class_{TEST_DOMAIN}" - assert issue.translation_key == translation_key - assert ( - issue.translation_placeholders - == {"platform": "test"} | translation_placeholders_extra - ) - - -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated supported features ints.""" - - class MockVacuumEntity(VacuumEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockVacuumEntity() - assert entity.supported_features_compat is VacuumEntityFeature(1) - assert "MockVacuumEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "VacuumEntityFeature.TURN_ON" in caplog.text - caplog.clear() - assert entity.supported_features_compat is VacuumEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text + assert entity.state_attributes == { + "battery_level": None, + "battery_icon": "mdi:battery-unknown", + "fan_speed": None, + } + assert entity.capability_attributes == { + "fan_speed_list": ["silent", "normal", "pet hair"] + } + assert entity._deprecated_supported_features_reported From 3ae858e3bf0e8873cadf3e07ddfa2e8ef2375f68 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 17 Jan 2024 18:34:10 -0500 Subject: [PATCH 0728/1544] Bump ZHA dependency zigpy to 0.60.6 (#108266) Bump zigpy to 0.60.6 --- 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 06ebfaaa6a0..de429b299c0 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.109", "zigpy-deconz==0.22.4", - "zigpy==0.60.4", + "zigpy==0.60.6", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 421f501a315..81ff6d99ba9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2905,7 +2905,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.4 +zigpy==0.60.6 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b36e58214c1..8acda73d10f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2213,7 +2213,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.4 +zigpy==0.60.6 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From 867caab70a960b4b29cd7111f35423c0ce67e8ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 17 Jan 2024 18:36:28 -0500 Subject: [PATCH 0729/1544] Speed up ZHA initialization and improve startup responsiveness (#108103) * Limit concurrency of startup traffic to allow for interactive usage * Drop `retryable_req`, we already have request retrying * Oops, `min` -> `max` * Add a comment describing why `async_initialize` is not concurrent * Fix existing unit tests * Break out fetching mains state into its own function to unit test --- .../zha/core/cluster_handlers/__init__.py | 3 +- homeassistant/components/zha/core/device.py | 17 ++-- homeassistant/components/zha/core/endpoint.py | 22 ++++- homeassistant/components/zha/core/gateway.py | 49 ++++++++--- homeassistant/components/zha/core/helpers.py | 60 +------------- tests/components/zha/conftest.py | 2 +- tests/components/zha/test_gateway.py | 82 ++++++++++++++++++- 7 files changed, 151 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 2b78c90aa19..00439343e81 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -42,7 +42,7 @@ from ..const import ( ZHA_CLUSTER_HANDLER_MSG_DATA, ZHA_CLUSTER_HANDLER_READS_PER_REQ, ) -from ..helpers import LogMixin, retryable_req, safe_read +from ..helpers import LogMixin, safe_read if TYPE_CHECKING: from ..endpoint import Endpoint @@ -362,7 +362,6 @@ class ClusterHandler(LogMixin): self.debug("skipping cluster handler configuration") self._status = ClusterHandlerStatus.CONFIGURED - @retryable_req(delays=(1, 1, 3)) async def async_initialize(self, from_cache: bool) -> None: """Initialize cluster handler.""" if not from_cache and self._endpoint.device.skip_configuration: diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1a3d3a2da1f..468e89fbbf0 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -592,12 +592,17 @@ class ZHADevice(LogMixin): self.debug("started initialization") await self._zdo_handler.async_initialize(from_cache) self._zdo_handler.debug("'async_initialize' stage succeeded") - await asyncio.gather( - *( - endpoint.async_initialize(from_cache) - for endpoint in self._endpoints.values() - ) - ) + + # We intentionally do not use `gather` here! This is so that if, for example, + # three `device.async_initialize()`s are spawned, only three concurrent requests + # will ever be in flight at once. Startup concurrency is managed at the device + # level. + for endpoint in self._endpoints.values(): + try: + await endpoint.async_initialize(from_cache) + except Exception: # pylint: disable=broad-exception-caught + self.debug("Failed to initialize endpoint", exc_info=True) + self.debug("power source: %s", self.power_source) self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 04c253128ee..4dbfccf6f25 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable +import functools import logging from typing import TYPE_CHECKING, Any, Final, TypeVar @@ -11,6 +12,7 @@ from zigpy.typing import EndpointType as ZigpyEndpointType from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util.async_ import gather_with_limited_concurrency from . import const, discovery, registries from .cluster_handlers import ClusterHandler @@ -169,20 +171,32 @@ class Endpoint: async def async_initialize(self, from_cache: bool = False) -> None: """Initialize claimed cluster handlers.""" - await self._execute_handler_tasks("async_initialize", from_cache) + await self._execute_handler_tasks( + "async_initialize", from_cache, max_concurrency=1 + ) async def async_configure(self) -> None: """Configure claimed cluster handlers.""" await self._execute_handler_tasks("async_configure") - async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: + async def _execute_handler_tasks( + self, func_name: str, *args: Any, max_concurrency: int | None = None + ) -> None: """Add a throttled cluster handler task and swallow exceptions.""" cluster_handlers = [ *self.claimed_cluster_handlers.values(), *self.client_cluster_handlers.values(), ] tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers] - results = await asyncio.gather(*tasks, return_exceptions=True) + + gather: Callable[..., Awaitable] + + if max_concurrency is None: + gather = asyncio.gather + else: + gather = functools.partial(gather_with_limited_concurrency, max_concurrency) + + results = await gather(*tasks, return_exceptions=True) for cluster_handler, outcome in zip(cluster_handlers, results): if isinstance(outcome, Exception): cluster_handler.warning( diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3efdc77934a..cca8aa93e99 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ import itertools import logging import re import time -from typing import TYPE_CHECKING, Any, NamedTuple, Self +from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast from zigpy.application import ControllerApplication from zigpy.config import ( @@ -36,6 +36,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType +from homeassistant.util.async_ import gather_with_limited_concurrency from . import discovery from .const import ( @@ -292,6 +293,39 @@ class ZHAGateway: # entity registry tied to the devices discovery.GROUP_PROBE.discover_group_entities(zha_group) + @property + def radio_concurrency(self) -> int: + """Maximum configured radio concurrency.""" + return self.application_controller._concurrent_requests_semaphore.max_value # pylint: disable=protected-access + + async def async_fetch_updated_state_mains(self) -> None: + """Fetch updated state for mains powered devices.""" + _LOGGER.debug("Fetching current state for mains powered devices") + + now = time.time() + + # Only delay startup to poll mains-powered devices that are online + online_devices = [ + dev + for dev in self.devices.values() + if dev.is_mains_powered + and dev.last_seen is not None + and (now - dev.last_seen) < dev.consider_unavailable_time + ] + + # Prioritize devices that have recently been contacted + online_devices.sort(key=lambda dev: cast(float, dev.last_seen), reverse=True) + + # Make sure that we always leave slots for non-startup requests + max_poll_concurrency = max(1, self.radio_concurrency - 4) + + await gather_with_limited_concurrency( + max_poll_concurrency, + *(dev.async_initialize(from_cache=False) for dev in online_devices), + ) + + _LOGGER.debug("completed fetching current state for mains powered devices") + async def async_initialize_devices_and_entities(self) -> None: """Initialize devices and load entities.""" @@ -302,17 +336,8 @@ class ZHAGateway: async def fetch_updated_state() -> None: """Fetch updated state for mains powered devices.""" - _LOGGER.debug("Fetching current state for mains powered devices") - await asyncio.gather( - *( - dev.async_initialize(from_cache=False) - for dev in self.devices.values() - if dev.is_mains_powered - ) - ) - _LOGGER.debug( - "completed fetching current state for mains powered devices - allowing polled requests" - ) + await self.async_fetch_updated_state_mains() + _LOGGER.debug("Allowing polled requests") self.hass.data[DATA_ZHA].allow_polling = True # background the fetching of state for mains powered devices diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 8e518d805c6..6f0167827e8 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -5,19 +5,15 @@ https://home-assistant.io/integrations/zha/ """ from __future__ import annotations -import asyncio import binascii import collections -from collections.abc import Callable, Collection, Coroutine, Iterator +from collections.abc import Callable, Iterator import dataclasses from dataclasses import dataclass import enum -import functools -import itertools import logging -from random import uniform import re -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar import voluptuous as vol import zigpy.exceptions @@ -322,58 +318,6 @@ class LogMixin: return self.log(logging.ERROR, msg, *args, **kwargs) -def retryable_req( - delays: Collection[float] = (1, 5, 10, 15, 30, 60, 120, 180, 360, 600, 900, 1800), - raise_: bool = False, -) -> Callable[ - [Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R]]], - Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R | None]], -]: - """Make a method with ZCL requests retryable. - - This adds delays keyword argument to function. - len(delays) is number of tries. - raise_ if the final attempt should raise the exception. - """ - - def decorator( - func: Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R]], - ) -> Callable[Concatenate[_ClusterHandlerT, _P], Coroutine[Any, Any, _R | None]]: - @functools.wraps(func) - async def wrapper( - cluster_handler: _ClusterHandlerT, *args: _P.args, **kwargs: _P.kwargs - ) -> _R | None: - exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) - try_count, errors = 1, [] - for delay in itertools.chain(delays, [None]): - try: - return await func(cluster_handler, *args, **kwargs) - except exceptions as ex: - errors.append(ex) - if delay: - delay = uniform(delay * 0.75, delay * 1.25) - cluster_handler.debug( - "%s: retryable request #%d failed: %s. Retrying in %ss", - func.__name__, - try_count, - ex, - round(delay, 1), - ) - try_count += 1 - await asyncio.sleep(delay) - else: - cluster_handler.warning( - "%s: all attempts have failed: %s", func.__name__, errors - ) - if raise_: - raise - return None - - return wrapper - - return decorator - - def convert_install_code(value: str) -> bytes: """Convert string to install code bytes and validate length.""" diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 55405d0a51c..a30c6f35052 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -135,7 +135,7 @@ def _wrap_mock_instance(obj: Any) -> MagicMock: real_attr = getattr(obj, attr_name) mock_attr = getattr(mock, attr_name) - if callable(real_attr): + if callable(real_attr) and not hasattr(real_attr, "__aenter__"): mock_attr.side_effect = real_attr else: setattr(mock, attr_name, real_attr) diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 9c3cf7aa2f8..f19ed9bd4a9 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,12 +1,14 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha +import zigpy.types import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting +import zigpy.zdo.types from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.group import GroupMember @@ -321,3 +323,81 @@ async def test_single_reload_on_multiple_connection_loss( assert len(mock_reload.mock_calls) == 1 await hass.async_block_till_done() + + +@pytest.mark.parametrize("radio_concurrency", [1, 2, 8]) +async def test_startup_concurrency_limit( + radio_concurrency: int, + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, + zigpy_device_mock, +): + """Test ZHA gateway limits concurrency on startup.""" + config_entry.add_to_hass(hass) + zha_gateway = ZHAGateway(hass, {}, config_entry) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_gateway.async_initialize() + + for i in range(50): + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=f"11:22:33:44:{i:08x}", + nwk=0x1234 + i, + ) + zigpy_dev.node_desc.mac_capability_flags |= ( + zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered + ) + + zha_gateway._async_get_or_create_device(zigpy_dev, restored=True) + + # Keep track of request concurrency during initialization + current_concurrency = 0 + concurrencies = [] + + async def mock_send_packet(*args, **kwargs): + nonlocal current_concurrency + + current_concurrency += 1 + concurrencies.append(current_concurrency) + + await asyncio.sleep(0.001) + + current_concurrency -= 1 + concurrencies.append(current_concurrency) + + type(zha_gateway).radio_concurrency = PropertyMock(return_value=radio_concurrency) + assert zha_gateway.radio_concurrency == radio_concurrency + + with patch( + "homeassistant.components.zha.core.device.ZHADevice.async_initialize", + side_effect=mock_send_packet, + ): + await zha_gateway.async_fetch_updated_state_mains() + + await zha_gateway.shutdown() + + # Make sure concurrency was always limited + assert current_concurrency == 0 + assert min(concurrencies) == 0 + + if radio_concurrency > 1: + assert 1 <= max(concurrencies) < zha_gateway.radio_concurrency + else: + assert 1 == max(concurrencies) == zha_gateway.radio_concurrency From 274d501bcab99fd06ac3584513d054ac6f29d3cf Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 18 Jan 2024 03:33:31 +0100 Subject: [PATCH 0730/1544] Bump aiounifi to v69 to improve websocket logging (#108265) --- homeassistant/components/unifi/controller.py | 6 +++++- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index a941e836ae2..833d2001980 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -7,6 +7,7 @@ import ssl from types import MappingProxyType from typing import Any, Literal +import aiohttp from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent @@ -374,7 +375,10 @@ class UniFiController: async def _websocket_runner() -> None: """Start websocket.""" - await self.api.start_websocket() + try: + await self.api.start_websocket() + except (aiohttp.ClientConnectorError, aiounifi.WebsocketError): + LOGGER.error("Websocket disconnected") self.available = False async_dispatcher_send(self.hass, self.signal_reachable) self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 4a43a65d5bb..90b4421f164 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==68"], + "requirements": ["aiounifi==69"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 81ff6d99ba9..41e24532fc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==68 +aiounifi==69 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8acda73d10f..76c454e9527 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==68 +aiounifi==69 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 154fe8631a3604054e2297950a9099d484be7645 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 18 Jan 2024 03:34:18 +0100 Subject: [PATCH 0731/1544] Use cache update for WIFI blinds (#108224) --- homeassistant/components/motion_blinds/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index cfc7d319b38..e8dc5494f25 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -5,7 +5,7 @@ import logging from socket import timeout from typing import Any -from motionblinds import ParseException +from motionblinds import DEVICE_TYPES_WIFI, ParseException from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -59,7 +59,9 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): def update_blind(self, blind): """Fetch data from a blind.""" try: - if self._wait_for_push: + if blind.device_type in DEVICE_TYPES_WIFI: + blind.Update_from_cache() + elif self._wait_for_push: blind.Update() else: blind.Update_trigger() From 484584084a301d83179b73c11e9a4943bb01b146 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 17 Jan 2024 21:35:53 -0500 Subject: [PATCH 0732/1544] Allow multiple config entries in Honeywell (#108263) * Address popping all entires when unloading * optimize hass data --- homeassistant/components/honeywell/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index f5cce1d890a..816d2abf78c 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -85,8 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return False data = HoneywellData(config_entry.entry_id, client, devices) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = data + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) @@ -105,7 +104,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) if unload_ok: - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok From 2d1c5d84f3962bff7d3043addc4c3fb96eb2ad02 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 17 Jan 2024 21:37:21 -0500 Subject: [PATCH 0733/1544] Remove unused variables in honeywell (#108252) Remove unused configuration keys --- homeassistant/components/honeywell/__init__.py | 11 ++--------- homeassistant/components/honeywell/const.py | 2 -- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 816d2abf78c..c79d99276b1 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -13,9 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( _LOGGER, CONF_COOL_AWAY_TEMPERATURE, - CONF_DEV_ID, CONF_HEAT_AWAY_TEMPERATURE, - CONF_LOC_ID, DOMAIN, ) @@ -70,15 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "Failed to initialize the Honeywell client: Connection error" ) from ex - loc_id = config_entry.data.get(CONF_LOC_ID) - dev_id = config_entry.data.get(CONF_DEV_ID) - devices = {} for location in client.locations_by_id.values(): - if not loc_id or location.locationid == loc_id: - for device in location.devices_by_id.values(): - if not dev_id or device.deviceid == dev_id: - devices[device.deviceid] = device + for device in location.devices_by_id.values(): + devices[device.deviceid] = device if len(devices) == 0: _LOGGER.debug("No devices found") diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index 32846563c44..28868812e24 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -7,7 +7,5 @@ CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" DEFAULT_COOL_AWAY_TEMPERATURE = 88 DEFAULT_HEAT_AWAY_TEMPERATURE = 61 -CONF_DEV_ID = "thermostat" -CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) RETRY = 3 From cfbfdf7949baabf6f2abe3ee33c7c03c11a34c68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 17:43:14 -1000 Subject: [PATCH 0734/1544] Fix apple_tv IP Address not being updated from discovery (#107611) --- .../components/apple_tv/config_flow.py | 12 +++++- tests/components/apple_tv/test_config_flow.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index fc65253fe43..251d1e377d3 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -126,7 +126,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @callback def _entry_unique_id_from_identifers(self, all_identifiers: set[str]) -> str | None: """Search existing entries for an identifier and return the unique id.""" - for entry in self._async_current_entries(): + for entry in self._async_current_entries(include_ignore=True): if not all_identifiers.isdisjoint( entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]) ): @@ -186,7 +186,6 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_not_supported") host = discovery_info.host - self._async_abort_entries_match({CONF_ADDRESS: host}) service_type = discovery_info.type[:-1] # Remove leading . name = discovery_info.name.replace(f".{service_type}.", "") properties = discovery_info.properties @@ -196,6 +195,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if unique_id is None: return self.async_abort(reason="unknown") + # The unique id for the zeroconf service may not be + # the same as the unique id for the device. If the + # device is already configured so if we don't + # find a match here, we will fallback to + # looking up the device by all its identifiers + # in the next block. + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host}) + if existing_unique_id := self._entry_unique_id_from_identifers({unique_id}): await self.async_set_unique_id(existing_unique_id) self._abort_if_unique_id_configured(updates={CONF_ADDRESS: host}) diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 513c21f7ce5..714fe987bc8 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -691,6 +691,44 @@ async def test_zeroconf_ip_change(hass: HomeAssistant, mock_scan) -> None: assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" +async def test_zeroconf_ip_change_after_ip_conflict_with_ignored_entry( + hass: HomeAssistant, mock_scan +) -> None: + """Test that the config entry gets updated when the ip changes and reloads.""" + entry = MockConfigEntry( + domain="apple_tv", unique_id="mrpid", data={CONF_ADDRESS: "127.0.0.2"} + ) + ignored_entry = MockConfigEntry( + domain="apple_tv", + unique_id="unrelated", + data={CONF_ADDRESS: "127.0.0.2"}, + source=config_entries.SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_async_setup.mock_calls) == 1 + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert ignored_entry.data[CONF_ADDRESS] == "127.0.0.2" + + async def test_zeroconf_ip_change_via_secondary_identifier( hass: HomeAssistant, mock_scan ) -> None: From 19258cb3dfe1627351a97c04a2be6db26071327d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 20:45:44 -1000 Subject: [PATCH 0735/1544] Fix benign typo in entity registry (#108270) --- homeassistant/helpers/entity_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 049d136ed72..022fa045a3e 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -133,7 +133,7 @@ EventEntityRegistryUpdatedData = ( EntityOptionsType = Mapping[str, Mapping[str, Any]] ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] -DISLAY_DICT_OPTIONAL = ( +DISPLAY_DICT_OPTIONAL = ( ("ai", "area_id"), ("di", "device_id"), ("tk", "translation_key"), @@ -208,7 +208,7 @@ class RegistryEntry: Returns None if there's no data needed for display. """ display_dict: dict[str, Any] = {"ei": self.entity_id, "pl": self.platform} - for key, attr_name in DISLAY_DICT_OPTIONAL: + for key, attr_name in DISPLAY_DICT_OPTIONAL: if (attr_val := getattr(self, attr_name)) is not None: display_dict[key] = attr_val if (category := self.entity_category) is not None: From c656024365552bef95320c0bb600c185b42b69b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jan 2024 08:15:17 +0100 Subject: [PATCH 0736/1544] Bump github/codeql-action from 3.23.0 to 3.23.1 (#108275) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 401580becfd..8c33ec5a5a7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.23.0 + uses: github/codeql-action/init@v3.23.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.23.0 + uses: github/codeql-action/analyze@v3.23.1 with: category: "/language:python" From b4b041d4bf300b3a41f44812d0182440005dcd47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 21:39:49 -1000 Subject: [PATCH 0737/1544] Small cleanups to the websocket api handler (#108274) --- .../components/websocket_api/auth.py | 16 +++++------- .../components/websocket_api/http.py | 25 +++++++++++-------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index f09c2601328..0a681692c3d 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -12,6 +12,7 @@ from homeassistant.auth.models import RefreshToken, User from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.const import __version__ from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.json import json_bytes from homeassistant.util.json import JsonValueType from .connection import ActiveConnection @@ -34,15 +35,10 @@ AUTH_MESSAGE_SCHEMA: Final = vol.Schema( } ) - -def auth_ok_message() -> dict[str, str]: - """Return an auth_ok message.""" - return {"type": TYPE_AUTH_OK, "ha_version": __version__} - - -def auth_required_message() -> dict[str, str]: - """Return an auth_required message.""" - return {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} +AUTH_OK_MESSAGE = json_bytes({"type": TYPE_AUTH_OK, "ha_version": __version__}) +AUTH_REQUIRED_MESSAGE = json_bytes( + {"type": TYPE_AUTH_REQUIRED, "ha_version": __version__} +) def auth_invalid_message(message: str) -> dict[str, str]: @@ -104,7 +100,7 @@ class AuthPhase: """Create an active connection.""" self._logger.debug("Auth OK") process_success_login(self._request) - self._send_message(auth_ok_message()) + self._send_message(AUTH_OK_MESSAGE) return ActiveConnection( self._logger, self._hass, self._send_message, user, refresh_token ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index c8c5d00cb2a..d966e4e26ef 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -18,7 +18,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.json import json_loads -from .auth import AuthPhase, auth_required_message +from .auth import AUTH_REQUIRED_MESSAGE, AuthPhase from .const import ( DATA_CONNECTIONS, MAX_PENDING_MSG, @@ -266,6 +266,11 @@ class WebSocketHandler: if self._writer_task is not None: self._writer_task.cancel() + @callback + def _async_handle_hass_stop(self, event: Event) -> None: + """Cancel this connection.""" + self._cancel() + async def async_handle(self) -> web.WebSocketResponse: """Handle a websocket response.""" request = self._request @@ -286,12 +291,9 @@ class WebSocketHandler: debug("%s: Connected from %s", self.description, request.remote) self._handle_task = asyncio.current_task() - @callback - def handle_hass_stop(event: Event) -> None: - """Cancel this connection.""" - self._cancel() - - unsub_stop = hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_hass_stop) + unsub_stop = hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop + ) # As the webserver is now started before the start # event we do not want to block for websocket responses @@ -302,7 +304,7 @@ class WebSocketHandler: disconnect_warn = None try: - self._send_message(auth_required_message()) + self._send_message(AUTH_REQUIRED_MESSAGE) # Auth Phase try: @@ -379,7 +381,7 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break - if msg.type == WSMsgType.BINARY: + if msg.type is WSMsgType.BINARY: if len(msg.data) < 1: disconnect_warn = "Received invalid binary message." break @@ -388,7 +390,7 @@ class WebSocketHandler: async_handle_binary(handler, payload) continue - if msg.type != WSMsgType.TEXT: + if msg.type is not WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break @@ -401,7 +403,8 @@ class WebSocketHandler: if is_enabled_for(logging_debug): debug("%s: Received %s", self.description, command_msg_data) - if not isinstance(command_msg_data, list): + # command_msg_data is always deserialized from JSON as a list + if type(command_msg_data) is not list: # noqa: E721 async_handle_str(command_msg_data) continue From 52e90b32df3d8af7109cb1cecee9777899362f98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 21:44:40 -1000 Subject: [PATCH 0738/1544] Avoid many replace calls in find_next_time_expression_time (#108273) --- homeassistant/util/dt.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 81237e1eca6..47863d32e67 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -393,7 +393,8 @@ def find_next_time_expression_time( next_second = seconds[0] result += dt.timedelta(minutes=1) - result = result.replace(second=next_second) + if result.second != next_second: + result = result.replace(second=next_second) # Match next minute next_minute = _lower_bound(minutes, result.minute) @@ -406,7 +407,8 @@ def find_next_time_expression_time( next_minute = minutes[0] result += dt.timedelta(hours=1) - result = result.replace(minute=next_minute) + if result.minute != next_minute: + result = result.replace(minute=next_minute) # Match next hour next_hour = _lower_bound(hours, result.hour) @@ -419,7 +421,8 @@ def find_next_time_expression_time( next_hour = hours[0] result += dt.timedelta(days=1) - result = result.replace(hour=next_hour) + if result.hour != next_hour: + result = result.replace(hour=next_hour) if result.tzinfo in (None, UTC): # Using UTC, no DST checking needed From 26cc6a5bb4383217d241f18e23241aebfc59cd31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Jan 2024 21:53:55 -1000 Subject: [PATCH 0739/1544] Add state caching to button entities (#108272) --- homeassistant/components/button/__init__.py | 22 +++++++++----- tests/components/button/test_init.py | 33 ++++++++++++++++----- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 358348a8077..3ecc27f8573 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,7 +1,7 @@ """Component to pressing a button as platforms.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta from enum import StrEnum import logging from typing import TYPE_CHECKING, final @@ -95,7 +95,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ _attr_should_poll = False _attr_device_class: ButtonDeviceClass | None _attr_state: None = None - __last_pressed: datetime | None = None + __last_pressed_isoformat: str | None = None def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class. @@ -113,13 +113,19 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ return self.entity_description.device_class return None - @property + @cached_property @final def state(self) -> str | None: """Return the entity state.""" - if self.__last_pressed is None: - return None - return self.__last_pressed.isoformat() + return self.__last_pressed_isoformat + + def __set_state(self, state: str | None) -> None: + """Set the entity state.""" + try: # noqa: SIM105 suppress is much slower + del self.state + except AttributeError: + pass + self.__last_pressed_isoformat = state @final async def _async_press_action(self) -> None: @@ -127,7 +133,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ Should not be overridden, handle setting last press timestamp. """ - self.__last_pressed = dt_util.utcnow() + self.__set_state(dt_util.utcnow().isoformat()) self.async_write_ha_state() await self.async_press() @@ -136,7 +142,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ await super().async_internal_added_to_hass() state = await self.async_get_last_state() if state is not None and state.state is not None: - self.__last_pressed = dt_util.parse_datetime(state.state) + self.__set_state(state.state) def press(self) -> None: """Press the button.""" diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 24f893578ce..2457a796d45 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -1,7 +1,9 @@ """The tests for the Button component.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.button import ( @@ -51,6 +53,7 @@ async def test_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, + freezer: FrozenDateTimeFactory, ) -> None: """Test we integration.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -62,17 +65,31 @@ async def test_custom_integration( assert hass.states.get("button.button_1").state == STATE_UNKNOWN now = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow", return_value=now): - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.button_1"}, - blocking=True, - ) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.button_1"}, + blocking=True, + ) assert hass.states.get("button.button_1").state == now.isoformat() assert "The button has been pressed" in caplog.text + now_isoformat = dt_util.utcnow().isoformat() + assert hass.states.get("button.button_1").state == now_isoformat + + new_time = dt_util.utcnow() + timedelta(weeks=1) + freezer.move_to(new_time) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.button_1"}, + blocking=True, + ) + + new_time_isoformat = new_time.isoformat() + assert hass.states.get("button.button_1").state == new_time_isoformat + async def test_restore_state( hass: HomeAssistant, enable_custom_integrations: None From afcb7a26cd8d75dc1356ea3debe05f2116c110ec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:20:19 +0100 Subject: [PATCH 0740/1544] Enable strict typing for config (#108023) --- .strict-typing | 1 + homeassistant/components/config/__init__.py | 110 ++++++++++++------ .../components/config/area_registry.py | 8 +- homeassistant/components/config/auth.py | 7 +- .../config/auth_provider_homeassistant.py | 6 +- homeassistant/components/config/automation.py | 16 ++- .../components/config/config_entries.py | 52 +++++---- homeassistant/components/config/core.py | 6 +- .../components/config/device_registry.py | 2 +- homeassistant/components/config/scene.py | 17 ++- homeassistant/components/config/script.py | 17 ++- homeassistant/util/yaml/dumper.py | 2 +- mypy.ini | 10 ++ 13 files changed, 178 insertions(+), 76 deletions(-) diff --git a/.strict-typing b/.strict-typing index e5de22ce608..ff2bd9800e7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -122,6 +122,7 @@ homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.co2signal.* homeassistant.components.command_line.* +homeassistant.components.config.* homeassistant.components.configurator.* homeassistant.components.counter.* homeassistant.components.cover.* diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 84a1c2eaa17..05e5d3b9a2d 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,9 +1,14 @@ """Component to configure Home Assistant via an API.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable, Coroutine from http import HTTPStatus import importlib import os +from typing import Any, Generic, TypeVar, cast +from aiohttp import web import voluptuous as vol from homeassistant.components import frontend @@ -16,6 +21,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ATTR_COMPONENT from homeassistant.util.file import write_utf8_file_atomic from homeassistant.util.yaml import dump, load_yaml +from homeassistant.util.yaml.loader import JSON_TYPE + +_DataT = TypeVar("_DataT", dict[str, dict[str, Any]], list[dict[str, Any]]) DOMAIN = "config" SECTIONS = ( @@ -42,7 +50,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, "config", "config", "hass:cog", require_admin=True ) - async def setup_panel(panel_name): + async def setup_panel(panel_name: str) -> None: """Set up a panel.""" panel = importlib.import_module(f".{panel_name}", __name__) @@ -63,20 +71,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class BaseEditConfigView(HomeAssistantView): +class BaseEditConfigView(HomeAssistantView, Generic[_DataT]): """Configure a Group endpoint.""" def __init__( self, - component, - config_type, - path, - key_schema, - data_schema, + component: str, + config_type: str, + path: str, + key_schema: Callable[[Any], str], + data_schema: Callable[[dict[str, Any]], Any], *, - post_write_hook=None, - data_validator=None, - ): + post_write_hook: Callable[[str, str], Coroutine[Any, Any, None]] | None = None, + data_validator: Callable[ + [HomeAssistant, str, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any] | None], + ] + | None = None, + ) -> None: """Initialize a config view.""" self.url = f"/api/config/{component}/{config_type}/{{config_key}}" self.name = f"api:config:{component}:{config_type}" @@ -87,26 +99,36 @@ class BaseEditConfigView(HomeAssistantView): self.data_validator = data_validator self.mutation_lock = asyncio.Lock() - def _empty_config(self): + def _empty_config(self) -> _DataT: """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: _DataT, config_key: str + ) -> dict[str, Any] | None: """Get value.""" raise NotImplementedError - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: _DataT, + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" raise NotImplementedError - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: _DataT, config_key: str + ) -> dict[str, Any] | None: """Delete value.""" raise NotImplementedError @require_admin - async def get(self, request, config_key): + async def get(self, request: web.Request, config_key: str) -> web.Response: """Fetch device specific config.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self.mutation_lock: current = await self.read_config(hass) value = self._get_value(hass, current, config_key) @@ -117,7 +139,7 @@ class BaseEditConfigView(HomeAssistantView): return self.json(value) @require_admin - async def post(self, request, config_key): + async def post(self, request: web.Request, config_key: str) -> web.Response: """Validate config and return results.""" try: data = await request.json() @@ -129,7 +151,7 @@ class BaseEditConfigView(HomeAssistantView): except vol.Invalid as err: return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: # We just validate, we don't store that data because @@ -159,9 +181,9 @@ class BaseEditConfigView(HomeAssistantView): return self.json({"result": "ok"}) @require_admin - async def delete(self, request, config_key): + async def delete(self, request: web.Request, config_key: str) -> web.Response: """Remove an entry.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] async with self.mutation_lock: current = await self.read_config(hass) value = self._get_value(hass, current, config_key) @@ -178,46 +200,64 @@ class BaseEditConfigView(HomeAssistantView): return self.json({"result": "ok"}) - async def read_config(self, hass): + async def read_config(self, hass: HomeAssistant) -> _DataT: """Read the config.""" current = await hass.async_add_executor_job(_read, hass.config.path(self.path)) if not current: current = self._empty_config() - return current + return cast(_DataT, current) -class EditKeyBasedConfigView(BaseEditConfigView): +class EditKeyBasedConfigView(BaseEditConfigView[dict[str, dict[str, Any]]]): """Configure a list of entries.""" - def _empty_config(self): + def _empty_config(self) -> dict[str, Any]: """Return an empty config.""" return {} - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str + ) -> dict[str, Any] | None: """Get value.""" return data.get(config_key) - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: dict[str, dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" data.setdefault(config_key, {}).update(new_value) - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str + ) -> dict[str, Any]: """Delete value.""" return data.pop(config_key) -class EditIdBasedConfigView(BaseEditConfigView): +class EditIdBasedConfigView(BaseEditConfigView[list[dict[str, Any]]]): """Configure key based config entries.""" - def _empty_config(self): + def _empty_config(self) -> list[Any]: """Return an empty config.""" return [] - def _get_value(self, hass, data, config_key): + def _get_value( + self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str + ) -> dict[str, Any] | None: """Get value.""" return next((val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" if (value := self._get_value(hass, data, config_key)) is None: value = {CONF_ID: config_key} @@ -225,7 +265,9 @@ class EditIdBasedConfigView(BaseEditConfigView): value.update(new_value) - def _delete_value(self, hass, data, config_key): + def _delete_value( + self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str + ) -> None: """Delete value.""" index = next( idx for idx, val in enumerate(data) if val.get(CONF_ID) == config_key @@ -233,7 +275,7 @@ class EditIdBasedConfigView(BaseEditConfigView): data.pop(index) -def _read(path): +def _read(path: str) -> JSON_TYPE | None: """Read YAML helper.""" if not os.path.isfile(path): return None @@ -241,7 +283,7 @@ def _read(path): return load_yaml(path) -def _write(path, data): +def _write(path: str, data: dict | list) -> None: """Write YAML helper.""" # Do it before opening file. If dump causes error it will now not # truncate the file. diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index d41c712dffb..88f619ee349 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -1,14 +1,16 @@ """HTTP views to interact with the area registry.""" +from __future__ import annotations + from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.area_registry import async_get +from homeassistant.helpers.area_registry import AreaEntry, async_get -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Area Registry views.""" websocket_api.async_register_command(hass, websocket_list_areas) websocket_api.async_register_command(hass, websocket_create_area) @@ -126,7 +128,7 @@ def websocket_update_area( @callback -def _entry_dict(entry): +def _entry_dict(entry: AreaEntry) -> dict[str, Any]: """Convert entry to API format.""" return { "aliases": entry.aliases, diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 1699a4c8509..355dc739a9c 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -1,8 +1,11 @@ """Offer API to configure Home Assistant auth.""" +from __future__ import annotations + from typing import Any import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant @@ -17,7 +20,7 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command( hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST @@ -151,7 +154,7 @@ async def websocket_update( ) -def _user_info(user): +def _user_info(user: User) -> dict[str, Any]: """Format a user.""" ha_username = next( diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index d0606a748a9..c8b7e91f5a7 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -1,4 +1,6 @@ """Offer API to configure the Home Assistant auth provider.""" +from __future__ import annotations + from typing import Any import voluptuous as vol @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import Unauthorized -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command(hass, websocket_create) websocket_api.async_register_command(hass, websocket_delete) @@ -115,7 +117,7 @@ async def websocket_change_password( ) -> None: """Change current user password.""" if (user := connection.user) is None: - connection.send_error(msg["id"], "user_not_found", "User not found") + connection.send_error(msg["id"], "user_not_found", "User not found") # type: ignore[unreachable] return provider = auth_ha.async_get_provider(hass) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 72a493f8c1f..b3c7b27dc70 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,4 +1,7 @@ """Provide configuration end points for Automations.""" +from __future__ import annotations + +from typing import Any import uuid from homeassistant.components.automation.config import ( @@ -8,15 +11,16 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Automation config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads automations.""" await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -49,7 +53,13 @@ async def async_setup(hass): class EditAutomationConfigView(EditIdBasedConfigView): """Edit automation config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" updated_value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 77e2548d424..c289459a2af 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,8 +1,9 @@ """Http views to control the config manager.""" from __future__ import annotations +from collections.abc import Callable from http import HTTPStatus -from typing import Any +from typing import Any, NoReturn from aiohttp import web import aiohttp.web_exceptions @@ -29,7 +30,7 @@ from homeassistant.loader import ( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) @@ -58,7 +59,7 @@ class ConfigManagerEntryIndexView(HomeAssistantView): url = "/api/config/config_entries/entry" name = "api:config:config_entries:entry" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """List available config entries.""" hass: HomeAssistant = request.app["hass"] domain = None @@ -76,12 +77,12 @@ class ConfigManagerEntryResourceView(HomeAssistantView): url = "/api/config/config_entries/entry/{entry_id}" name = "api:config:config_entries:entry:resource" - async def delete(self, request, entry_id): + async def delete(self, request: web.Request, entry_id: str) -> web.Response: """Delete a config entry.""" if not request["hass_user"].is_admin: raise Unauthorized(config_entry_id=entry_id, permission="remove") - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: result = await hass.config_entries.async_remove(entry_id) @@ -97,12 +98,12 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): url = "/api/config/config_entries/entry/{entry_id}/reload" name = "api:config:config_entries:entry:resource:reload" - async def post(self, request, entry_id): + async def post(self, request: web.Request, entry_id: str) -> web.Response: """Reload a config entry.""" if not request["hass_user"].is_admin: raise Unauthorized(config_entry_id=entry_id, permission="remove") - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] entry = hass.config_entries.async_get_entry(entry_id) if not entry: return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) @@ -116,7 +117,12 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): return self.json({"require_restart": not entry.state.recoverable}) -def _prepare_config_flow_result_json(result, prepare_result_json): +def _prepare_config_flow_result_json( + result: data_entry_flow.FlowResult, + prepare_result_json: Callable[ + [data_entry_flow.FlowResult], data_entry_flow.FlowResult + ], +) -> data_entry_flow.FlowResult: """Convert result to JSON.""" if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) @@ -134,14 +140,14 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): url = "/api/config/config_entries/flow" name = "api:config:config_entries:flow" - async def get(self, request): + async def get(self, request: web.Request) -> NoReturn: """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" try: return await super().post(request) @@ -151,7 +157,9 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): status=HTTPStatus.BAD_REQUEST, ) - def _prepare_result_json(self, result): + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) @@ -165,18 +173,20 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def get(self, request, /, flow_id): + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") ) - async def post(self, request, flow_id): + async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) - def _prepare_result_json(self, result): + def _prepare_result_json( + self, result: data_entry_flow.FlowResult + ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" return _prepare_config_flow_result_json(result, super()._prepare_result_json) @@ -187,10 +197,10 @@ class ConfigManagerAvailableFlowView(HomeAssistantView): url = "/api/config/config_entries/flow_handlers" name = "api:config:config_entries:flow_handlers" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """List available flow handlers.""" - hass = request.app["hass"] - kwargs = {} + hass: HomeAssistant = request.app["hass"] + kwargs: dict[str, Any] = {} if "type" in request.query: kwargs["type_filter"] = request.query["type"] return self.json(await async_get_config_flows(hass, **kwargs)) @@ -205,7 +215,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request. handler in request is entry_id. @@ -222,14 +232,14 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def get(self, request, /, flow_id): + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" return await super().get(request, flow_id) @require_admin( error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) ) - async def post(self, request, flow_id): + async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" return await super().post(request, flow_id) @@ -535,7 +545,7 @@ async def async_matching_config_entries( @callback -def entry_json(entry: config_entries.ConfigEntry) -> dict: +def entry_json(entry: config_entries.ConfigEntry) -> dict[str, Any]: """Return JSON value of a config entry.""" handler = config_entries.HANDLERS.get(entry.domain) # work out if handler has support for options flow diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 4c64028874d..e6eac5f6e8e 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,7 +1,9 @@ """Component to interact with Hassbian tools.""" +from __future__ import annotations from typing import Any +from aiohttp import web import voluptuous as vol from homeassistant.components import websocket_api @@ -13,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, unit_system -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Hassbian config.""" hass.http.register_view(CheckConfigView) websocket_api.async_register_command(hass, websocket_update_config) @@ -28,7 +30,7 @@ class CheckConfigView(HomeAssistantView): name = "api:config:core:check_config" @require_admin - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Validate configuration and return results.""" res = await check_config.async_check_ha_config_file(request.app["hass"]) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index cbf3623e7a8..dfa55b02c30 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -17,7 +17,7 @@ from homeassistant.helpers.device_registry import ( ) -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Enable the Device Registry views.""" websocket_api.async_register_command(hass, websocket_list_devices) diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index 037cd55d6a0..efbfd73db05 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -1,19 +1,22 @@ """Provide configuration end points for Scenes.""" +from __future__ import annotations + +from typing import Any import uuid from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA from homeassistant.config import SCENE_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the Scene config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scenes.""" if action != ACTION_DELETE: await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -44,7 +47,13 @@ async def async_setup(hass): class EditSceneConfigView(EditIdBasedConfigView): """Edit scene config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: list[dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" updated_value = {CONF_ID: config_key} # Iterate through some keys that we want to have ordered in the output diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index 73f89aaf509..aa8a2a52d83 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,4 +1,8 @@ """Provide configuration end points for scripts.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.script import DOMAIN from homeassistant.components.script.config import ( SCRIPT_ENTITY_SCHEMA, @@ -6,15 +10,16 @@ from homeassistant.components.script.config import ( ) from homeassistant.config import SCRIPT_CONFIG_PATH from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditKeyBasedConfigView -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> bool: """Set up the script config API.""" - async def hook(action, config_key): + async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scripts.""" if action != ACTION_DELETE: await hass.services.async_call(DOMAIN, SERVICE_RELOAD) @@ -46,6 +51,12 @@ async def async_setup(hass): class EditScriptConfigView(EditKeyBasedConfigView): """Edit script config.""" - def _write_value(self, hass, data, config_key, new_value): + def _write_value( + self, + hass: HomeAssistant, + data: dict[str, dict[str, Any]], + config_key: str, + new_value: dict[str, Any], + ) -> None: """Set value.""" data[config_key] = new_value diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 65747d1fd3e..ec4700ef17e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -17,7 +17,7 @@ except ImportError: ) -def dump(_dict: dict) -> str: +def dump(_dict: dict | list) -> str: """Dump YAML to a string and remove null.""" return yaml.dump( _dict, diff --git a/mypy.ini b/mypy.ini index 84e0a494f65..d1918acbf66 100644 --- a/mypy.ini +++ b/mypy.ini @@ -980,6 +980,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.config.*] +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.configurator.*] check_untyped_defs = true disallow_incomplete_defs = true From 3761d1391507835118ff37b8d847a32f0bd6fc77 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:24:48 +0100 Subject: [PATCH 0741/1544] Improve daikin typing (#108039) --- homeassistant/components/daikin/__init__.py | 13 +++++++-- homeassistant/components/daikin/climate.py | 24 ++++++++-------- .../components/daikin/config_flow.py | 28 ++++++++++++++----- homeassistant/components/daikin/switch.py | 2 +- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index cc79f2ae233..e39fe97bc6c 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -1,7 +1,10 @@ """Platform for the Daikin AC.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import Any from aiohttp import ClientConnectionError from pydaikin.daikin_base import Appliance @@ -68,7 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): +async def daikin_api_setup( + hass: HomeAssistant, + host: str, + key: str | None, + uuid: str | None, + password: str | None, +) -> DaikinApi | None: """Create a Daikin instance only once.""" session = async_get_clientsession(hass) @@ -103,7 +112,7 @@ class DaikinApi: self._available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self, **kwargs): + async def async_update(self, **kwargs: Any) -> None: """Pull the latest data from Daikin.""" try: await self.device.update_status() diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index c848e0b703e..0c955b1ce4f 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities([DaikinClimate(daikin_api)], update_before_add=True) -def format_target_temperature(target_temperature): +def format_target_temperature(target_temperature: float) -> str: """Format target temperature to be sent to the Daikin unit, rounding to nearest half degree.""" return str(round(float(target_temperature) * 2, 0) / 2).rstrip("0").rstrip(".") @@ -126,6 +126,8 @@ class DaikinClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HA_STATE_TO_DAIKIN) _attr_target_temperature_step = 1 + _attr_fan_modes: list[str] + _attr_swing_modes: list[str] def __init__(self, api: DaikinApi) -> None: """Initialize the climate device.""" @@ -134,7 +136,7 @@ class DaikinClimate(ClimateEntity): self._attr_fan_modes = api.device.fan_rate self._attr_swing_modes = api.device.swing_modes self._attr_device_info = api.device_info - self._list = { + self._list: dict[str, list[Any]] = { ATTR_HVAC_MODE: self._attr_hvac_modes, ATTR_FAN_MODE: self._attr_fan_modes, ATTR_SWING_MODE: self._attr_swing_modes, @@ -151,9 +153,9 @@ class DaikinClimate(ClimateEntity): if api.device.support_swing_mode: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE - async def _set(self, settings): + async def _set(self, settings: dict[str, Any]) -> None: """Set device settings using API.""" - values = {} + values: dict[str, Any] = {} for attr in (ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE): if (value := settings.get(attr)) is None: @@ -180,17 +182,17 @@ class DaikinClimate(ClimateEntity): await self._api.device.set(values) @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._api.device.mac @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._api.device.inside_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._api.device.target_temperature @@ -221,7 +223,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_HVAC_MODE: hvac_mode}) @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the fan setting.""" return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() @@ -230,7 +232,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_FAN_MODE: fan_mode}) @property - def swing_mode(self): + def swing_mode(self) -> str: """Return the fan setting.""" return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() @@ -239,7 +241,7 @@ class DaikinClimate(ClimateEntity): await self._set({ATTR_SWING_MODE: swing_mode}) @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the preset_mode.""" if ( self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] @@ -282,7 +284,7 @@ class DaikinClimate(ClimateEntity): ) @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """List of available preset modes.""" ret = [PRESET_NONE] if self._api.device.support_away_mode: diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 2d5d1e12dfd..b79cc960fce 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -1,6 +1,9 @@ """Config flow for the Daikin platform.""" +from __future__ import annotations + import asyncio import logging +from typing import Any from uuid import uuid4 from aiohttp import ClientError, web_exceptions @@ -24,12 +27,12 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Daikin config flow.""" - self.host = None + self.host: str | None = None @property - def schema(self): + def schema(self) -> vol.Schema: """Return current schema.""" return vol.Schema( { @@ -39,7 +42,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def _create_entry(self, host, mac, key=None, uuid=None, password=None): + async def _create_entry( + self, + host: str, + mac: str, + key: str | None = None, + uuid: str | None = None, + password: str | None = None, + ) -> FlowResult: """Register new entry.""" if not self.unique_id: await self.async_set_unique_id(mac) @@ -56,7 +66,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def _create_device(self, host, key=None, password=None): + async def _create_device( + self, host: str, key: str | None = None, password: str | None = None + ) -> FlowResult: """Create device.""" # BRP07Cxx devices needs uuid together with key if key: @@ -108,12 +120,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): mac = device.mac return await self._create_entry(host, mac, key, uuid, password) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """User initiated config flow.""" if user_input is None: return self.async_show_form(step_id="user", data_schema=self.schema) if user_input.get(CONF_API_KEY) and user_input.get(CONF_PASSWORD): - self.host = user_input.get(CONF_HOST) + self.host = user_input[CONF_HOST] return self.async_show_form( step_id="user", data_schema=self.schema, diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 7acd234e397..8741898237e 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -61,7 +61,7 @@ class DaikinZoneSwitch(SwitchEntity): _attr_icon = ZONE_ICON _attr_has_entity_name = True - def __init__(self, api: DaikinApi, zone_id) -> None: + def __init__(self, api: DaikinApi, zone_id: int) -> None: """Initialize the zone.""" self._api = api self._zone_id = zone_id From 83e0a7528d8052005c338530ceb430e52e3ce08a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 18 Jan 2024 10:57:41 +0100 Subject: [PATCH 0742/1544] Add diagnostics to La Marzocco (#108240) * add diagnostics * make firmware section easier to read --- .../components/lamarzocco/diagnostics.py | 44 +++ .../snapshots/test_diagnostics.ambr | 301 ++++++++++++++++++ .../components/lamarzocco/test_diagnostics.py | 21 ++ 3 files changed, 366 insertions(+) create mode 100644 homeassistant/components/lamarzocco/diagnostics.py create mode 100644 tests/components/lamarzocco/snapshots/test_diagnostics.ambr create mode 100644 tests/components/lamarzocco/test_diagnostics.py diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py new file mode 100644 index 00000000000..6e75152bd60 --- /dev/null +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -0,0 +1,44 @@ +"""Diagnostics support for La Marzocco.""" + + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + +TO_REDACT = { + "serial_number", + "machine_sn", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaMarzoccoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # collect all data sources + data = {} + data["current_status"] = coordinator.lm.current_status + data["machine_info"] = coordinator.lm.machine_info + data["config"] = coordinator.lm.config + data["statistics"] = {"stats": coordinator.lm.statistics} # wrap to satisfy mypy + + # build a firmware section + data["firmware"] = { + "machine": { + "version": coordinator.lm.firmware_version, + "latest_version": coordinator.lm.latest_firmware_version, + }, + "gateway": { + "version": coordinator.lm.gateway_version, + "latest_version": coordinator.lm.latest_gateway_version, + }, + } + return async_redact_data(data, TO_REDACT) diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2462d4a125d --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -0,0 +1,301 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'boilerTargetTemperature': dict({ + 'CoffeeBoiler1': 95, + 'SteamBoiler': 123.9000015258789, + }), + 'boilers': list([ + dict({ + 'current': 123.80000305175781, + 'id': 'SteamBoiler', + 'isEnabled': True, + 'target': 123.9000015258789, + }), + dict({ + 'current': 96.5, + 'id': 'CoffeeBoiler1', + 'isEnabled': True, + 'target': 95, + }), + ]), + 'clock': '1901-07-08T10:29:00', + 'firmwareVersions': list([ + dict({ + 'fw_version': '1.40', + 'name': 'machine_firmware', + }), + dict({ + 'fw_version': 'v3.1-rc4', + 'name': 'gateway_firmware', + }), + ]), + 'groupCapabilities': list([ + dict({ + 'capabilities': dict({ + 'boilerId': 'CoffeeBoiler1', + 'groupNumber': 'Group1', + 'groupType': 'AV_Group', + 'hasFlowmeter': True, + 'hasScale': False, + 'numberOfDoses': 4, + }), + 'doseMode': dict({ + 'brewingType': 'PulsesType', + 'groupNumber': 'Group1', + }), + 'doses': list([ + dict({ + 'doseIndex': 'DoseA', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 135, + }), + dict({ + 'doseIndex': 'DoseB', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 97, + }), + dict({ + 'doseIndex': 'DoseC', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 108, + }), + dict({ + 'doseIndex': 'DoseD', + 'doseType': 'PulsesType', + 'groupNumber': 'Group1', + 'stopTarget': 121, + }), + ]), + }), + ]), + 'isBackFlushEnabled': False, + 'isPlumbedIn': True, + 'machineCapabilities': list([ + dict({ + 'coffeeBoilersNumber': 1, + 'family': 'GS3AV', + 'groupsNumber': 1, + 'hasCupWarmer': False, + 'machineModes': list([ + 'BrewingMode', + 'StandBy', + ]), + 'schedulingType': 'weeklyScheduling', + 'steamBoilersNumber': 1, + 'teaDosesNumber': 1, + }), + ]), + 'machineMode': 'BrewingMode', + 'machine_hw': '2', + 'machine_sn': '**REDACTED**', + 'preinfusionMode': dict({ + 'Group1': dict({ + 'groupNumber': 'Group1', + 'preinfusionStyle': 'PreinfusionByDoseType', + }), + }), + 'preinfusionModesAvailable': list([ + 'ByDoseType', + ]), + 'preinfusionSettings': dict({ + 'Group1': list([ + dict({ + 'doseType': 'DoseA', + 'groupNumber': 'Group1', + 'preWetHoldTime': 1, + 'preWetTime': 0.5, + }), + dict({ + 'doseType': 'DoseB', + 'groupNumber': 'Group1', + 'preWetHoldTime': 1, + 'preWetTime': 0.5, + }), + dict({ + 'doseType': 'DoseC', + 'groupNumber': 'Group1', + 'preWetHoldTime': 3.299999952316284, + 'preWetTime': 3.299999952316284, + }), + dict({ + 'doseType': 'DoseD', + 'groupNumber': 'Group1', + 'preWetHoldTime': 2, + 'preWetTime': 2, + }), + ]), + 'mode': 'TypeB', + }), + 'standByTime': 0, + 'tankStatus': True, + 'teaDoses': dict({ + 'DoseA': dict({ + 'doseIndex': 'DoseA', + 'stopTarget': 8, + }), + }), + 'version': 'v1', + 'weeklySchedulingConfig': dict({ + 'enabled': True, + 'friday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'monday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'saturday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'sunday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'thursday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'tuesday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + 'wednesday': dict({ + 'enabled': True, + 'h_off': 16, + 'h_on': 6, + 'm_off': 0, + 'm_on': 0, + }), + }), + }), + 'current_status': dict({ + 'brew_active': False, + 'coffee_boiler_on': True, + 'coffee_set_temp': 95, + 'coffee_temp': 93, + 'dose_k1': 1023, + 'dose_k2': 1023, + 'dose_k3': 1023, + 'dose_k4': 1023, + 'dose_k5': 1023, + 'drinks_k1': 13, + 'drinks_k2': 2, + 'drinks_k3': 42, + 'drinks_k4': 34, + 'enable_prebrewing': True, + 'enable_preinfusion': False, + 'fri_auto': 'Disabled', + 'fri_off_time': '00:00', + 'fri_on_time': '00:00', + 'global_auto': 'Enabled', + 'mon_auto': 'Disabled', + 'mon_off_time': '00:00', + 'mon_on_time': '00:00', + 'power': True, + 'prebrewing_toff_k1': 5, + 'prebrewing_toff_k2': 5, + 'prebrewing_toff_k3': 5, + 'prebrewing_toff_k4': 5, + 'prebrewing_toff_k5': 5, + 'prebrewing_ton_k1': 3, + 'prebrewing_ton_k2': 3, + 'prebrewing_ton_k3': 3, + 'prebrewing_ton_k4': 3, + 'prebrewing_ton_k5': 3, + 'preinfusion_k1': 4, + 'preinfusion_k2': 4, + 'preinfusion_k3': 4, + 'preinfusion_k4': 4, + 'preinfusion_k5': 4, + 'sat_auto': 'Disabled', + 'sat_off_time': '00:00', + 'sat_on_time': '00:00', + 'steam_boiler_enable': True, + 'steam_boiler_on': True, + 'steam_level_set': 3, + 'steam_set_temp': 128, + 'steam_temp': 113, + 'sun_auto': 'Disabled', + 'sun_off_time': '00:00', + 'sun_on_time': '00:00', + 'thu_auto': 'Disabled', + 'thu_off_time': '00:00', + 'thu_on_time': '00:00', + 'total_flushing': 69, + 'tue_auto': 'Disabled', + 'tue_off_time': '00:00', + 'tue_on_time': '00:00', + 'water_reservoir_contact': True, + 'wed_auto': 'Disabled', + 'wed_off_time': '00:00', + 'wed_on_time': '00:00', + }), + 'firmware': dict({ + 'gateway': dict({ + 'latest_version': 'v3.1-rc4', + 'version': 'v2.2-rc0', + }), + 'machine': dict({ + 'latest_version': '1.2', + 'version': '1.1', + }), + }), + 'machine_info': dict({ + 'machine_name': 'GS01234', + 'serial_number': '**REDACTED**', + }), + 'statistics': dict({ + 'stats': list([ + dict({ + 'coffeeType': 0, + 'count': 1047, + }), + dict({ + 'coffeeType': 1, + 'count': 560, + }), + dict({ + 'coffeeType': 2, + 'count': 468, + }), + dict({ + 'coffeeType': 3, + 'count': 312, + }), + dict({ + 'coffeeType': 4, + 'count': 2252, + }), + dict({ + 'coffeeType': -1, + 'count': 1740, + }), + ]), + }), + }) +# --- diff --git a/tests/components/lamarzocco/test_diagnostics.py b/tests/components/lamarzocco/test_diagnostics.py new file mode 100644 index 00000000000..a42b15dec3c --- /dev/null +++ b/tests/components/lamarzocco/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Tests for the diagnostics data provided by the La Marzocco integration.""" +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 65abbe5369269db65992e4395a2119bc2ad468bf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 18 Jan 2024 13:08:24 +0100 Subject: [PATCH 0743/1544] Bump lmcloud to 0.4.35 (#108288) bump lmcloud --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 422f971186e..8dd8e1294b0 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["lmcloud"], - "requirements": ["lmcloud==0.4.34"] + "requirements": ["lmcloud==0.4.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41e24532fc7..71adc51d1b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1210,7 +1210,7 @@ linear-garage-door==0.2.7 linode-api==4.1.9b1 # homeassistant.components.lamarzocco -lmcloud==0.4.34 +lmcloud==0.4.35 # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76c454e9527..130537b59ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -955,7 +955,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.7 # homeassistant.components.lamarzocco -lmcloud==0.4.34 +lmcloud==0.4.35 # homeassistant.components.logi_circle logi-circle==0.2.3 From 7d5a672ed1f7fc6719cc6debf7d27683e850bf00 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 18 Jan 2024 14:37:43 +0100 Subject: [PATCH 0744/1544] Add tests to flexit_bacnet integration (#108291) * Add fixture for update method * Mock flexit_bacnet * Adds test for climate * Adds snapshot testing * Adds test for init, refactor test for config flow --- tests/components/flexit_bacnet/__init__.py | 16 +++ tests/components/flexit_bacnet/conftest.py | 46 +++++++-- .../flexit_bacnet/snapshots/test_climate.ambr | 73 ++++++++++++++ .../components/flexit_bacnet/test_climate.py | 27 +++++ .../flexit_bacnet/test_config_flow.py | 98 ++++++++----------- tests/components/flexit_bacnet/test_init.py | 24 +++++ 6 files changed, 219 insertions(+), 65 deletions(-) create mode 100644 tests/components/flexit_bacnet/snapshots/test_climate.ambr create mode 100644 tests/components/flexit_bacnet/test_climate.py create mode 100644 tests/components/flexit_bacnet/test_init.py diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py index 4cae6e4f4bf..e934f3c7e5f 100644 --- a/tests/components/flexit_bacnet/__init__.py +++ b/tests/components/flexit_bacnet/__init__.py @@ -1 +1,17 @@ """Tests for the Flexit Nordic (BACnet) integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Flexit Nordic (BACnet) integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.flexit_bacnet.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index b136b134e01..faa0bc6b7c0 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -1,13 +1,18 @@ """Configuration for Flexit Nordic (BACnet) tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from flexit_bacnet import FlexitBACnet import pytest from homeassistant import config_entries from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + @pytest.fixture async def flow_id(hass: HomeAssistant) -> str: @@ -22,23 +27,44 @@ async def flow_id(hass: HomeAssistant) -> str: return result["flow_id"] -@pytest.fixture(autouse=True) -def mock_serial_number_and_device_name(): - """Mock serial number of the device.""" +@pytest.fixture +def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: + """Mock data from the device.""" + flexit_bacnet = AsyncMock(spec=FlexitBACnet) with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", - "0000-0001", + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet", + return_value=flexit_bacnet, ), patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", - "Device Name", + "homeassistant.components.flexit_bacnet.FlexitBACnet", + return_value=flexit_bacnet, ): - yield + flexit_bacnet.serial_number = "0000-0001" + flexit_bacnet.device_name = "Device Name" + flexit_bacnet.room_temperature = 19.0 + flexit_bacnet.air_temp_setpoint_away = 18.0 + flexit_bacnet.air_temp_setpoint_home = 22.0 + flexit_bacnet.ventilation_mode = 4 + + yield flexit_bacnet @pytest.fixture -def mock_setup_entry(): +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True ) as setup_entry_mock: yield setup_entry_mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr new file mode 100644 index 00000000000..cc9c38e370c --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Device Name', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_mode': 'boost', + 'preset_modes': list([ + 'away', + 'home', + 'boost', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.device_name', + 'last_changed': , + 'last_updated': , + 'state': 'fan_only', + }) +# --- +# name: test_climate_entity.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_modes': list([ + 'away', + 'home', + 'boost', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.device_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0000-0001', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py new file mode 100644 index 00000000000..077aee019e7 --- /dev/null +++ b/tests/components/flexit_bacnet/test_climate.py @@ -0,0 +1,27 @@ +"""Tests for the Flexit Nordic (BACnet) climate entity.""" +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + +ENTITY_CLIMATE = "climate.device_name" + + +async def test_climate_entity( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert hass.states.get(ENTITY_CLIMATE) == snapshot + assert entity_registry.async_get(ENTITY_CLIMATE) == snapshot diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py index ed513587af6..860d25e4b75 100644 --- a/tests/components/flexit_bacnet/test_config_flow.py +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -1,31 +1,26 @@ """Test the Flexit Nordic (BACnet) config flow.""" import asyncio.exceptions -from unittest.mock import patch from flexit_bacnet import DecodingError import pytest -from homeassistant.components.flexit_bacnet.const import DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - -async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: +async def test_form( + hass: HomeAssistant, flow_id: str, mock_setup_entry, mock_flexit_bacnet +) -> None: """Test we get the form and the happy path works.""" - with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" - ): - result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_IP_ADDRESS: "1.1.1.1", - CONF_DEVICE_ID: 2, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Device Name" @@ -35,6 +30,7 @@ async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None CONF_DEVICE_ID: 2, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_flexit_bacnet.mock_calls) == 1 @pytest.mark.parametrize( @@ -50,39 +46,39 @@ async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None ], ) async def test_flow_fails( - hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry + hass: HomeAssistant, + flow_id: str, + error: Exception, + message: str, + mock_setup_entry, + mock_flexit_bacnet, ) -> None: """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. """ - with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", - side_effect=error, - ): - result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_IP_ADDRESS: "1.1.1.1", - CONF_DEVICE_ID: 2, - }, - ) + mock_flexit_bacnet.update.side_effect = error + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": message} assert len(mock_setup_entry.mock_calls) == 0 # ensure that user can recover from this error - with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_IP_ADDRESS: "1.1.1.1", - CONF_DEVICE_ID: 2, - }, - ) + mock_flexit_bacnet.update.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Device Name" @@ -94,27 +90,19 @@ async def test_flow_fails( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: +async def test_form_device_already_exist( + hass: HomeAssistant, flow_id: str, mock_flexit_bacnet, mock_config_entry +) -> None: """Test that we cannot add already added device.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + flow_id, + { CONF_IP_ADDRESS: "1.1.1.1", CONF_DEVICE_ID: 2, }, - unique_id="0000-0001", ) - entry.add_to_hass(hass) - with patch( - "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" - ): - result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_IP_ADDRESS: "1.1.1.1", - CONF_DEVICE_ID: 2, - }, - ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/flexit_bacnet/test_init.py b/tests/components/flexit_bacnet/test_init.py new file mode 100644 index 00000000000..35f0e04be67 --- /dev/null +++ b/tests/components/flexit_bacnet/test_init.py @@ -0,0 +1,24 @@ +"""Tests for the Flexit Nordic (BACnet) __init__.""" +from homeassistant.components.flexit_bacnet.const import DOMAIN as FLEXIT_BACNET_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_flexit_bacnet +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(FLEXIT_BACNET_DOMAIN)) == 1 + assert mock_config_entry.state == 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(FLEXIT_BACNET_DOMAIN) From c4f033e61c2be7faf4e17113c0c6dbe684d7e010 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 18 Jan 2024 14:55:44 +0100 Subject: [PATCH 0745/1544] Add test for failed initialization in Flexit BACnet (#108294) --- .coveragerc | 2 -- .../components/flexit_bacnet/__init__.py | 2 +- tests/components/flexit_bacnet/test_init.py | 17 ++++++++++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.coveragerc b/.coveragerc index 07e31e07961..b7d4b0bc68b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -406,8 +406,6 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py - homeassistant/components/flexit_bacnet/__init__.py - homeassistant/components/flexit_bacnet/const.py homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index c9a0b332d93..53ae1bbe775 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await device.update() except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise ConfigEntryNotReady( - f"Timeout while connecting to {entry.data['address']}" + f"Timeout while connecting to {entry.data[CONF_IP_ADDRESS]}" ) from exc hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device diff --git a/tests/components/flexit_bacnet/test_init.py b/tests/components/flexit_bacnet/test_init.py index 35f0e04be67..71f79f54302 100644 --- a/tests/components/flexit_bacnet/test_init.py +++ b/tests/components/flexit_bacnet/test_init.py @@ -1,5 +1,7 @@ """Tests for the Flexit Nordic (BACnet) __init__.""" -from homeassistant.components.flexit_bacnet.const import DOMAIN as FLEXIT_BACNET_DOMAIN +from flexit_bacnet import DecodingError + +from homeassistant.components.flexit_bacnet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -14,11 +16,20 @@ async def test_loading_and_unloading_config_entry( """Test loading and unloading a config entry.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) - assert len(hass.config_entries.async_entries(FLEXIT_BACNET_DOMAIN)) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert mock_config_entry.state == 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(FLEXIT_BACNET_DOMAIN) + + +async def test_failed_initialization( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_flexit_bacnet +) -> None: + """Test failed initialization.""" + mock_flexit_bacnet.update.side_effect = DecodingError + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY From bfe21b33f063fa97edec6d06b632d58bcf1b326c Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 18 Jan 2024 15:45:56 +0100 Subject: [PATCH 0746/1544] Add coordinator to Flexit bacnet (#108295) * Adds coordinator and base entity class * Patch the coordinator * Adds device property to base class And refactors accordingly * Use const instead of string * Moves _attr_has_entity_name to base entity * Argument as positional * Use device_id from init --- .../components/flexit_bacnet/__init__.py | 24 +++----- .../components/flexit_bacnet/climate.py | 61 +++++++++---------- .../components/flexit_bacnet/coordinator.py | 49 +++++++++++++++ .../components/flexit_bacnet/entity.py | 34 +++++++++++ tests/components/flexit_bacnet/conftest.py | 2 +- 5 files changed, 119 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/flexit_bacnet/coordinator.py create mode 100644 homeassistant/components/flexit_bacnet/entity.py diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 53ae1bbe775..39e06156a59 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -1,17 +1,12 @@ """The Flexit Nordic (BACnet) integration.""" from __future__ import annotations -import asyncio.exceptions - -from flexit_bacnet import FlexitBACnet -from flexit_bacnet.bacnet import DecodingError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import FlexitCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] @@ -19,24 +14,19 @@ PLATFORMS: list[Platform] = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flexit Nordic (BACnet) from a config entry.""" - device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + device_id = entry.data[CONF_DEVICE_ID] - try: - await device.update() - except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise ConfigEntryNotReady( - f"Timeout while connecting to {entry.data[CONF_IP_ADDRESS]}" - ) from exc - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + coordinator = FlexitCoordinator(hass, device_id) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" + """Unload the Flexit Nordic (BACnet) config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 79846bee019..7740bed73e1 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -6,7 +6,6 @@ from flexit_bacnet import ( VENTILATION_MODE_AWAY, VENTILATION_MODE_HOME, VENTILATION_MODE_STOP, - FlexitBACnet, ) from flexit_bacnet.bacnet import DecodingError @@ -22,7 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -32,6 +30,8 @@ from .const import ( PRESET_TO_VENTILATION_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP, ) +from .coordinator import FlexitCoordinator +from .entity import FlexitEntity async def async_setup_entry( @@ -40,18 +40,16 @@ async def async_setup_entry( async_add_devices: AddEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" - device = hass.data[DOMAIN][config_entry.entry_id] + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_devices([FlexitClimateEntity(device)]) + async_add_devices([FlexitClimateEntity(coordinator)]) -class FlexitClimateEntity(ClimateEntity): +class FlexitClimateEntity(FlexitEntity, ClimateEntity): """Flexit air handling unit.""" _attr_name = None - _attr_has_entity_name = True - _attr_hvac_modes = [ HVACMode.OFF, HVACMode.FAN_ONLY, @@ -72,36 +70,27 @@ class FlexitClimateEntity(ClimateEntity): _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP - def __init__(self, device: FlexitBACnet) -> None: - """Initialize the unit.""" - self._device = device - self._attr_unique_id = device.serial_number - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, device.serial_number), - }, - name=device.device_name, - manufacturer="Flexit", - model="Nordic", - serial_number=device.serial_number, - ) + def __init__(self, coordinator: FlexitCoordinator) -> None: + """Initialize the Flexit unit.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.device.serial_number async def async_update(self) -> None: """Refresh unit state.""" - await self._device.update() + await self.device.update() @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._device.room_temperature + return self.device.room_temperature @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - if self._device.ventilation_mode == VENTILATION_MODE_AWAY: - return self._device.air_temp_setpoint_away + if self.device.ventilation_mode == VENTILATION_MODE_AWAY: + return self.device.air_temp_setpoint_away - return self._device.air_temp_setpoint_home + return self.device.air_temp_setpoint_home async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -109,12 +98,14 @@ class FlexitClimateEntity(ClimateEntity): return try: - if self._device.ventilation_mode == VENTILATION_MODE_AWAY: - await self._device.set_air_temp_setpoint_away(temperature) + if self.device.ventilation_mode == VENTILATION_MODE_AWAY: + await self.device.set_air_temp_setpoint_away(temperature) else: - await self._device.set_air_temp_setpoint_home(temperature) + await self.device.set_air_temp_setpoint_home(temperature) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() @property def preset_mode(self) -> str: @@ -122,21 +113,23 @@ class FlexitClimateEntity(ClimateEntity): Requires ClimateEntityFeature.PRESET_MODE. """ - return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + return VENTILATION_TO_PRESET_MODE_MAP[self.device.ventilation_mode] async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] try: - await self._device.set_ventilation_mode(ventilation_mode) + await self.device.set_ventilation_mode(ventilation_mode) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._device.ventilation_mode == VENTILATION_MODE_STOP: + if self.device.ventilation_mode == VENTILATION_MODE_STOP: return HVACMode.OFF return HVACMode.FAN_ONLY @@ -145,8 +138,10 @@ class FlexitClimateEntity(ClimateEntity): """Set new target hvac mode.""" try: if hvac_mode == HVACMode.OFF: - await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + await self.device.set_ventilation_mode(VENTILATION_MODE_STOP) else: - await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + await self.device.set_ventilation_mode(VENTILATION_MODE_HOME) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py new file mode 100644 index 00000000000..556264e1268 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -0,0 +1,49 @@ +"""DataUpdateCoordinator for Flexit Nordic (BACnet) integration..""" +import asyncio.exceptions +from datetime import timedelta +import logging + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): + """Class to manage fetching data from a Flexit Nordic (BACnet) device.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device_id: str) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_id}", + update_interval=timedelta(seconds=60), + ) + + self.device = FlexitBACnet( + self.config_entry.data[CONF_IP_ADDRESS], + self.config_entry.data[CONF_DEVICE_ID], + ) + + async def _async_update_data(self) -> FlexitBACnet: + """Fetch data from the device.""" + + try: + await self.device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}" + ) from exc + + return self.device diff --git a/homeassistant/components/flexit_bacnet/entity.py b/homeassistant/components/flexit_bacnet/entity.py new file mode 100644 index 00000000000..3e00fae54af --- /dev/null +++ b/homeassistant/components/flexit_bacnet/entity.py @@ -0,0 +1,34 @@ +"""Base entity for the Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +from flexit_bacnet import FlexitBACnet + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FlexitCoordinator + + +class FlexitEntity(CoordinatorEntity[FlexitCoordinator]): + """Defines a Flexit entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FlexitCoordinator) -> None: + """Initialize a Flexit Nordic (BACnet) entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, coordinator.device.serial_number), + }, + name=coordinator.device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=coordinator.device.serial_number, + ) + + @property + def device(self) -> FlexitBACnet: + """Return the device.""" + return self.coordinator.data diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index faa0bc6b7c0..0c6153e81c0 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -35,7 +35,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet", return_value=flexit_bacnet, ), patch( - "homeassistant.components.flexit_bacnet.FlexitBACnet", + "homeassistant.components.flexit_bacnet.coordinator.FlexitBACnet", return_value=flexit_bacnet, ): flexit_bacnet.serial_number = "0000-0001" From cdb798bec0913a8a5778baa1ec31f5d2b2283813 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 18 Jan 2024 16:32:29 +0100 Subject: [PATCH 0747/1544] Don't always set first thread dataset as preferred (#108278) * Don't always set first thread dataset as preferred * Update tests * Make clarifying comments clearer * Call asyncio.wait with return_when=ALL_COMPLETED * Update otbr test * Update homeassistant/components/thread/dataset_store.py Co-authored-by: Stefan Agner * Update homeassistant/components/thread/dataset_store.py --------- Co-authored-by: Stefan Agner --- .../components/thread/dataset_store.py | 77 ++++- tests/components/otbr/__init__.py | 29 ++ tests/components/otbr/test_init.py | 41 ++- tests/components/thread/__init__.py | 1 + tests/components/thread/test_dataset_store.py | 270 +++++++++++++++++- tests/components/thread/test_websocket_api.py | 14 + 6 files changed, 426 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 9c5d79cc0e0..9dc4ad31217 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from asyncio import Event, Task, wait import dataclasses from datetime import datetime import logging @@ -16,6 +17,9 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util, ulid as ulid_util +from . import discovery + +BORDER_AGENT_DISCOVERY_TIMEOUT = 30 DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 @@ -177,6 +181,7 @@ class DatasetStore: self.hass = hass self.datasets: dict[str, DatasetEntry] = {} self._preferred_dataset: str | None = None + self._set_preferred_dataset_task: Task | None = None self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, STORAGE_VERSION_MAJOR, @@ -267,11 +272,21 @@ class DatasetStore: preferred_border_agent_id=preferred_border_agent_id, source=source, tlv=tlv ) self.datasets[entry.id] = entry - # Set to preferred if there is no preferred dataset - if self._preferred_dataset is None: - self._preferred_dataset = entry.id self.async_schedule_save() + # Set the new network as preferred if there is no preferred dataset and there is + # no other router present. We only attempt this once. + if ( + self._preferred_dataset is None + and preferred_border_agent_id + and not self._set_preferred_dataset_task + ): + self._set_preferred_dataset_task = self.hass.async_create_task( + self._set_preferred_dataset_if_only_network( + entry.id, preferred_border_agent_id + ) + ) + @callback def async_delete(self, dataset_id: str) -> None: """Delete dataset.""" @@ -310,6 +325,62 @@ class DatasetStore: self._preferred_dataset = dataset_id self.async_schedule_save() + async def _set_preferred_dataset_if_only_network( + self, dataset_id: str, border_agent_id: str + ) -> None: + """Set the preferred dataset, unless there are other routers present.""" + _LOGGER.debug( + "_set_preferred_dataset_if_only_network called for router %s", + border_agent_id, + ) + + own_router_evt = Event() + other_router_evt = Event() + + @callback + def router_discovered( + key: str, data: discovery.ThreadRouterDiscoveryData + ) -> None: + """Handle router discovered.""" + _LOGGER.debug("discovered router with id %s", data.border_agent_id) + if data.border_agent_id == border_agent_id: + own_router_evt.set() + return + + other_router_evt.set() + + # Start Thread router discovery + thread_discovery = discovery.ThreadRouterDiscovery( + self.hass, router_discovered, lambda key: None + ) + await thread_discovery.async_start() + + found_own_router = self.hass.async_create_task(own_router_evt.wait()) + found_other_router = self.hass.async_create_task(other_router_evt.wait()) + pending = {found_own_router, found_other_router} + (done, pending) = await wait(pending, timeout=BORDER_AGENT_DISCOVERY_TIMEOUT) + if found_other_router in done: + # We found another router on the network, don't set the dataset + # as preferred + _LOGGER.debug("Other router found, do not set dataset as default") + + # Note that asyncio.wait does not raise TimeoutError, it instead returns + # the jobs which did not finish in the pending-set. + elif found_own_router in pending: + # Either the router is not there, or mDNS is not working. In any case, + # don't set the router as preferred. + _LOGGER.debug("Own router not found, do not set dataset as default") + + else: + # We've discovered the router connected to the dataset, but we did not + # find any other router on the network - mark the dataset as preferred. + _LOGGER.debug("No other router found, set dataset as default") + self.preferred_dataset = dataset_id + + for task in pending: + task.cancel() + await thread_discovery.async_stop() + async def async_load(self) -> None: """Load the datasets.""" data = await self._store.async_load() diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index a30275d3569..e72849aa5a1 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -28,3 +28,32 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex( ) TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") + + +ROUTER_DISCOVERY_HASS = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\x00\x00\x01\xb1", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", + }, + "interface_index": None, +} diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 1b5c1e8b60a..496427c083a 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,13 +1,16 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest import python_otbr_api +from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import otbr, thread +from homeassistant.components.thread import discovery from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -21,6 +24,7 @@ from . import ( DATASET_CH16, DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, + ROUTER_DISCOVERY_HASS, TEST_BORDER_AGENT_ID, ) @@ -34,8 +38,19 @@ DATASET_NO_CHANNEL = bytes.fromhex( ) -async def test_import_dataset(hass: HomeAssistant) -> None: +async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test the active dataset is imported at setup.""" + add_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock() + mock_async_zeroconf.async_get_service_info = AsyncMock() + issue_registry = ir.async_get(hass) assert await thread.async_get_preferred_dataset(hass) is None @@ -46,13 +61,37 @@ async def test_import_dataset(hass: HomeAssistant) -> None: title="My OTBR", ) config_entry.add_to_hass(hass) + with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, ): assert await hass.config_entries.async_setup(config_entry.entry_id) + # Wait for Thread router discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Wait for discovery of other routers to time out + await hass.async_block_till_done() + dataset_store = await thread.dataset_store.async_get_store(hass) assert ( list(dataset_store.datasets.values())[0].preferred_border_agent_id diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index 7ca6cbaf2ed..155e46a8ee0 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -18,6 +18,7 @@ DATASET_3 = ( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) +TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") ROUTER_DISCOVERY_GOOGLE_1 = { "type_": "_meshcop._udp.local.", diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index d8822a7d536..246fb88f3ef 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -1,14 +1,24 @@ """Test the thread dataset store.""" +import asyncio from typing import Any +from unittest.mock import ANY, AsyncMock, patch import pytest from python_otbr_api.tlv_parser import TLVError +from zeroconf.asyncio import AsyncServiceInfo -from homeassistant.components.thread import dataset_store +from homeassistant.components.thread import dataset_store, discovery from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import DATASET_1, DATASET_2, DATASET_3 +from . import ( + DATASET_1, + DATASET_2, + DATASET_3, + ROUTER_DISCOVERY_GOOGLE_1, + ROUTER_DISCOVERY_HASS, + TEST_BORDER_AGENT_ID, +) from tests.common import flush_store @@ -107,6 +117,7 @@ async def test_delete_preferred_dataset(hass: HomeAssistant) -> None: store = await dataset_store.async_get_store(hass) dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id with pytest.raises(HomeAssistantError, match="attempt to remove preferred dataset"): store.async_delete(dataset_id) @@ -130,6 +141,10 @@ async def test_get_preferred_dataset(hass: HomeAssistant) -> None: await dataset_store.async_add_dataset(hass, "source", DATASET_1) + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id + assert (await dataset_store.async_get_preferred_dataset(hass)) == DATASET_1 @@ -256,6 +271,8 @@ async def test_load_datasets(hass: HomeAssistant) -> None: for dataset in datasets: store1.async_add(dataset["source"], dataset["tlv"], None) assert len(store1.datasets) == 3 + dataset_id = list(store1.datasets.values())[0].id + store1.preferred_dataset = dataset_id for dataset in store1.datasets.values(): if dataset.source == "Google": @@ -575,3 +592,252 @@ async def test_set_preferred_border_agent_id(hass: HomeAssistant) -> None: hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" ) assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + + +async def test_automatically_set_preferred_dataset( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset.""" + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Wait for discovery of other routers to time out and discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) == DATASET_1 + + +async def test_automatically_set_preferred_dataset_own_and_other_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case both our own and another router are found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover a service matching our router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_HASS + ) + listener.add_service( + None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"] + ) + + # Discover another router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_GOOGLE_1 + ) + listener.add_service( + None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"] + ) + + # Wait for discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None + + +async def test_automatically_set_preferred_dataset_other_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case another router is found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Discover another router + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( + **ROUTER_DISCOVERY_GOOGLE_1 + ) + listener.add_service( + None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"] + ) + + # Wait for discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None + + +async def test_automatically_set_preferred_dataset_no_router( + hass: HomeAssistant, mock_async_zeroconf: None +) -> None: + """Test automatically setting the first dataset as the preferred dataset. + + In this test case no routers are found. + """ + add_service_listener_called = asyncio.Event() + remove_service_listener_called = asyncio.Event() + + async def mock_add_service_listener(type_: str, listener: Any): + add_service_listener_called.set() + + async def mock_remove_service_listener(listener: Any): + remove_service_listener_called.set() + + mock_async_zeroconf.async_add_service_listener = AsyncMock( + side_effect=mock_add_service_listener + ) + mock_async_zeroconf.async_remove_service_listener = AsyncMock( + side_effect=mock_remove_service_listener + ) + mock_async_zeroconf.async_get_service_info = AsyncMock() + + with patch( + "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", + 0.1, + ): + await dataset_store.async_add_dataset( + hass, + "source", + DATASET_1, + preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + ) + + # Wait for discovery to start + await add_service_listener_called.wait() + mock_async_zeroconf.async_add_service_listener.assert_called_once_with( + "_meshcop._udp.local.", ANY + ) + + # Wait for discovery of other routers to time out and discovery to stop + await remove_service_listener_called.wait() + + store = await dataset_store.async_get_store(hass) + assert ( + list(store.datasets.values())[0].preferred_border_agent_id + == TEST_BORDER_AGENT_ID.hex() + ) + assert await dataset_store.async_get_preferred_dataset(hass) is None diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 75e1b313132..3b05586a1db 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -86,6 +86,17 @@ async def test_delete_dataset( assert msg["success"] datasets = msg["result"]["datasets"] + # Set the first dataset as preferred + await client.send_json_auto_id( + { + "type": "thread/set_preferred_dataset", + "dataset_id": datasets[0]["dataset_id"], + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + # Try deleting the preferred dataset await client.send_json_auto_id( {"type": "thread/delete_dataset", "dataset_id": datasets[0]["dataset_id"]} @@ -139,6 +150,9 @@ async def test_list_get_dataset( await dataset_store.async_add_dataset(hass, dataset["source"], dataset["tlv"]) store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id + for dataset in store.datasets.values(): if dataset.source == "Google": dataset_1 = dataset From 32b0bf6b4e02968f5040e5b3966376d67a30d98c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 19 Jan 2024 02:40:36 +1000 Subject: [PATCH 0748/1544] Improve coordinator logic in Tessie to allow sleep (#107988) * Poll status before state * Tests --- .../components/tessie/binary_sensor.py | 4 +-- homeassistant/components/tessie/const.py | 10 +++++- .../components/tessie/coordinator.py | 22 ++++++++----- tests/components/tessie/common.py | 6 ++-- tests/components/tessie/conftest.py | 16 ++++++++- tests/components/tessie/test_coordinator.py | 33 ++++++++++--------- 6 files changed, 60 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 5edbb108568..e4c0d5d5c66 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, TessieStatus +from .const import DOMAIN, TessieState from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -30,7 +30,7 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( TessieBinarySensorEntityDescription( key="state", device_class=BinarySensorDeviceClass.CONNECTIVITY, - is_on=lambda x: x == TessieStatus.ONLINE, + is_on=lambda x: x == TessieState.ONLINE, ), TessieBinarySensorEntityDescription( key="charge_state_battery_heater_on", diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 28981b87e6d..591d4652274 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -13,13 +13,21 @@ MODELS = { } -class TessieStatus(StrEnum): +class TessieState(StrEnum): """Tessie status.""" ASLEEP = "asleep" ONLINE = "online" +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + AWAKE = "awake" + WAITING = "waiting_for_sleep" + + class TessieSeatHeaterOptions(StrEnum): """Tessie seat heater options.""" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 75cac088bde..c2106af665f 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientResponseError -from tessie_api import get_state +from tessie_api import get_state, get_status from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -45,11 +45,21 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: + status = await get_status( + session=self.session, + api_key=self.api_key, + vin=self.vin, + ) + if status["status"] == TessieStatus.ASLEEP: + # Vehicle is asleep, no need to poll for data + self.data["state"] = status["status"] + return self.data + vehicle = await get_state( session=self.session, api_key=self.api_key, vin=self.vin, - use_cache=False, + use_cache=True, ) except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: @@ -57,13 +67,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from e raise e - if vehicle["state"] == TessieStatus.ONLINE: - # Vehicle is online, all data is fresh - return self._flatten(vehicle) - - # Vehicle is asleep, only update state - self.data["state"] = vehicle["state"] - return self.data + return self._flatten(vehicle) def _flatten( self, data: dict[str, Any], parent: str | None = None diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index ae80526e5d9..ccff7f62b1b 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -6,7 +6,7 @@ from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo -from homeassistant.components.tessie.const import DOMAIN +from homeassistant.components.tessie.const import DOMAIN, TessieStatus from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -14,7 +14,9 @@ from tests.common import MockConfigEntry, load_json_object_fixture TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) -TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) +TEST_VEHICLE_STATUS_AWAKE = {"status": TessieStatus.AWAKE} +TEST_VEHICLE_STATUS_ASLEEP = {"status": TessieStatus.ASLEEP} + TEST_RESPONSE = {"result": True} TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"} diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index c7a344d54c5..02b3d56691e 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -5,7 +5,11 @@ from unittest.mock import patch import pytest -from .common import TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE +from .common import ( + TEST_STATE_OF_ALL_VEHICLES, + TEST_VEHICLE_STATE_ONLINE, + TEST_VEHICLE_STATUS_AWAKE, +) @pytest.fixture @@ -18,6 +22,16 @@ def mock_get_state(): yield mock_get_state +@pytest.fixture +def mock_get_status(): + """Mock get_status function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_status", + return_value=TEST_VEHICLE_STATUS_AWAKE, + ) as mock_get_status: + yield mock_get_status + + @pytest.fixture def mock_get_state_of_all_vehicles(): """Mock get_state_of_all_vehicles function.""" diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 311222466fd..65f91c6f33e 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -10,8 +10,7 @@ from .common import ( ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, - TEST_VEHICLE_STATE_ASLEEP, - TEST_VEHICLE_STATE_ONLINE, + TEST_VEHICLE_STATUS_ASLEEP, setup_platform, ) @@ -20,59 +19,61 @@ from tests.common import async_fire_time_changed WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) -async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: +async def test_coordinator_online( + hass: HomeAssistant, mock_get_state, mock_get_status +) -> None: """Tests that the coordinator handles online vehicles.""" - mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE await setup_platform(hass) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() + mock_get_status.assert_called_once() mock_get_state.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_ON -async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles asleep vehicles.""" - mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP await setup_platform(hass) + mock_get_status.return_value = TEST_VEHICLE_STATUS_ASLEEP async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() - mock_get_state.assert_called_once() + mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_OFF -async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles client errors.""" - mock_get_state.side_effect = ERROR_UNKNOWN + mock_get_status.side_effect = ERROR_UNKNOWN await setup_platform(hass) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() - mock_get_state.assert_called_once() + mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE -async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: +async def test_coordinator_auth(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles timeout errors.""" - mock_get_state.side_effect = ERROR_AUTH + mock_get_status.side_effect = ERROR_AUTH await setup_platform(hass) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() - mock_get_state.assert_called_once() + mock_get_status.assert_called_once() -async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: +async def test_coordinator_connection(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles connection errors.""" - mock_get_state.side_effect = ERROR_CONNECTION + mock_get_status.side_effect = ERROR_CONNECTION await setup_platform(hass) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() - mock_get_state.assert_called_once() + mock_get_status.assert_called_once() assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE From c399cab42756fdca4522227fc6e5da8976d6f1fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Jan 2024 08:41:32 -1000 Subject: [PATCH 0749/1544] Small speed up to checking core state (#107845) --- homeassistant/core.py | 23 ++++++++++++------- tests/common.py | 2 +- tests/components/alexa/test_state_report.py | 9 +++++--- tests/components/august/test_sensor.py | 2 +- tests/components/automation/test_init.py | 10 ++++---- .../test_active_update_coordinator.py | 2 +- .../bluetooth/test_active_update_processor.py | 2 +- .../test_passive_update_processor.py | 2 +- tests/components/bond/test_entity.py | 2 +- tests/components/cert_expiry/test_init.py | 2 +- tests/components/cloud/test_alexa_config.py | 10 ++++---- tests/components/cloud/test_google_config.py | 14 +++++------ tests/components/counter/test_init.py | 6 ++--- .../device_sun_light_trigger/test_init.py | 2 +- tests/components/fastdotcom/test_init.py | 7 +++--- .../generic_hygrostat/test_humidifier.py | 8 +++---- .../generic_thermostat/test_climate.py | 8 +++---- tests/components/group/test_fan.py | 2 +- tests/components/group/test_init.py | 14 +++++------ tests/components/hassio/conftest.py | 2 +- .../here_travel_time/test_sensor.py | 2 +- .../triggers/test_homeassistant.py | 4 ++-- tests/components/homekit/test_type_covers.py | 4 ++-- tests/components/homekit/test_type_fans.py | 2 +- tests/components/homekit/test_type_lights.py | 2 +- .../homekit/test_type_media_players.py | 2 +- tests/components/homekit/test_type_sensors.py | 2 +- .../homekit/test_type_thermostats.py | 4 ++-- tests/components/input_boolean/test_init.py | 4 ++-- tests/components/input_button/test_init.py | 2 +- tests/components/input_datetime/test_init.py | 2 +- tests/components/input_number/test_init.py | 8 +++---- tests/components/input_text/test_init.py | 6 ++--- .../manual/test_alarm_control_panel.py | 10 ++++---- tests/components/mqtt/test_init.py | 2 +- tests/components/mqtt/test_mixins.py | 2 +- tests/components/mqtt/test_util.py | 10 ++++---- .../components/mqtt_statestream/test_init.py | 2 +- tests/components/netatmo/test_init.py | 2 +- .../nmap_tracker/test_config_flow.py | 2 +- tests/components/person/test_init.py | 8 +++---- tests/components/recorder/test_init.py | 8 +++---- tests/components/rflink/test_binary_sensor.py | 2 +- tests/components/rflink/test_cover.py | 2 +- tests/components/rflink/test_light.py | 2 +- tests/components/rflink/test_switch.py | 2 +- tests/components/script/test_init.py | 4 ++-- .../sensor/test_recorder_missing_stats.py | 4 ++-- .../components/template/test_binary_sensor.py | 2 +- tests/components/template/test_sensor.py | 4 ++-- tests/components/template/test_switch.py | 2 +- tests/components/timer/test_init.py | 10 ++++---- tests/components/trace/test_websocket_api.py | 6 ++--- tests/components/utility_meter/test_sensor.py | 6 ++--- tests/components/whirlpool/test_sensor.py | 8 +++---- tests/components/zha/test_cover.py | 4 ++-- tests/components/zha/test_init.py | 2 +- tests/components/zwave_js/test_update.py | 4 ++-- tests/helpers/test_device_registry.py | 2 +- tests/helpers/test_discovery_flow.py | 6 ++--- tests/helpers/test_entity_platform.py | 2 +- tests/helpers/test_entity_registry.py | 4 ++-- tests/helpers/test_restore_state.py | 4 ++-- tests/helpers/test_script.py | 4 ++-- tests/helpers/test_start.py | 20 ++++++++-------- tests/helpers/test_storage.py | 10 ++++---- tests/helpers/test_update_coordinator.py | 2 +- tests/test_config_entries.py | 4 ++-- 68 files changed, 176 insertions(+), 165 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 9c9ca335602..bef89118de9 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -393,16 +393,23 @@ class HomeAssistant: self._stop_future: concurrent.futures.Future[None] | None = None self._shutdown_jobs: list[HassJobWithArgs] = [] - @property + @cached_property def is_running(self) -> bool: """Return if Home Assistant is running.""" return self.state in (CoreState.starting, CoreState.running) - @property + @cached_property def is_stopping(self) -> bool: """Return if Home Assistant is stopping.""" return self.state in (CoreState.stopping, CoreState.final_write) + def set_state(self, state: CoreState) -> None: + """Set the current state.""" + self.state = state + for prop in ("is_running", "is_stopping"): + with suppress(AttributeError): + delattr(self, prop) + def start(self) -> int: """Start Home Assistant. @@ -451,7 +458,7 @@ class HomeAssistant: _LOGGER.info("Starting Home Assistant") setattr(self.loop, "_thread_ident", threading.get_ident()) - self.state = CoreState.starting + self.set_state(CoreState.starting) self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -483,7 +490,7 @@ class HomeAssistant: ) return - self.state = CoreState.running + self.set_state(CoreState.running) self.bus.async_fire(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -894,7 +901,7 @@ class HomeAssistant: self.exit_code = exit_code - self.state = CoreState.stopping + self.set_state(CoreState.stopping) self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): @@ -907,7 +914,7 @@ class HomeAssistant: self._async_log_running_tasks("stop integrations") # Stage 3 - Final write - self.state = CoreState.final_write + self.set_state(CoreState.final_write) self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): @@ -920,7 +927,7 @@ class HomeAssistant: self._async_log_running_tasks("final write") # Stage 4 - Close - self.state = CoreState.not_running + self.set_state(CoreState.not_running) self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) # Make a copy of running_tasks since a task can finish @@ -971,7 +978,7 @@ class HomeAssistant: ) self._async_log_running_tasks("close") - self.state = CoreState.stopped + self.set_state(CoreState.stopped) if self._stopped is not None: self._stopped.set() diff --git a/tests/common.py b/tests/common.py index 8b5a16c7104..1b40904d5e2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -285,7 +285,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None - hass.state = CoreState.running + hass.set_state(CoreState.running) @callback def clear_instance(event): diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index c17593e0e2a..08d198145df 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -735,9 +735,12 @@ async def test_proactive_mode_filter_states( "off", {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - with patch.object(hass, "state", core.CoreState.stopping): - await hass.async_block_till_done() - await hass.async_block_till_done() + + current_state = hass.state + hass.set_state(core.CoreState.stopping) + await hass.async_block_till_done() + await hass.async_block_till_done() + hass.set_state(current_state) assert len(aioclient_mock.mock_calls) == 0 # unsupported entity should not report diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index f2ea0066345..10b7eb86235 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -340,7 +340,7 @@ async def test_restored_state( ) # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) mock_restore_cache_with_extra_data( hass, [ diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6bb1b89259a..3a8c12f735a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1202,7 +1202,7 @@ async def test_initial_value_off(hass: HomeAssistant) -> None: async def test_initial_value_on(hass: HomeAssistant) -> None: """Test initial value on.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") assert await async_setup_component( @@ -1231,7 +1231,7 @@ async def test_initial_value_on(hass: HomeAssistant) -> None: async def test_initial_value_off_but_restore_on(hass: HomeAssistant) -> None: """Test initial value off and restored state is turned on.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") mock_restore_cache(hass, (State("automation.hello", STATE_ON),)) @@ -1328,7 +1328,7 @@ async def test_automation_is_on_if_no_initial_state_or_restore( async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: """Test if automation is not trigger on bootstrap.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "test", "automation") assert await async_setup_component( @@ -2460,7 +2460,7 @@ async def test_recursive_automation_starting_script( await asyncio.wait_for(script_done_event.wait(), 10) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 10) @@ -2521,7 +2521,7 @@ async def test_recursive_automation( await asyncio.wait_for(service_called.wait(), 1) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) diff --git a/tests/components/bluetooth/test_active_update_coordinator.py b/tests/components/bluetooth/test_active_update_coordinator.py index 2686138d724..83fee1456cd 100644 --- a/tests/components/bluetooth/test_active_update_coordinator.py +++ b/tests/components/bluetooth/test_active_update_coordinator.py @@ -439,7 +439,7 @@ async def test_no_polling_after_stop_event( assert needs_poll_calls == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert needs_poll_calls == 1 diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index fba86223a2d..00562a20daf 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -436,7 +436,7 @@ async def test_no_polling_after_stop_event( assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert needs_poll_calls == 1 diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 345c4b62b7e..5c7c4e39083 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -553,7 +553,7 @@ async def test_no_updates_once_stopping( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) assert len(all_events) == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) # We should stop processing events once hass is stopping inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py index 100a133ae4d..d61b4e06560 100644 --- a/tests/components/bond/test_entity.py +++ b/tests/components/bond/test_entity.py @@ -206,7 +206,7 @@ async def test_polling_stops_at_the_stop_event(hass: HomeAssistant) -> None: assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() with patch_bond_device_state(return_value={"power": 1, "speed": 1}): diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 00f8a34fb0c..0e0ff1444eb 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -120,7 +120,7 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: async def test_delay_load_during_startup(hass: HomeAssistant) -> None: """Test delayed loading of a config entry during startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) entry.add_to_hass(hass) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 2be2a8eb2bb..0ebc385b516 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -551,7 +551,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( alexa_settings_version: int, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -649,7 +649,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_v2_no_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config from v2 to v3 when no entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -696,7 +696,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_v2_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config from v2 to v3 when an entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -744,7 +744,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -782,7 +782,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Alexa entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 39bf60570f2..bedc6b459c5 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -236,7 +236,7 @@ async def test_google_entity_registry_sync( assert len(mock_sync.mock_calls) == 3 # When hass is not started yet we wait till started - hass.state = CoreState.starting + hass.set_state(CoreState.starting) hass.bus.async_fire( er.EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entry.entity_id}, @@ -338,7 +338,7 @@ async def test_sync_google_on_home_assistant_start( config = CloudGoogleConfig( hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) with patch.object(config, "async_sync_entities_all") as mock_sync: await config.async_initialize() await config.async_connect_agent_user("mock-user-id") @@ -498,7 +498,7 @@ async def test_google_config_migrate_expose_entity_prefs( google_settings_version: int, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -611,7 +611,7 @@ async def test_google_config_migrate_expose_entity_prefs_v2_no_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config from v2 to v3 when no entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -658,7 +658,7 @@ async def test_google_config_migrate_expose_entity_prefs_v2_exposed( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config from v2 to v3 when an entity is exposed.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set("light.state_only", "on") @@ -705,7 +705,7 @@ async def test_google_config_migrate_expose_entity_prefs_default_none( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) entity_default = entity_registry.async_get_or_create( @@ -742,7 +742,7 @@ async def test_google_config_migrate_expose_entity_prefs_default( entity_registry: er.EntityRegistry, ) -> None: """Test migrating Google entity config.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 53bec13d567..a52c083d10f 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -282,7 +282,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("counter.test1", "11"), State("counter.test2", "-22")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -315,7 +315,7 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, DOMAIN, {DOMAIN: {"test1": {}, "test2": {CONF_INITIAL: 10}, "test3": {}}} @@ -332,7 +332,7 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_STEP: 5}}}) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index ada1c03a923..3831d247ed4 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -238,7 +238,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( async def test_initialize_start(hass: HomeAssistant) -> None: """Test we initialize when HA starts.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component( hass, device_sun_light_trigger.DOMAIN, diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py index dc61acb620e..547d574b25a 100644 --- a/tests/components/fastdotcom/test_init.py +++ b/tests/components/fastdotcom/test_init.py @@ -64,11 +64,12 @@ async def test_delayed_speedtest_during_startup( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.fastdotcom.coordinator.fast_com" - ), patch.object(hass, "state", CoreState.starting): + original_state = hass.state + hass.set_state(CoreState.starting) + with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + hass.set_state(original_state) assert config_entry.state == config_entries.ConfigEntryState.LOADED state = hass.states.get("sensor.fast_com_download") diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 9c0fa7ddaef..d68e5ca78e0 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -1376,7 +1376,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1414,7 +1414,7 @@ async def test_restore_state_target_humidity(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1457,7 +1457,7 @@ async def test_restore_state_and_return_to_normal(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1512,7 +1512,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 9196de8b096..e0b6e5c9987 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -1317,7 +1317,7 @@ async def test_restore_state(hass: HomeAssistant, hvac_mode) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1355,7 +1355,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -1432,7 +1432,7 @@ async def test_restore_will_turn_off_(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} @@ -1480,7 +1480,7 @@ async def test_restore_will_turn_off_when_loaded_second(hass: HomeAssistant) -> ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() assert hass.states.get(heater_switch) is None diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 2a1baef6798..ecc21ab0cd2 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -386,7 +386,7 @@ async def test_state_missing_entity_id(hass: HomeAssistant, setup_comp) -> None: async def test_setup_before_started(hass: HomeAssistant) -> None: """Test we can setup before starting.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component(hass, DOMAIN, CONFIG_MISSING_FAN) await hass.async_block_till_done() diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 5c48385c91e..cb5143d5a12 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1139,7 +1139,7 @@ async def test_group_alarm(hass: HomeAssistant) -> None: hass.states.async_set("alarm_control_panel.one", "armed_away") hass.states.async_set("alarm_control_panel.two", "armed_home") hass.states.async_set("alarm_control_panel.three", "armed_away") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, @@ -1187,7 +1187,7 @@ async def test_group_vacuum_off(hass: HomeAssistant) -> None: hass.states.async_set("vacuum.one", "docked") hass.states.async_set("vacuum.two", "off") hass.states.async_set("vacuum.three", "off") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, @@ -1280,7 +1280,7 @@ async def test_switch_removed(hass: HomeAssistant) -> None: hass.states.async_set("switch.two", "off") hass.states.async_set("switch.three", "on") - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await async_setup_component( hass, "group", @@ -1409,7 +1409,7 @@ async def test_group_that_references_a_group_of_lights(hass: HomeAssistant) -> N "light.living_front_ri", "light.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "off") @@ -1443,7 +1443,7 @@ async def test_group_that_references_a_group_of_covers(hass: HomeAssistant) -> N "cover.living_front_ri", "cover.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "closed") @@ -1479,7 +1479,7 @@ async def test_group_that_references_two_groups_of_covers(hass: HomeAssistant) - "cover.living_front_ri", "cover.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in entity_ids: hass.states.async_set(entity_id, "closed") @@ -1523,7 +1523,7 @@ async def test_group_that_references_two_types_of_groups(hass: HomeAssistant) -> "device_tracker.living_front_ri", "device_tracker.living_back_lef", ] - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for entity_id in group_1_entity_ids: hass.states.async_set(entity_id, "closed") diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 0cce33f6dfd..e54fdcafd1d 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -58,7 +58,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): ), patch( "homeassistant.components.hassio.HassIO.refresh_updates", ): - hass.state = CoreState.starting + hass.set_state(CoreState.starting) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) return hass_api.call_args[0][1] diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 28228788cf5..21580c48f33 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -486,7 +486,7 @@ async def test_route_not_found( async def test_restore_state(hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) last_reset = "2022-11-29T00:00:00.000000+00:00" mock_restore_cache_with_extra_data( hass, diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 6ac8291ee55..9a202bc99a1 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -31,7 +31,7 @@ async def test_if_fires_on_hass_start( ) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component(hass, automation.DOMAIN, hass_config) assert automation.is_on(hass, "automation.hello") @@ -54,7 +54,7 @@ async def test_if_fires_on_hass_start( async def test_if_fires_on_hass_shutdown(hass: HomeAssistant) -> None: """Test the firing when Home Assistant shuts down.""" calls = async_mock_service(hass, "test", "automation") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await async_setup_component( hass, diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 989e4dd01d3..bc43ebaf42f 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -610,7 +610,7 @@ async def test_windowcovering_basic_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "cover", @@ -648,7 +648,7 @@ async def test_windowcovering_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event entity_registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "cover", diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 118e67a43b1..edaa277576e 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -557,7 +557,7 @@ async def test_fan_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "fan", diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 7568e7a4844..80555750640 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -580,7 +580,7 @@ async def test_light_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="simple" diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 1954d6bf8ca..8089db833e8 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -432,7 +432,7 @@ async def test_tv_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "media_player", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 23e53eef94d..fae963c81f5 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -545,7 +545,7 @@ async def test_sensor_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "sensor", diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 0bea0144506..3f5d939bb82 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -971,7 +971,7 @@ async def test_thermostat_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "climate", "generic", "1234", suggested_object_id="simple" @@ -1801,7 +1801,7 @@ async def test_water_heater_restore( hass: HomeAssistant, entity_registry: er.EntityRegistry, hk_driver, events ) -> None: """Test setting up an entity from state in the event registry.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "water_heater", "generic", "1234", suggested_object_id="simple" diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 4caf914ca19..65d7fb93d19 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -133,7 +133,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": None, "b2": None}}) @@ -153,7 +153,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("input_boolean.b1", "on"), State("input_boolean.b2", "off")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index 9233668c113..2f9b677e134 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -96,7 +96,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: (State("input_button.b1", "2021-01-01T23:59:59+00:00"),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": None, "b2": None}}) diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index a0b80ac420c..0a3f9b3ed6c 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -325,7 +325,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) initial = datetime.datetime(2017, 1, 1, 23, 42) default = datetime.datetime.combine(datetime.date.today(), DEFAULT_TIME) diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 1334ba4aebd..305ff74b6bf 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -238,7 +238,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: hass, (State("input_number.b1", "70"), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -261,7 +261,7 @@ async def test_restore_invalid_state(hass: HomeAssistant) -> None: hass, (State("input_number.b1", "="), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -284,7 +284,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non hass, (State("input_number.b1", "70"), State("input_number.b2", "200")) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -308,7 +308,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": {"min": 0, "max": 100}}}) diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 23d1c3307e5..c057407a644 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -165,7 +165,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: (State("input_text.b1", "test"), State("input_text.b2", "testing too long")), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"b1": None, "b2": {"min": 0, "max": 10}}} @@ -187,7 +187,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non (State("input_text.b1", "testing"), State("input_text.b2", "testing too long")), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component( hass, @@ -211,7 +211,7 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": {"min": 0, "max": 100}}}) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9ca8ac9e2ba..14e28b0999d 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -1221,7 +1221,7 @@ async def test_restore_state(hass: HomeAssistant, expected_state) -> None: """Ensure state is restored on startup.""" mock_restore_cache(hass, (State("alarm_control_panel.test", expected_state),)) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1266,7 +1266,7 @@ async def test_restore_state_arming(hass: HomeAssistant, expected_state) -> None hass, (State(entity_id, expected_state, attributes, last_updated=time),) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1323,7 +1323,7 @@ async def test_restore_state_pending(hass: HomeAssistant, previous_state) -> Non (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1388,7 +1388,7 @@ async def test_restore_state_triggered(hass: HomeAssistant, previous_state) -> N (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( @@ -1434,7 +1434,7 @@ async def test_restore_state_triggered_long_ago(hass: HomeAssistant) -> None: (State(entity_id, STATE_ALARM_TRIGGERED, attributes, last_updated=time),), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") assert await async_setup_component( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5bd41da057c..bfbf4e8670c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2452,7 +2452,7 @@ async def test_delayed_birth_message( """Test sending birth message does not happen until Home Assistant starts.""" mqtt_mock = await mqtt_mock_entry() - hass.state = CoreState.starting + hass.set_state(CoreState.starting) birth = asyncio.Event() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 3c25d419cfe..751521645a9 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -315,7 +315,7 @@ async def test_default_entity_and_device_name( """ events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index d1f265770b8..14153b44d87 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -140,7 +140,7 @@ async def test_waiting_for_client_not_loaded( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client while mqtt entry is not yet loaded.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -199,7 +199,7 @@ async def test_waiting_for_client_entry_fails( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client where mqtt entry is failing.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -227,7 +227,7 @@ async def test_waiting_for_client_setup_fails( mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test waiting for client where mqtt entry is failing during setup.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -254,7 +254,7 @@ async def test_waiting_for_client_timeout( hass: HomeAssistant, ) -> None: """Test waiting for client with timeout.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( @@ -273,7 +273,7 @@ async def test_waiting_for_client_with_disabled_entry( hass: HomeAssistant, ) -> None: """Test waiting for client with timeout.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await hass.async_block_till_done() entry = MockConfigEntry( diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index cd228183c9e..c7bb9d4fcfa 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -58,7 +58,7 @@ async def test_setup_and_stop_waits_for_ha( e_id = "fake.entity" # HA is not running - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert await add_statestream(hass, base_topic="pub") await hass.async_block_till_done() diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 9aee3170027..3e0231579a8 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -310,7 +310,7 @@ async def test_setup_component_with_delay( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test setup of the netatmo component with delayed startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) with patch( "pyatmo.AbstractAsyncAuth.async_addwebhook", side_effect=AsyncMock() diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 96393a5139d..95c944449de 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -195,7 +195,7 @@ async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: }, ) config_entry.add_to_hass(hass) - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 71491ee3caf..a9f91801883 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -99,7 +99,7 @@ async def test_valid_invalid_user_ids( async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test set up person with one device tracker.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -159,7 +159,7 @@ async def test_setup_two_trackers( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -247,7 +247,7 @@ async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: """Test set up person with two device trackers, one unavailable.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) user_id = hass_admin_user.id config = { DOMAIN: { @@ -302,7 +302,7 @@ async def test_restore_home_state( } state = State("person.tracked_person", "home", attrs) mock_restore_cache(hass, (state,)) - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) mock_component(hass, "recorder") config = { DOMAIN: { diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index a9a12d72c41..f8aa219fdb4 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -137,7 +137,7 @@ async def test_shutdown_before_startup_finishes( recorder.CONF_DB_URL: recorder_db_url, recorder.CONF_COMMIT_INTERVAL: 1, } - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass, config)) @@ -168,7 +168,7 @@ async def test_canceled_before_startup_finishes( caplog: pytest.LogCaptureFixture, ) -> None: """Test recorder shuts down when its startup future is canceled out from under it.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass)) await recorder_helper.async_wait_recorder(hass) @@ -192,7 +192,7 @@ async def test_shutdown_closes_connections( ) -> None: """Test shutdown closes connections.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) instance = get_instance(hass) await instance.async_db_ready @@ -219,7 +219,7 @@ async def test_state_gets_saved_when_set_before_start_event( ) -> None: """Test we can record an event when starting with not running.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.create_task(async_setup_recorder_instance(hass)) diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 7c1ee331d48..416bd4f71b4 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -193,7 +193,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.test", STATE_ON), State(f"{DOMAIN}.test2", STATE_ON)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, CONFIG, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_cover.py b/tests/components/rflink/test_cover.py index 2f1ff42cc9a..71b3d2067d0 100644 --- a/tests/components/rflink/test_cover.py +++ b/tests/components/rflink/test_cover.py @@ -349,7 +349,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.c1", STATE_OPEN), State(f"{DOMAIN}.c2", STATE_CLOSED)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index 34b918cd3ed..75d2566f336 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -575,7 +575,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py index 07e34c7ebfe..35646d0fd22 100644 --- a/tests/components/rflink/test_switch.py +++ b/tests/components/rflink/test_switch.py @@ -263,7 +263,7 @@ async def test_restore_state(hass: HomeAssistant, monkeypatch) -> None: hass, (State(f"{DOMAIN}.s1", STATE_ON), State(f"{DOMAIN}.s2", STATE_OFF)) ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) # setup mocking rflink module _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 83abd37137e..5f8e04d527a 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1152,7 +1152,7 @@ async def test_script_restore_last_triggered(hass: HomeAssistant) -> None: State("script.last_triggered", STATE_OFF, {"last_triggered": time}), ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert await async_setup_component( hass, @@ -1373,7 +1373,7 @@ async def test_recursive_script_turn_on( await asyncio.wait_for(service_called.wait(), 1) # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index f6f6445a0fb..b67a353932a 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -52,7 +52,7 @@ def test_compile_missing_statistics( start_time = three_days_ago + timedelta(days=3) freezer.move_to(three_days_ago) hass: HomeAssistant = get_test_home_assistant() - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) setup_component(hass, "sensor", {}) setup_component(hass, "recorder", {"recorder": config}) @@ -90,7 +90,7 @@ def test_compile_missing_statistics( hass.stop() freezer.move_to(start_time) hass: HomeAssistant = get_test_home_assistant() - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) setup_component(hass, "sensor", {}) hass.states.set("sensor.test1", "0", POWER_SENSOR_ATTRIBUTES) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 01c0f005716..708571ce913 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -764,7 +764,7 @@ async def test_no_update_template_match_all( ) -> None: """Test that we do not update sensors that match on all.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) await setup.async_setup_component( hass, diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 8aef5947d56..d25f638cfdb 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -507,7 +507,7 @@ async def test_no_template_match_all( """Test that we allow static templates.""" hass.states.async_set("sensor.test_sensor", "startup") - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) await async_setup_component( hass, @@ -752,7 +752,7 @@ async def test_this_variable_early_hass_not_running( """ entity_id = "sensor.none_false" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) # Setup template with assert_setup_component(count, domain): diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index db09fe6e676..1e979fc9926 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -525,7 +525,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) mock_component(hass, "recorder") await async_setup_component( diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 6b6929e88ec..92baa013f14 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -147,7 +147,7 @@ async def test_config_options(hass: HomeAssistant) -> None: async def test_methods_and_events(hass: HomeAssistant) -> None: """Test methods and events.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -393,7 +393,7 @@ async def test_start_service(hass: HomeAssistant) -> None: async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: """Test for a timer to end.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 20}}}) @@ -460,7 +460,7 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -569,7 +569,7 @@ async def test_config_reload( async def test_timer_restarted_event(hass: HomeAssistant) -> None: """Ensure restarted event is called after starting a paused or running timer.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -636,7 +636,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None: async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None: """Ensure timer's state changes when it restarted.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 1197719328b..511667a7462 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -426,7 +426,7 @@ async def test_restore_traces( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client, domain ) -> None: """Test restored traces.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 def next_id(): @@ -598,7 +598,7 @@ async def test_restore_traces_overflow( num_restored_moon_traces, ) -> None: """Test restored traces are evicted first.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 trace_uuids = [] @@ -679,7 +679,7 @@ async def test_restore_traces_late_overflow( restored_run_id, ) -> None: """Test restored traces are evicted first.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) id = 1 trace_uuids = [] diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index d77c2db356a..37127363614 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -534,7 +534,7 @@ async def test_restore_state( ) -> None: """Test utility sensor restore state.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) last_reset = "2020-12-21T00:00:00.013073+00:00" @@ -865,7 +865,7 @@ async def test_delta_values( ) -> None: """Test utility meter "delta_values" mode.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) now = dt_util.utcnow() with freeze_time(now): @@ -974,7 +974,7 @@ async def test_non_periodically_resetting( ) -> None: """Test utility meter "non periodically resetting" mode.""" # Home assistant is not runnit yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) now = dt_util.utcnow() with freeze_time(now): diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 3155d588e14..20d0c436134 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -49,7 +49,7 @@ async def test_dryer_sensor_values( entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -113,7 +113,7 @@ async def test_washer_sensor_values( entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -280,7 +280,7 @@ async def test_restore_state( ) -> None: """Test sensor restore state.""" # Home assistant is not running yet - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, @@ -333,7 +333,7 @@ async def test_callback( mock_sensor1_api: MagicMock, ) -> None: """Test callback timestamp callback function.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 0adb7583d31..5a1f2a862ac 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -685,7 +685,7 @@ async def test_shade_restore_state( ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) zha_device = await zha_device_restored(zigpy_shade_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -711,7 +711,7 @@ async def test_cover_restore_state( ), ) - hass.state = CoreState.starting + hass.set_state(CoreState.starting) zha_device = await zha_device_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index c2e9469c239..9c9ffbb3ba1 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -279,7 +279,7 @@ async def test_shutdown_on_ha_stop( zha_data.gateway, "shutdown", wraps=zha_data.gateway.shutdown ) as mock_shutdown: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert len(mock_shutdown.mock_calls) == 1 diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 9e17f25c708..ed42363ca41 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -327,14 +327,14 @@ async def test_update_entity_ha_not_running( assert len(client.async_send_command.call_args_list) == 1 # Update should be delayed by a day because HA is not running - hass.state = CoreState.starting + hass.set_state(CoreState.starting) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 - hass.state = CoreState.running + hass.set_state(CoreState.running) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 240afa2cbab..c82a7493fe1 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1408,7 +1408,7 @@ async def test_cleanup_device_registry_removes_expired_orphaned_devices( async def test_cleanup_startup(hass: HomeAssistant) -> None: """Test we run a cleanup on startup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) with patch( "homeassistant.helpers.device_registry.Debouncer.async_call" diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 9f1d8dfcbc9..0b3386f8e04 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -38,7 +38,7 @@ async def test_async_create_flow_deferred_until_started( hass: HomeAssistant, mock_flow_init ) -> None: """Test flows are deferred until started.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) discovery_flow.async_create_flow( hass, "hue", @@ -79,7 +79,7 @@ async def test_async_create_flow_checks_existing_flows_before_startup( hass: HomeAssistant, mock_flow_init ) -> None: """Test existing flows prevent an identical ones from being created before startup.""" - hass.state = CoreState.stopped + hass.set_state(CoreState.stopped) for _ in range(2): discovery_flow.async_create_flow( hass, @@ -104,7 +104,7 @@ async def test_async_create_flow_does_nothing_after_stop( """Test we no longer create flows when hass is stopping.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) mock_flow_init.reset_mock() discovery_flow.async_create_flow( hass, diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index dfaec4577aa..01558c426c7 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -937,7 +937,7 @@ async def test_reset_cancels_retry_setup(hass: HomeAssistant) -> None: async def test_reset_cancels_retry_setup_when_not_started(hass: HomeAssistant) -> None: """Test that resetting a platform will cancel scheduled a setup retry when not yet started.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) async_setup_entry = Mock(side_effect=PlatformNotReady) initial_listeners = hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index d01d7746253..1c13da1192f 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -870,7 +870,7 @@ async def test_restore_states( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test restoring states.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "light", @@ -936,7 +936,7 @@ async def test_async_get_device_class_lookup( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test registry device class lookup.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) entity_registry.async_get_or_create( "binary_sensor", diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 79298ed1611..2a01439ccbd 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -210,7 +210,7 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: async def test_hass_starting(hass: HomeAssistant) -> None: """Test that we cache data.""" - hass.state = CoreState.starting + hass.set_state(CoreState.starting) now = dt_util.utcnow() stored_states = [ @@ -224,7 +224,7 @@ async def test_hass_starting(hass: HomeAssistant) -> None: await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) hass.data.pop(DATA_RESTORE_STATE) await async_load(hass) data = async_get(hass) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 1ea602f7cda..a4361d28e74 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4340,7 +4340,7 @@ async def test_shutdown_after( script_obj = script.Script(hass, sequence, "test script", "test_domain") delay_started_flag = async_watch_for_action(script_obj, delay_alias) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await hass.async_block_till_done() @@ -4379,7 +4379,7 @@ async def test_start_script_after_shutdown( script_obj = script.Script(hass, sequence, "test script", "test_domain") # Trigger 1st stage script shutdown - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) hass.bus.async_fire("homeassistant_stop") await hass.async_block_till_done() # Trigger 2nd stage script shutdown diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index f5204a2ec64..d203b336f27 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -21,7 +21,7 @@ async def test_at_start_when_running_awaitable(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 1 - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert hass.is_running start.async_at_start(hass, cb_at_start) @@ -46,7 +46,7 @@ async def test_at_start_when_running_callback( start.async_at_start(hass, cb_at_start)() assert len(calls) == 1 - hass.state = CoreState.starting + hass.set_state(CoreState.starting) assert hass.is_running start.async_at_start(hass, cb_at_start)() @@ -59,7 +59,7 @@ async def test_at_start_when_running_callback( async def test_at_start_when_starting_awaitable(hass: HomeAssistant) -> None: """Test at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -81,7 +81,7 @@ async def test_at_start_when_starting_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -130,7 +130,7 @@ async def test_cancelling_at_start_when_running( async def test_cancelling_at_start_when_starting(hass: HomeAssistant) -> None: """Test cancelling at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] @@ -164,7 +164,7 @@ async def test_at_started_when_running_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 1 # Test the job is not run if state is CoreState.starting - hass.state = CoreState.starting + hass.set_state(CoreState.starting) start.async_at_started(hass, cb_at_start) await hass.async_block_till_done() @@ -188,7 +188,7 @@ async def test_at_started_when_running_callback( assert len(calls) == 1 # Test the job is not run if state is CoreState.starting - hass.state = CoreState.starting + hass.set_state(CoreState.starting) start.async_at_started(hass, cb_at_start)() assert len(calls) == 1 @@ -200,7 +200,7 @@ async def test_at_started_when_running_callback( async def test_at_started_when_starting_awaitable(hass: HomeAssistant) -> None: """Test at started when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = [] @@ -225,7 +225,7 @@ async def test_at_started_when_starting_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test at started when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) calls = [] @@ -277,7 +277,7 @@ async def test_cancelling_at_started_when_running( async def test_cancelling_at_started_when_starting(hass: HomeAssistant) -> None: """Test cancelling at start when yet to start.""" - hass.state = CoreState.not_running + hass.set_state(CoreState.not_running) assert not hass.is_running calls = [] diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 85aa4d2de0e..4506d827096 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -140,7 +140,7 @@ async def test_saving_on_final_write( assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -164,7 +164,7 @@ async def test_not_delayed_saving_while_stopping( store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) store.async_delay_save(lambda: MOCK_DATA, 1) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) @@ -181,7 +181,7 @@ async def test_not_delayed_saving_after_stopping( assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() assert store.key not in hass_storage @@ -195,7 +195,7 @@ async def test_not_saving_while_stopping( ) -> None: """Test saves don't write when stopping Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await store.async_save(MOCK_DATA) assert store.key not in hass_storage @@ -723,7 +723,7 @@ async def test_read_only_store( assert read_only_store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 182ed6c3cb4..6497382ab9a 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -506,7 +506,7 @@ async def test_stop_refresh_on_ha_stop( # Fire Home Assistant stop event hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) await hass.async_block_till_done() # Make sure no update with subscriber after stop event diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index fd74a2e6286..2a56e34b981 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1084,7 +1084,7 @@ async def test_setup_retrying_during_unload(hass: HomeAssistant) -> None: async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) -> None: """Test if we unload an entry that is in retry mode before started.""" entry = MockConfigEntry(domain="test") - hass.state = CoreState.starting + hass.set_state(CoreState.starting) initial_listeners = hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) @@ -1121,7 +1121,7 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(mock_setup_entry.mock_calls) == 1 - hass.state = CoreState.stopping + hass.set_state(CoreState.stopping) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() From edd7feaf106fae006a020d17794b73dcbfc39b4b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 18 Jan 2024 22:11:02 +0100 Subject: [PATCH 0750/1544] Add task to install all requirements of an integration (#108262) * Add task to install the requirements of an integration * Gather recursive requirements * Move valid_integration to util * Apply suggestions from code review Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Implement suggestions --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .vscode/tasks.json | 14 ++++++ script/const.py | 4 ++ script/install_integration_requirements.py | 54 ++++++++++++++++++++++ script/scaffold/__main__.py | 13 +----- script/util.py | 15 ++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 script/const.py create mode 100644 script/install_integration_requirements.py create mode 100644 script/util.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b8cb8a4e61a..d6657f04557 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -157,6 +157,20 @@ "kind": "build", "isDefault": true } + }, + { + "label": "Install integration requirements", + "detail": "Install all requirements of a given integration.", + "type": "shell", + "command": "${command:python.interpreterPath} -m script.install_integration_requirements ${input:integrationName}", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + } } ], "inputs": [ diff --git a/script/const.py b/script/const.py new file mode 100644 index 00000000000..de9b559e634 --- /dev/null +++ b/script/const.py @@ -0,0 +1,4 @@ +"""Script constants.""" +from pathlib import Path + +COMPONENT_DIR = Path("homeassistant/components") diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py new file mode 100644 index 00000000000..1a87be04f7e --- /dev/null +++ b/script/install_integration_requirements.py @@ -0,0 +1,54 @@ +"""Install requirements for a given integration.""" + +import argparse +from pathlib import Path +import subprocess +import sys + +from .gen_requirements_all import gather_recursive_requirements +from .util import valid_integration + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser( + description="Install requirements for a given integration" + ) + parser.add_argument( + "integration", type=valid_integration, help="Integration to target." + ) + + arguments = parser.parse_args() + + return arguments + + +def main() -> int | None: + """Install requirements for a given integration.""" + if not Path("requirements_all.txt").is_file(): + print("Run from project root") + return 1 + + args = get_arguments() + + requirements = gather_recursive_requirements(args.integration) + + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "-c", + "homeassistant/package_constraints.txt", + "-U", + *requirements, + ] + print(" ".join(cmd)) + subprocess.run( + cmd, + check=True, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index ddbd1189e11..c303fc2c247 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -4,24 +4,15 @@ from pathlib import Path import subprocess import sys +from script.util import valid_integration + from . import docs, error, gather_info, generate -from .const import COMPONENT_DIR TEMPLATES = [ p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir() ] -def valid_integration(integration): - """Test if it's a valid integration.""" - if not (COMPONENT_DIR / integration).exists(): - raise argparse.ArgumentTypeError( - f"The integration {integration} does not exist." - ) - - return integration - - def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser(description="Home Assistant Scaffolder") diff --git a/script/util.py b/script/util.py new file mode 100644 index 00000000000..b7c37c72102 --- /dev/null +++ b/script/util.py @@ -0,0 +1,15 @@ +"""Utility functions for the scaffold script.""" + +import argparse + +from .const import COMPONENT_DIR + + +def valid_integration(integration): + """Test if it's a valid integration.""" + if not (COMPONENT_DIR / integration).exists(): + raise argparse.ArgumentTypeError( + f"The integration {integration} does not exist." + ) + + return integration From 5f08e2a2d17d0d6f308d5f22ddff51e427b4b42e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:13:08 +0100 Subject: [PATCH 0751/1544] Improve august typing (1) (#108325) --- homeassistant/components/august/__init__.py | 14 ++++++++------ homeassistant/components/august/activity.py | 2 ++ homeassistant/components/august/binary_sensor.py | 12 ++++++------ homeassistant/components/august/button.py | 2 +- homeassistant/components/august/camera.py | 6 +++++- homeassistant/components/august/entity.py | 10 +++++----- homeassistant/components/august/gateway.py | 2 +- homeassistant/components/august/lock.py | 6 +++--- homeassistant/components/august/sensor.py | 10 +++++----- homeassistant/components/august/subscriber.py | 11 ++++++----- 10 files changed, 42 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index c1eb21b6827..01731290e60 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import ValuesView +from collections.abc import Iterable, ValuesView from datetime import datetime from itertools import chain import logging @@ -104,7 +104,7 @@ async def async_setup_august( @callback def _async_trigger_ble_lock_discovery( hass: HomeAssistant, locks_with_offline_keys: list[LockDetail] -): +) -> None: """Update keys for the yalexs-ble integration if available.""" for lock_detail in locks_with_offline_keys: discovery_flow.async_create_flow( @@ -213,7 +213,7 @@ class AugustData(AugustSubscriberMixin): self._hass, self._async_initial_sync(), "august-initial-sync" ) - async def _async_initial_sync(self): + async def _async_initial_sync(self) -> None: """Attempt to request an initial sync.""" # We don't care if this fails because we only want to wake # locks that are actually online anyways and they will be @@ -274,7 +274,9 @@ class AugustData(AugustSubscriberMixin): async def _async_refresh(self, time): await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) - async def _async_refresh_device_detail_by_ids(self, device_ids_list): + async def _async_refresh_device_detail_by_ids( + self, device_ids_list: Iterable[str] + ) -> None: """Refresh each device in sequence. This used to be a gather but it was less reliable with august's @@ -421,7 +423,7 @@ class AugustData(AugustSubscriberMixin): return ret - def _remove_inoperative_doorbells(self): + def _remove_inoperative_doorbells(self) -> None: for doorbell in list(self.doorbells): device_id = doorbell.device_id if self._device_detail_by_id.get(device_id): @@ -435,7 +437,7 @@ class AugustData(AugustSubscriberMixin): ) del self._doorbells_by_id[device_id] - def _remove_inoperative_locks(self): + def _remove_inoperative_locks(self) -> None: # Remove non-operative locks as there must # be a bridge (August Connect) for them to # be usable diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index fdb399f0646..fb87a1f7969 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,4 +1,6 @@ """Consume the august activity stream.""" +from __future__ import annotations + import asyncio from datetime import datetime from functools import partial diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 144666844e7..056969921f0 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -228,7 +228,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self._attr_unique_id = f"{self._device_id}_{description.key}" @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" assert self._data.activity_stream is not None door_activity = self._data.activity_stream.get_latest_device_activity( @@ -270,12 +270,12 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Initialize the sensor.""" super().__init__(data, device) self.entity_description = description - self._check_for_off_update_listener = None + self._check_for_off_update_listener: Callable[[], None] | None = None self._data = data self._attr_unique_id = f"{self._device_id}_{description.key}" @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" self._cancel_any_pending_updates() self._attr_is_on = self.entity_description.value_fn(self._data, self._detail) @@ -286,14 +286,14 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): else: self._attr_available = True - def _schedule_update_to_recheck_turn_off_sensor(self): + def _schedule_update_to_recheck_turn_off_sensor(self) -> None: """Schedule an update to recheck the sensor to see if it is ready to turn off.""" # If the sensor is already off there is nothing to do if not self.is_on: return @callback - def _scheduled_update(now): + def _scheduled_update(now: datetime) -> None: """Timer callback for sensor update.""" self._check_for_off_update_listener = None self._update_from_data() @@ -304,7 +304,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self.hass, TIME_TO_RECHECK_DETECTION.total_seconds(), _scheduled_update ) - def _cancel_any_pending_updates(self): + def _cancel_any_pending_updates(self) -> None: """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" if not self._check_for_off_update_listener: return diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index b8f66aea02b..3997a2d72bf 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -36,5 +36,5 @@ class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): await self._data.async_status_async(self._device_id, self._hyper_bridge) @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Nothing to update as buttons are stateless.""" diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index e618c2d49d5..904ef316e5b 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,7 +1,9 @@ """Support for August doorbell camera.""" from __future__ import annotations +from aiohttp import ClientSession from yalexs.activity import ActivityType +from yalexs.doorbell import Doorbell from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera @@ -37,7 +39,9 @@ class AugustCamera(AugustEntityMixin, Camera): _attr_translation_key = "camera" - def __init__(self, data, device, session, timeout): + def __init__( + self, data: AugustData, device: Doorbell, session: ClientSession, timeout: int + ) -> None: """Initialize an August security camera.""" super().__init__(data, device) self._timeout = timeout diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index d149e035ac4..787b99c34ac 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -42,7 +42,7 @@ class AugustEntityMixin(Entity): self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)} @property - def _device_id(self): + def _device_id(self) -> str: return self._device.device_id @property @@ -50,17 +50,17 @@ class AugustEntityMixin(Entity): return self._data.get_device_detail(self._device.device_id) @property - def _hyper_bridge(self): + def _hyper_bridge(self) -> bool: """Check if the lock has a paired hyper bridge.""" return bool(self._detail.bridge and self._detail.bridge.hyper_bridge) @callback - def _update_from_data_and_write_state(self): + def _update_from_data_and_write_state(self) -> None: self._update_from_data() self.async_write_ha_state() @abstractmethod - def _update_from_data(self): + def _update_from_data(self) -> None: """Update the entity state from the data object.""" async def async_added_to_hass(self): @@ -77,7 +77,7 @@ class AugustEntityMixin(Entity): ) -def _remove_device_types(name, device_types): +def _remove_device_types(name: str, device_types: list[str]) -> str: """Strip device types from a string. August stores the name as Master Bed Lock diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index badff721d10..2527be0ee1b 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -132,7 +132,7 @@ class AugustGateway: return self.authentication - async def async_reset_authentication(self): + async def async_reset_authentication(self) -> None: """Remove the cache file.""" await self._hass.async_add_executor_job(self._reset_authentication) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index e082cd1cfab..31a4c24092b 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -4,7 +4,7 @@ from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType -from yalexs.lock import LockStatus +from yalexs.lock import Lock, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity @@ -39,7 +39,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): _attr_name = None - def __init__(self, data, device): + def __init__(self, data: AugustData, device: Lock) -> None: """Initialize the lock.""" super().__init__(data, device) self._lock_status = None @@ -82,7 +82,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): ) self._data.async_signal_device_id_update(self._device_id) - def _update_lock_status_from_detail(self): + def _update_lock_status_from_detail(self) -> bool: self._attr_available = self._detail.bridge_is_online if self._lock_status != self._detail.lock_status: diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 1896a91c54f..94b883dba0d 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from yalexs.activity import ActivityType from yalexs.doorbell import Doorbell @@ -179,7 +179,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): _attr_translation_key = "operator" - def __init__(self, data, device): + def __init__(self, data: AugustData, device) -> None: """Initialize the sensor.""" super().__init__(data, device) self._data = data @@ -211,9 +211,9 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._attr_entity_picture = lock_activity.operator_thumbnail_url @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" - attributes = {} + attributes: dict[str, Any] = {} if self._operated_remote is not None: attributes[ATTR_OPERATION_REMOTE] = self._operated_remote @@ -291,7 +291,7 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): self._update_from_data() @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" self._attr_native_value = self.entity_description.value_fn(self._detail) self._attr_available = self._attr_native_value is not None diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 138887ed09e..9b4e118b83e 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -1,11 +1,11 @@ """Base class for August entity.""" - +from __future__ import annotations from abc import abstractmethod from datetime import datetime, timedelta from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval @@ -34,7 +34,7 @@ class AugustSubscriberMixin: self._subscriptions.setdefault(device_id, []).append(update_callback) - def _unsubscribe(): + def _unsubscribe() -> None: self.async_unsubscribe_device_id(device_id, update_callback) return _unsubscribe @@ -54,9 +54,10 @@ class AugustSubscriberMixin: ) @callback - def _async_cancel_update_interval(_): + def _async_cancel_update_interval(_: Event) -> None: self._stop_interval = None - self._unsub_interval() + if self._unsub_interval: + self._unsub_interval() self._stop_interval = self._hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, _async_cancel_update_interval From 72667adeba367c659e499b9919e296eea429ff48 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:24:41 +0100 Subject: [PATCH 0752/1544] Improve august typing (2) (#108327) --- homeassistant/components/august/__init__.py | 29 ++++++++++++++------- homeassistant/components/august/gateway.py | 3 ++- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 01731290e60..6c625011ee2 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, ValuesView +from collections.abc import Callable, Coroutine, Iterable, ValuesView from datetime import datetime from itertools import chain import logging -from typing import Any +from typing import Any, ParamSpec, TypeVar from aiohttp import ClientError, ClientResponseError from yalexs.const import DEFAULT_BRAND @@ -34,6 +34,9 @@ from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin from .util import async_create_august_clientsession +_R = TypeVar("_R") +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) API_CACHED_ATTRS = { @@ -360,7 +363,7 @@ class AugustData(AugustSubscriberMixin): return device.device_name return None - async def async_lock(self, device_id): + async def async_lock(self, device_id: str): """Lock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -369,7 +372,9 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_status_async(self, device_id, hyper_bridge): + async def async_status_async( + self, device_id: str, hyper_bridge: bool + ) -> str | None: """Request status of the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -379,7 +384,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_lock_async(self, device_id, hyper_bridge): + async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str | None: """Lock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -389,7 +394,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_unlock(self, device_id): + async def async_unlock(self, device_id: str): """Unlock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -398,7 +403,9 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_unlock_async(self, device_id, hyper_bridge): + async def async_unlock_async( + self, device_id: str, hyper_bridge: bool + ) -> str | None: """Unlock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -409,8 +416,12 @@ class AugustData(AugustSubscriberMixin): ) async def _async_call_api_op_requires_bridge( - self, device_id, func, *args, **kwargs - ): + self, + device_id: str, + func: Callable[_P, Coroutine[Any, Any, _R]], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> _R | None: """Call an API that requires the bridge to be online and will change the device state.""" ret = None try: diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 2527be0ee1b..a134a1429d8 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -34,6 +34,8 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" + api: ApiAsync + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" self._aiohttp_session = aiohttp_session @@ -41,7 +43,6 @@ class AugustGateway: self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass self._config: Mapping[str, Any] | None = None - self.api: ApiAsync | None = None self.authenticator: AuthenticatorAsync | None = None self.authentication: Authentication | None = None From a670ac25fd53e68b91f695688e765288e7996ce6 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Thu, 18 Jan 2024 17:36:57 -0500 Subject: [PATCH 0753/1544] Fix remote control codes for jvc_projector (#108253) Update dependency to add/fix remote codes --- homeassistant/components/jvc_projector/manifest.json | 2 +- homeassistant/components/jvc_projector/remote.py | 12 ++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/jvc_projector/test_remote.py | 8 ++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index bc01da5d89a..a7c08bb9f51 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.6"] + "requirements": ["pyjvcprojector==1.0.9"] } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index 45f797a5aaa..dcc9e5cff51 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -36,6 +36,18 @@ COMMANDS = { "lens_control": const.REMOTE_LENS_CONTROL, "setting_memory": const.REMOTE_SETTING_MEMORY, "gamma_settings": const.REMOTE_GAMMA_SETTINGS, + "hdmi_1": const.REMOTE_HDMI_1, + "hdmi_2": const.REMOTE_HDMI_2, + "mode_1": const.REMOTE_MODE_1, + "mode_2": const.REMOTE_MODE_2, + "mode_3": const.REMOTE_MODE_3, + "lens_ap": const.REMOTE_LENS_AP, + "gamma": const.REMOTE_GAMMA, + "color_temp": const.REMOTE_COLOR_TEMP, + "natural": const.REMOTE_NATURAL, + "cinema": const.REMOTE_CINEMA, + "anamo": const.REMOTE_ANAMO, + "3d_format": const.REMOTE_3D_FORMAT, } _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 71adc51d1b8..3ca4bd64f66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1858,7 +1858,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.6 +pyjvcprojector==1.0.9 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 130537b59ce..f0b527b3a33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1418,7 +1418,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.6 +pyjvcprojector==1.0.9 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py index 5505e160ca7..28bf835e032 100644 --- a/tests/components/jvc_projector/test_remote.py +++ b/tests/components/jvc_projector/test_remote.py @@ -61,6 +61,14 @@ async def test_commands( ) assert mock_device.remote.call_count == 1 + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["hdmi_1"]}, + blocking=True, + ) + assert mock_device.remote.call_count == 2 + async def test_unknown_command( hass: HomeAssistant, From 7c6fe31505fad3baddc4b01e7635f16df4c5306c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 18 Jan 2024 23:45:15 +0100 Subject: [PATCH 0754/1544] Improve api typing (#108307) --- homeassistant/components/api/__init__.py | 85 +++++++++++++++--------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index d380e0ce3ee..a9b7fc08273 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -4,6 +4,7 @@ from asyncio import shield, timeout from functools import lru_cache from http import HTTPStatus import logging +from typing import Any from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest @@ -30,7 +31,7 @@ from homeassistant.const import ( URL_API_TEMPLATE, ) import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ( InvalidEntityFormatError, InvalidStateError, @@ -92,7 +93,7 @@ class APIStatusView(HomeAssistantView): name = "api:status" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Retrieve if API is running.""" return self.json_message("API running.") @@ -124,14 +125,15 @@ class APIEventStream(HomeAssistantView): @require_admin async def get(self, request): """Provide a streaming interface for the event bus.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] stop_obj = object() - to_write = asyncio.Queue() + to_write: asyncio.Queue[object | str] = asyncio.Queue() - if restrict := request.query.get("restrict"): - restrict = restrict.split(",") + [EVENT_HOMEASSISTANT_STOP] + restrict: list[str] | None = None + if restrict_str := request.query.get("restrict"): + restrict = restrict_str.split(",") + [EVENT_HOMEASSISTANT_STOP] - async def forward_events(event): + async def forward_events(event: Event) -> None: """Forward events to the open request.""" if restrict and event.event_type not in restrict: return @@ -188,9 +190,10 @@ class APIConfigView(HomeAssistantView): name = "api:config" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current configuration.""" - return self.json(request.app["hass"].config.as_dict()) + hass: HomeAssistant = request.app["hass"] + return self.json(hass.config.as_dict()) class APIStatesView(HomeAssistantView): @@ -243,9 +246,10 @@ class APIEntityStateView(HomeAssistantView): ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) - async def post(self, request, entity_id): + async def post(self, request: web.Request, entity_id: str) -> web.Response: """Update state of entity.""" - if not request["hass_user"].is_admin: + user: User = request["hass_user"] + if not user.is_admin: raise Unauthorized(entity_id=entity_id) hass: HomeAssistant = request.app["hass"] try: @@ -275,18 +279,20 @@ class APIEntityStateView(HomeAssistantView): # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - resp = self.json(hass.states.get(entity_id).as_dict(), status_code) + assert (state := hass.states.get(entity_id)) + resp = self.json(state.as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") return resp @ha.callback - def delete(self, request, entity_id): + def delete(self, request: web.Request, entity_id: str) -> web.Response: """Remove entity.""" if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - if request.app["hass"].states.async_remove(entity_id): + hass: HomeAssistant = request.app["hass"] + if hass.states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) @@ -298,9 +304,10 @@ class APIEventListenersView(HomeAssistantView): name = "api:event-listeners" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get event listeners.""" - return self.json(async_events_json(request.app["hass"])) + hass: HomeAssistant = request.app["hass"] + return self.json(async_events_json(hass)) class APIEventView(HomeAssistantView): @@ -310,11 +317,11 @@ class APIEventView(HomeAssistantView): name = "api:event" @require_admin - async def post(self, request, event_type): + async def post(self, request: web.Request, event_type: str) -> web.Response: """Fire events.""" body = await request.text() try: - event_data = json_loads(body) if body else None + event_data: Any = json_loads(body) if body else None except ValueError: return self.json_message( "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST @@ -327,14 +334,15 @@ class APIEventView(HomeAssistantView): # Special case handling for event STATE_CHANGED # We will try to convert state dicts back to State objects - if event_type == ha.EVENT_STATE_CHANGED and event_data: + if event_type == EVENT_STATE_CHANGED and event_data: for key in ("old_state", "new_state"): - state = ha.State.from_dict(event_data.get(key)) + state = ha.State.from_dict(event_data[key]) if state: event_data[key] = state - request.app["hass"].bus.async_fire( + hass: HomeAssistant = request.app["hass"] + hass.bus.async_fire( event_type, event_data, ha.EventOrigin.remote, self.context(request) ) @@ -347,9 +355,10 @@ class APIServicesView(HomeAssistantView): url = URL_API_SERVICES name = "api:services" - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Get registered services.""" - services = await async_services_json(request.app["hass"]) + hass: HomeAssistant = request.app["hass"] + services = await async_services_json(hass) return self.json(services) @@ -359,12 +368,14 @@ class APIDomainServicesView(HomeAssistantView): url = "/api/services/{domain}/{service}" name = "api:domain-services" - async def post(self, request, domain, service): + async def post( + self, request: web.Request, domain: str, service: str + ) -> web.Response: """Call a service. Returns a list of changed states. """ - hass: ha.HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app["hass"] body = await request.text() try: data = json_loads(body) if body else None @@ -384,14 +395,20 @@ class APIDomainServicesView(HomeAssistantView): changed_states.append(state.json_fragment) cancel_listen = hass.bus.async_listen( - EVENT_STATE_CHANGED, _async_save_changed_entities, run_immediately=True + EVENT_STATE_CHANGED, + _async_save_changed_entities, # type: ignore[arg-type] + run_immediately=True, ) try: # shield the service call from cancellation on connection drop await shield( hass.services.async_call( - domain, service, data, blocking=True, context=context + domain, + service, + data, # type: ignore[arg-type] + blocking=True, + context=context, ) ) except (vol.Invalid, ServiceNotFound) as ex: @@ -409,9 +426,10 @@ class APIComponentsView(HomeAssistantView): name = "api:components" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current loaded components.""" - return self.json(request.app["hass"].config.components) + hass: HomeAssistant = request.app["hass"] + return self.json(hass.config.components) @lru_cache @@ -427,7 +445,7 @@ class APITemplateView(HomeAssistantView): name = "api:template" @require_admin - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Render a template.""" try: data = await request.json() @@ -448,17 +466,18 @@ class APIErrorLog(HomeAssistantView): @require_admin async def get(self, request): """Retrieve API error log.""" - return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) + hass: HomeAssistant = request.app["hass"] + return web.FileResponse(hass.data[DATA_LOGGING]) -async def async_services_json(hass): +async def async_services_json(hass: HomeAssistant) -> list[dict[str, Any]]: """Generate services data to JSONify.""" descriptions = await async_get_all_descriptions(hass) return [{"domain": key, "services": value} for key, value in descriptions.items()] @ha.callback -def async_events_json(hass): +def async_events_json(hass: HomeAssistant) -> list[dict[str, Any]]: """Generate event data to JSONify.""" return [ {"event": key, "listener_count": value} From 6e8d491dae794c9c2e67c847febc19a63b52648c Mon Sep 17 00:00:00 2001 From: Kostas Chatzikokolakis Date: Fri, 19 Jan 2024 01:06:11 +0200 Subject: [PATCH 0755/1544] Add iBeacon UUID allowlist (#104790) --- .../components/ibeacon/config_flow.py | 65 +++++++++- homeassistant/components/ibeacon/const.py | 1 + .../components/ibeacon/coordinator.py | 63 +++++++++- homeassistant/components/ibeacon/strings.json | 8 +- tests/components/ibeacon/test_config_flow.py | 69 ++++++++++- tests/components/ibeacon/test_coordinator.py | 111 +++++++++++++++++- 6 files changed, 308 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index f4d36c2e617..c7d6c358a29 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -2,12 +2,17 @@ from __future__ import annotations from typing import Any +from uuid import UUID + +import voluptuous as vol from homeassistant import config_entries from homeassistant.components import bluetooth +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import DOMAIN +from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -29,3 +34,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="iBeacon Tracker", data={}) return self.async_show_form(step_id="user") + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict | None = None) -> FlowResult: + """Manage the options.""" + errors = {} + + current_uuids = self.config_entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + new_uuid = None + + if user_input is not None: + if new_uuid := user_input.get("new_uuid", "").lower(): + try: + # accept non-standard formats that can be fixed by UUID + new_uuid = str(UUID(new_uuid)) + except ValueError: + errors["new_uuid"] = "invalid_uuid_format" + + if not errors: + # don't modify current_uuids in memory, cause HA will think that the new + # data is equal to the old, and will refuse to write them to disk. + updated_uuids = user_input.get("allow_nameless_uuids", []) + if new_uuid and new_uuid not in updated_uuids: + updated_uuids.append(new_uuid) + + data = {CONF_ALLOW_NAMELESS_UUIDS: list(updated_uuids)} + return self.async_create_entry(title="", data=data) + + schema = { + vol.Optional( + "new_uuid", + description={"suggested_value": new_uuid}, + ): str, + } + if current_uuids: + schema |= { + vol.Optional( + "allow_nameless_uuids", + default=current_uuids, + ): cv.multi_select(sorted(current_uuids)) + } + return self.async_show_form( + step_id="init", errors=errors, data_schema=vol.Schema(schema) + ) diff --git a/homeassistant/components/ibeacon/const.py b/homeassistant/components/ibeacon/const.py index 19b3a6f6599..041448101fa 100644 --- a/homeassistant/components/ibeacon/const.py +++ b/homeassistant/components/ibeacon/const.py @@ -46,3 +46,4 @@ MIN_SEEN_TRANSIENT_NEW = ( CONF_IGNORE_ADDRESSES = "ignore_addresses" CONF_IGNORE_UUIDS = "ignore_uuids" +CONF_ALLOW_NAMELESS_UUIDS = "allow_nameless_uuids" diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 537b4b8f860..b23ea77e013 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime +import logging import time from ibeacon_ble import ( @@ -21,6 +22,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from .const import ( + CONF_ALLOW_NAMELESS_UUIDS, CONF_IGNORE_ADDRESSES, CONF_IGNORE_UUIDS, DOMAIN, @@ -34,6 +36,8 @@ from .const import ( UPDATE_INTERVAL, ) +_LOGGER = logging.getLogger(__name__) + MONOTONIC_TIME = time.monotonic @@ -141,6 +145,16 @@ class IBeaconCoordinator: # iBeacons with random MAC addresses, fixed UUID, random major/minor self._major_minor_by_uuid: dict[str, set[tuple[int, int]]] = {} + # iBeacons from devices with no name + self._allow_nameless_uuids = set( + entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + ) + self._ignored_nameless_by_uuid: dict[str, set[str]] = {} + + self._entry.async_on_unload( + self._entry.add_update_listener(self.async_config_entry_updated) + ) + @callback def async_device_id_seen(self, device_id: str) -> bool: """Return True if the device_id has been seen since boot.""" @@ -248,6 +262,8 @@ class IBeaconCoordinator: if uuid_str in self._ignore_uuids: return + _LOGGER.debug("update beacon %s", uuid_str) + major = ibeacon_advertisement.major minor = ibeacon_advertisement.minor major_minor_by_uuid = self._major_minor_by_uuid.setdefault(uuid_str, set()) @@ -296,12 +312,24 @@ class IBeaconCoordinator: address = service_info.address unique_id = f"{group_id}_{address}" new = unique_id not in self._last_ibeacon_advertisement_by_unique_id - # Reject creating new trackers if the name is not set - if new and ( - service_info.device.name is None - or service_info.device.name.replace("-", ":") == service_info.device.address + uuid = str(ibeacon_advertisement.uuid) + + # Reject creating new trackers if the name is not set (unless the uuid is allowlisted). + if ( + new + and uuid not in self._allow_nameless_uuids + and ( + service_info.device.name is None + or service_info.device.name.replace("-", ":") + == service_info.device.address + ) ): + # Store the ignored addresses, cause the uuid might be allowlisted later + self._ignored_nameless_by_uuid.setdefault(uuid, set()).add(address) + + _LOGGER.debug("ignoring new beacon %s due to empty device name", unique_id) return + previously_tracked = address in self._unique_ids_by_address self._last_ibeacon_advertisement_by_unique_id[unique_id] = ibeacon_advertisement self._async_track_ibeacon_with_unique_address(address, group_id, unique_id) @@ -428,6 +456,33 @@ class IBeaconCoordinator: ibeacon_advertisement, ) + async def async_config_entry_updated( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Restore ignored nameless beacons when the allowlist is updated.""" + + self._allow_nameless_uuids = set( + self._entry.options.get(CONF_ALLOW_NAMELESS_UUIDS, []) + ) + + for uuid in self._allow_nameless_uuids: + for address in self._ignored_nameless_by_uuid.pop(uuid, set()): + _LOGGER.debug( + "restoring nameless iBeacon %s from address %s", uuid, address + ) + + if not ( + service_info := bluetooth.async_last_service_info( + self.hass, address, connectable=False + ) + ): + continue # no longer available + + # the beacon was ignored, we need to re-process it from scratch + self._async_update_ibeacon( + service_info, bluetooth.BluetoothChange.ADVERTISEMENT + ) + @callback def _async_update(self, _now: datetime) -> None: """Update the Coordinator.""" diff --git a/homeassistant/components/ibeacon/strings.json b/homeassistant/components/ibeacon/strings.json index be3f7020cbe..440df8292a9 100644 --- a/homeassistant/components/ibeacon/strings.json +++ b/homeassistant/components/ibeacon/strings.json @@ -13,11 +13,15 @@ "options": { "step": { "init": { - "description": "iBeacons with an RSSI value lower than the Minimum RSSI will be ignored. If the integration is seeing neighboring iBeacons, increasing this value may help.", + "description": "iBeacons with an empty device name are ignored by default, unless their UUID is explicitly allowed in the list below.", "data": { - "min_rssi": "Minimum RSSI" + "new_uuid": "Enter a new allowed UUID", + "allow_nameless_uuids": "Currently allowed UUIDs. Uncheck to remove" } } + }, + "error": { + "invalid_uuid_format": "UUIDs should contain 32 hex characters grouped as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" } }, "entity": { diff --git a/tests/components/ibeacon/test_config_flow.py b/tests/components/ibeacon/test_config_flow.py index 7dee1b5c709..2f79474dea7 100644 --- a/tests/components/ibeacon/test_config_flow.py +++ b/tests/components/ibeacon/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.ibeacon.const import DOMAIN +from homeassistant.components.ibeacon.const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -49,3 +49,70 @@ async def test_setup_user_already_setup( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + # test save invalid uuid + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "new_uuid": "invalid", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"new_uuid": "invalid_uuid_format"} + + # test save new uuid + uuid = "daa4b6bb-b77a-4662-aeb8-b3ed56454091" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "new_uuid": uuid, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: [uuid]} + + # test save duplicate uuid + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ALLOW_NAMELESS_UUIDS: [uuid], + "new_uuid": uuid, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: [uuid]} + + # delete + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ALLOW_NAMELESS_UUIDS: [], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_ALLOW_NAMELESS_UUIDS: []} diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 3c9beaf396d..372907307a7 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -4,7 +4,15 @@ import time import pytest -from homeassistant.components.ibeacon.const import ATTR_SOURCE, DOMAIN, UPDATE_INTERVAL +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.components.ibeacon.const import ( + ATTR_SOURCE, + CONF_ALLOW_NAMELESS_UUIDS, + DOMAIN, + UPDATE_INTERVAL, +) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo @@ -26,6 +34,7 @@ from tests.components.bluetooth import ( inject_advertisement_with_time_and_source_connectable, inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -146,6 +155,106 @@ async def test_ignore_default_name(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids()) == before_entity_count +async def test_default_name_allowlisted(hass: HomeAssistant) -> None: + """Test we do NOT ignore beacons with default device name but allowlisted UUID.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={CONF_ALLOW_NAMELESS_UUIDS: ["426c7565-4368-6172-6d42-6561636f6e73"]}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) > before_entity_count + + +async def test_default_name_allowlisted_restore(hass: HomeAssistant) -> None: + """Test that ignored nameless iBeacons are restored when allowlist entry is added.""" + 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() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"new_uuid": "426c7565-4368-6172-6d42-6561636f6e73"}, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) > before_entity_count + + +async def test_default_name_allowlisted_restore_late(hass: HomeAssistant) -> None: + """Test that allowlisting an ignored but no longer advertised nameless iBeacon has no effect.""" + start_monotonic = time.monotonic() + + 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() + + before_entity_count = len(hass.states.async_entity_ids()) + inject_bluetooth_service_info( + hass, + replace( + BLUECHARM_BEACON_SERVICE_INFO_DBUS, + name=BLUECHARM_BEACON_SERVICE_INFO_DBUS.address, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + # Fastforward time until the device is no longer advertised + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch_bluetooth_time( + monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"new_uuid": "426c7565-4368-6172-6d42-6561636f6e73"}, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids()) == before_entity_count + + async def test_rotating_major_minor_and_mac_with_name(hass: HomeAssistant) -> None: """Test the different uuid, major, minor from many addresses removes all associated entities.""" entry = MockConfigEntry( From 6e8e14fbe219af0071a4034d8c591f09e8a98281 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 00:14:49 +0100 Subject: [PATCH 0756/1544] Improve august typing (3) (#108329) --- homeassistant/components/august/__init__.py | 12 ++++++------ homeassistant/components/august/camera.py | 2 +- homeassistant/components/august/entity.py | 2 +- homeassistant/components/august/lock.py | 2 +- homeassistant/components/august/sensor.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 6c625011ee2..eb54a488831 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -140,7 +140,7 @@ class AugustData(AugustSubscriberMixin): self._config_entry = config_entry self._hass = hass self._august_gateway = august_gateway - self.activity_stream: ActivityStream | None = None + self.activity_stream: ActivityStream = None # type: ignore[assignment] self._api = august_gateway.api self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {} self._doorbells_by_id: dict[str, Doorbell] = {} @@ -153,7 +153,7 @@ class AugustData(AugustSubscriberMixin): """Brand of the device.""" return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND) - async def async_setup(self): + async def async_setup(self) -> None: """Async setup of august device data and activities.""" token = self._august_gateway.access_token # This used to be a gather but it was less reliable with august's recent api changes. @@ -248,16 +248,16 @@ class AugustData(AugustSubscriberMixin): device = self.get_device_detail(device_id) activities = activities_from_pubnub_message(device, date_time, message) activity_stream = self.activity_stream - assert activity_stream is not None if activities: activity_stream.async_process_newer_device_activities(activities) self.async_signal_device_id_update(device.device_id) activity_stream.async_schedule_house_id_refresh(device.house_id) @callback - def async_stop(self): + def async_stop(self) -> None: """Stop the subscriptions.""" - self._pubnub_unsub() + if self._pubnub_unsub: + self._pubnub_unsub() self.activity_stream.async_stop() @property @@ -303,7 +303,7 @@ class AugustData(AugustSubscriberMixin): exc_info=err, ) - async def _async_refresh_device_detail_by_id(self, device_id): + async def _async_refresh_device_detail_by_id(self, device_id: str) -> None: if device_id in self._locks_by_id: if self.activity_stream and self.activity_stream.pubnub.connected: saved_attrs = _save_live_attrs(self._device_detail_by_id[device_id]) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 904ef316e5b..17c9f91c566 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -63,7 +63,7 @@ class AugustCamera(AugustEntityMixin, Camera): return self._detail.model @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor.""" doorbell_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 787b99c34ac..ce32157e1e4 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -63,7 +63,7 @@ class AugustEntityMixin(Entity): def _update_from_data(self) -> None: """Update the entity state from the data object.""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to updates.""" self.async_on_remove( self._data.async_subscribe_device_id( diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 31a4c24092b..b0951a42259 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -91,7 +91,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return False @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" activity_stream = self._data.activity_stream device_id = self._device_id diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 94b883dba0d..babf8cda072 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -194,7 +194,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._update_from_data() @callback - def _update_from_data(self): + def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" lock_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, {ActivityType.LOCK_OPERATION} From 0206833cfd5a53892fe18dc32506f287bc46115a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 00:59:26 +0100 Subject: [PATCH 0757/1544] Improve august typing (4) (#108331) --- homeassistant/components/august/gateway.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index a134a1429d8..63bc085b811 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -35,19 +35,19 @@ class AugustGateway: """Handle the connection to August.""" api: ApiAsync + authenticator: AuthenticatorAsync + authentication: Authentication + _access_token_cache_file: str def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() - self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass self._config: Mapping[str, Any] | None = None - self.authenticator: AuthenticatorAsync | None = None - self.authentication: Authentication | None = None @property - def access_token(self): + def access_token(self) -> str: """Access token for the api.""" return self.authentication.access_token @@ -98,9 +98,8 @@ class AugustGateway: await self.authenticator.async_setup_authentication() - async def async_authenticate(self): + async def async_authenticate(self) -> Authentication: """Authenticate with the details provided to setup.""" - self.authentication = None try: self.authentication = await self.authenticator.async_authenticate() if self.authentication.state == AuthenticationState.AUTHENTICATED: @@ -137,13 +136,13 @@ class AugustGateway: """Remove the cache file.""" await self._hass.async_add_executor_job(self._reset_authentication) - def _reset_authentication(self): + def _reset_authentication(self) -> None: """Remove the cache file.""" path = self._hass.config.path(self._access_token_cache_file) if os.path.exists(path): os.unlink(path) - async def async_refresh_access_token_if_needed(self): + async def async_refresh_access_token_if_needed(self) -> None: """Refresh the august access token if needed.""" if not self.authenticator.should_refresh(): return From 94c8c71ffb208e9b2c5e8b173b87ddb9f79a35b4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 01:09:52 +0100 Subject: [PATCH 0758/1544] Improve august typing (5) (#108332) --- homeassistant/components/august/__init__.py | 34 ++++++++++++--------- homeassistant/components/august/camera.py | 2 +- homeassistant/components/august/entity.py | 4 +-- homeassistant/components/august/lock.py | 9 ++++-- homeassistant/components/august/sensor.py | 17 ++++++----- 5 files changed, 38 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index eb54a488831..624121b8828 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -9,6 +9,7 @@ import logging from typing import Any, ParamSpec, TypeVar from aiohttp import ClientError, ClientResponseError +from yalexs.activity import ActivityTypes from yalexs.const import DEFAULT_BRAND from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.exceptions import AugustApiAIOHTTPError @@ -274,7 +275,7 @@ class AugustData(AugustSubscriberMixin): """Return the py-august LockDetail or DoorbellDetail object for a device.""" return self._device_detail_by_id[device_id] - async def _async_refresh(self, time): + async def _async_refresh(self, time: datetime) -> None: await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) async def _async_refresh_device_detail_by_ids( @@ -329,7 +330,13 @@ class AugustData(AugustSubscriberMixin): ) self.async_signal_device_id_update(device_id) - async def _async_update_device_detail(self, device, api_call): + async def _async_update_device_detail( + self, + device: Doorbell | Lock, + api_call: Callable[ + [str, str], Coroutine[Any, Any, DoorbellDetail | LockDetail] + ], + ) -> None: _LOGGER.debug( "Started retrieving detail for %s (%s)", device.device_name, @@ -363,7 +370,7 @@ class AugustData(AugustSubscriberMixin): return device.device_name return None - async def async_lock(self, device_id: str): + async def async_lock(self, device_id: str) -> list[ActivityTypes]: """Lock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -372,9 +379,7 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_status_async( - self, device_id: str, hyper_bridge: bool - ) -> str | None: + async def async_status_async(self, device_id: str, hyper_bridge: bool) -> str: """Request status of the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -384,7 +389,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str | None: + async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str: """Lock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -394,7 +399,7 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) - async def async_unlock(self, device_id: str): + async def async_unlock(self, device_id: str) -> list[ActivityTypes]: """Unlock the device.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -403,9 +408,7 @@ class AugustData(AugustSubscriberMixin): device_id, ) - async def async_unlock_async( - self, device_id: str, hyper_bridge: bool - ) -> str | None: + async def async_unlock_async(self, device_id: str, hyper_bridge: bool) -> str: """Unlock the device but do not wait for a response since it will come via pubnub.""" return await self._async_call_api_op_requires_bridge( device_id, @@ -421,9 +424,8 @@ class AugustData(AugustSubscriberMixin): func: Callable[_P, Coroutine[Any, Any, _R]], *args: _P.args, **kwargs: _P.kwargs, - ) -> _R | None: + ) -> _R: """Call an API that requires the bridge to be online and will change the device state.""" - ret = None try: ret = await func(*args, **kwargs) except AugustApiAIOHTTPError as err: @@ -479,7 +481,7 @@ class AugustData(AugustSubscriberMixin): del self._locks_by_id[device_id] -def _save_live_attrs(lock_detail): +def _save_live_attrs(lock_detail: DoorbellDetail | LockDetail) -> dict[str, Any]: """Store the attributes that the lock detail api may have an invalid cache for. Since we are connected to pubnub we may have more current data @@ -489,7 +491,9 @@ def _save_live_attrs(lock_detail): return {attr: getattr(lock_detail, attr) for attr in API_CACHED_ATTRS} -def _restore_live_attrs(lock_detail, attrs): +def _restore_live_attrs( + lock_detail: DoorbellDetail | LockDetail, attrs: dict[str, Any] +) -> None: """Restore the non-cache attributes after a cached update.""" for attr, value in attrs.items(): setattr(lock_detail, attr, value) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 17c9f91c566..e5835a69e07 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -58,7 +58,7 @@ class AugustCamera(AugustEntityMixin, Camera): return self._device.has_subscription @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._detail.model diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index ce32157e1e4..bcd2c6e2503 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -1,7 +1,7 @@ """Base class for August entity.""" from abc import abstractmethod -from yalexs.doorbell import Doorbell +from yalexs.doorbell import Doorbell, DoorbellDetail from yalexs.lock import Lock, LockDetail from yalexs.util import get_configuration_url @@ -46,7 +46,7 @@ class AugustEntityMixin(Entity): return self._device.device_id @property - def _detail(self): + def _detail(self) -> DoorbellDetail | LockDetail: return self._data.get_device_detail(self._device.device_id) @property diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index b0951a42259..93e0de018b0 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,9 +1,12 @@ """Support for August lock.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine import logging from typing import Any from aiohttp import ClientResponseError -from yalexs.activity import SOURCE_PUBNUB, ActivityType +from yalexs.activity import SOURCE_PUBNUB, ActivityType, ActivityTypes from yalexs.lock import Lock, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity @@ -62,7 +65,9 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return await self._call_lock_operation(self._data.async_unlock) - async def _call_lock_operation(self, lock_operation): + async def _call_lock_operation( + self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]] + ) -> None: try: activities = await lock_operation(self._device_id) except ClientResponseError as err: diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index babf8cda072..2cf0bb36d08 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, cast -from yalexs.activity import ActivityType +from yalexs.activity import ActivityType, LockOperationActivity from yalexs.doorbell import Doorbell from yalexs.keypad import KeypadDetail from yalexs.lock import Lock, LockDetail @@ -158,7 +158,7 @@ async def async_setup_entry( async_add_entities(entities) -async def _async_migrate_old_unique_ids(hass, devices): +async def _async_migrate_old_unique_ids(hass: HomeAssistant, devices) -> None: """Keypads now have their own serial number.""" registry = er.async_get(hass) for device in devices: @@ -184,11 +184,11 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): super().__init__(data, device) self._data = data self._device = device - self._operated_remote = None - self._operated_keypad = None - self._operated_manual = None - self._operated_tag = None - self._operated_autorelock = None + self._operated_remote: bool | None = None + self._operated_keypad: bool | None = None + self._operated_manual: bool | None = None + self._operated_tag: bool | None = None + self._operated_autorelock: bool | None = None self._operated_time = None self._attr_unique_id = f"{self._device_id}_lock_operator" self._update_from_data() @@ -202,6 +202,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._attr_available = True if lock_activity is not None: + lock_activity = cast(LockOperationActivity, lock_activity) self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad From a21d5b58584616a4e89057c99c989aa3e2e0d812 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 01:11:55 +0100 Subject: [PATCH 0759/1544] Improve person typing (#108218) --- homeassistant/components/person/__init__.py | 66 ++++++++++++--------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index f6444a869ee..3a7db248862 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -1,8 +1,9 @@ """Support for tracking people.""" from __future__ import annotations +from collections.abc import Callable import logging -from typing import Any +from typing import Any, Self import voluptuous as vol @@ -46,10 +47,13 @@ from homeassistant.helpers import ( service, ) from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -112,7 +116,7 @@ async def async_create_person( @bind_hass async def async_add_user_device_tracker( hass: HomeAssistant, user_id: str, device_tracker_entity_id: str -): +) -> None: """Add a device tracker to a person linked to a user.""" coll: PersonStorageCollection = hass.data[DOMAIN][1] @@ -187,7 +191,9 @@ UPDATE_FIELDS = { class PersonStore(Store): """Person storage.""" - async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: """Migrate to the new version. Migrate storage to use format of collection helper. @@ -281,14 +287,14 @@ class PersonStorageCollection(collection.DictStorageCollection): """Return a new updated data object.""" update_data = self.UPDATE_SCHEMA(update_data) - user_id = update_data.get(CONF_USER_ID) + user_id: str | None = update_data.get(CONF_USER_ID) if user_id is not None and user_id != item.get(CONF_USER_ID): await self._validate_user_id(user_id) return {**item, **update_data} - async def _validate_user_id(self, user_id): + async def _validate_user_id(self, user_id: str) -> None: """Validate the used user_id.""" if await self.hass.auth.async_get_user(user_id) is None: raise ValueError("User does not exist") @@ -402,32 +408,32 @@ class Person(collection.CollectionEntity, RestoreEntity): _attr_should_poll = False editable: bool - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Set up person.""" self._config = config - self._latitude = None - self._longitude = None - self._gps_accuracy = None - self._source = None - self._state = None - self._unsub_track_device = None + self._latitude: float | None = None + self._longitude: float | None = None + self._gps_accuracy: float | None = None + self._source: str | None = None + self._state: str | None = None + self._unsub_track_device: Callable[[], None] | None = None @classmethod - def from_storage(cls, config: ConfigType): + def from_storage(cls, config: ConfigType) -> Self: """Return entity instance initialized from storage.""" person = cls(config) person.editable = True return person @classmethod - def from_yaml(cls, config: ConfigType): + def from_yaml(cls, config: ConfigType) -> Self: """Return entity instance initialized from yaml.""" person = cls(config) person.editable = False return person @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._config[CONF_NAME] @@ -437,14 +443,14 @@ class Person(collection.CollectionEntity, RestoreEntity): return self._config.get(CONF_PICTURE) @property - def state(self): + def state(self) -> str | None: """Return the state of the person.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the person.""" - data = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id} + data: dict[str, Any] = {ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id} if self._latitude is not None: data[ATTR_LATITUDE] = self._latitude if self._longitude is not None: @@ -459,16 +465,16 @@ class Person(collection.CollectionEntity, RestoreEntity): return data @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID for the person.""" return self._config[CONF_ID] @property - def device_trackers(self): + def device_trackers(self) -> list[str]: """Return the device trackers for the person.""" return self._config[CONF_DEVICE_TRACKERS] - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register device trackers.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): @@ -480,14 +486,14 @@ class Person(collection.CollectionEntity, RestoreEntity): else: # Wait for hass start to not have race between person # and device trackers finishing setup. - async def person_start_hass(now): + async def person_start_hass(_: Event) -> None: await self.async_update_config(self._config) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, person_start_hass ) - async def async_update_config(self, config: ConfigType): + async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config @@ -505,12 +511,14 @@ class Person(collection.CollectionEntity, RestoreEntity): self._update_state() @callback - def _async_handle_tracker_update(self, event): + def _async_handle_tracker_update( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle the device tracker state changes.""" self._update_state() @callback - def _update_state(self): + def _update_state(self) -> None: """Update the state.""" latest_non_gps_home = latest_not_home = latest_gps = latest = None for entity_id in self._config[CONF_DEVICE_TRACKERS]: @@ -545,7 +553,7 @@ class Person(collection.CollectionEntity, RestoreEntity): self.async_write_ha_state() @callback - def _parse_source_state(self, state): + def _parse_source_state(self, state: State) -> None: """Parse source state and set person attributes. This is a device tracker state or the restored person state. @@ -570,7 +578,7 @@ def ws_list_person( ) -def _get_latest(prev: State | None, curr: State): +def _get_latest(prev: State | None, curr: State) -> State: """Get latest state.""" if prev is None or curr.last_updated > prev.last_updated: return curr From 25b7bb4a4f2777354236acc09a321518b1914fc0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 01:12:14 +0100 Subject: [PATCH 0760/1544] Adjust require_admin decorator typing (#108306) Co-authored-by: J. Nick Koston --- homeassistant/components/api/__init__.py | 4 +-- homeassistant/components/http/decorators.py | 33 +++++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a9b7fc08273..8a5e1f0b0e0 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -123,7 +123,7 @@ class APIEventStream(HomeAssistantView): name = "api:stream" @require_admin - async def get(self, request): + async def get(self, request: web.Request) -> web.StreamResponse: """Provide a streaming interface for the event bus.""" hass: HomeAssistant = request.app["hass"] stop_obj = object() @@ -464,7 +464,7 @@ class APIErrorLog(HomeAssistantView): name = "api:error_log" @require_admin - async def get(self, request): + async def get(self, request: web.Request) -> web.FileResponse: """Retrieve API error log.""" hass: HomeAssistant = request.app["hass"] return web.FileResponse(hass.data[DATA_LOGGING]) diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index 4d8ac5c2df5..b2e8e535fd2 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -5,16 +5,18 @@ from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar, overload -from aiohttp.web import Request, Response +from aiohttp.web import Request, Response, StreamResponse +from homeassistant.auth.models import User from homeassistant.exceptions import Unauthorized from .view import HomeAssistantView _HomeAssistantViewT = TypeVar("_HomeAssistantViewT", bound=HomeAssistantView) +_ResponseT = TypeVar("_ResponseT", bound=Response | StreamResponse) _P = ParamSpec("_P") _FuncType = Callable[ - Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, Response] + Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, _ResponseT] ] @@ -23,30 +25,36 @@ def require_admin( _func: None = None, *, error: Unauthorized | None = None, -) -> Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]]: +) -> Callable[ + [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], + _FuncType[_HomeAssistantViewT, _P, _ResponseT], +]: ... @overload def require_admin( - _func: _FuncType[_HomeAssistantViewT, _P], -) -> _FuncType[_HomeAssistantViewT, _P]: + _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT], +) -> _FuncType[_HomeAssistantViewT, _P, _ResponseT]: ... def require_admin( - _func: _FuncType[_HomeAssistantViewT, _P] | None = None, + _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT] | None = None, *, error: Unauthorized | None = None, ) -> ( - Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]] - | _FuncType[_HomeAssistantViewT, _P] + Callable[ + [_FuncType[_HomeAssistantViewT, _P, _ResponseT]], + _FuncType[_HomeAssistantViewT, _P, _ResponseT], + ] + | _FuncType[_HomeAssistantViewT, _P, _ResponseT] ): """Home Assistant API decorator to require user to be an admin.""" def decorator_require_admin( - func: _FuncType[_HomeAssistantViewT, _P], - ) -> _FuncType[_HomeAssistantViewT, _P]: + func: _FuncType[_HomeAssistantViewT, _P, _ResponseT], + ) -> _FuncType[_HomeAssistantViewT, _P, _ResponseT]: """Wrap the provided with_admin function.""" @wraps(func) @@ -55,9 +63,10 @@ def require_admin( request: Request, *args: _P.args, **kwargs: _P.kwargs, - ) -> Response: + ) -> _ResponseT: """Check admin and call function.""" - if not request["hass_user"].is_admin: + user: User = request["hass_user"] + if not user.is_admin: raise error or Unauthorized() return await func(self, request, *args, **kwargs) From bc2acb3c0e6562d4515e4593b5f2343b25d97d4d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 08:46:34 +0100 Subject: [PATCH 0761/1544] Improve ffmpeg* typing (#108092) --- homeassistant/components/ffmpeg/__init__.py | 51 ++++++++++--------- homeassistant/components/ffmpeg/camera.py | 4 +- .../components/ffmpeg_motion/binary_sensor.py | 42 ++++++++------- .../components/ffmpeg_noise/binary_sensor.py | 25 +++++---- tests/components/ffmpeg/test_init.py | 2 +- 5 files changed, 71 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index a98766c78c6..4ab4ee32a09 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio import re +from typing import Generic, TypeVar +from haffmpeg.core import HAFFmpeg from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame import voluptuous as vol @@ -13,9 +15,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( + SignalType, async_dispatcher_connect, async_dispatcher_send, ) @@ -23,15 +26,17 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) + DOMAIN = "ffmpeg" SERVICE_START = "start" SERVICE_STOP = "stop" SERVICE_RESTART = "restart" -SIGNAL_FFMPEG_START = "ffmpeg.start" -SIGNAL_FFMPEG_STOP = "ffmpeg.stop" -SIGNAL_FFMPEG_RESTART = "ffmpeg.restart" +SIGNAL_FFMPEG_START = SignalType[list[str] | None]("ffmpeg.start") +SIGNAL_FFMPEG_STOP = SignalType[list[str] | None]("ffmpeg.stop") +SIGNAL_FFMPEG_RESTART = SignalType[list[str] | None]("ffmpeg.restart") DATA_FFMPEG = "ffmpeg" @@ -66,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Register service async def async_service_handle(service: ServiceCall) -> None: """Handle service ffmpeg process.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) + entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID) if service.service == SERVICE_START: async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids) @@ -128,20 +133,20 @@ async def async_get_image( class FFmpegManager: """Helper for ha-ffmpeg.""" - def __init__(self, hass, ffmpeg_bin): + def __init__(self, hass: HomeAssistant, ffmpeg_bin: str) -> None: """Initialize helper.""" self.hass = hass - self._cache = {} + self._cache = {} # type: ignore[var-annotated] self._bin = ffmpeg_bin - self._version = None - self._major_version = None + self._version: str | None = None + self._major_version: int | None = None @property - def binary(self): + def binary(self) -> str: """Return ffmpeg binary from config.""" return self._bin - async def async_get_version(self): + async def async_get_version(self) -> tuple[str | None, int | None]: """Return ffmpeg version.""" ffversion = FFVersion(self._bin) @@ -156,7 +161,7 @@ class FFmpegManager: return self._version, self._major_version @property - def ffmpeg_stream_content_type(self): + def ffmpeg_stream_content_type(self) -> str: """Return HTTP content type for ffmpeg stream.""" if self._major_version is not None and self._major_version > 3: return CONTENT_TYPE_MULTIPART.format("ffmpeg") @@ -164,17 +169,17 @@ class FFmpegManager: return CONTENT_TYPE_MULTIPART.format("ffserver") -class FFmpegBase(Entity): +class FFmpegBase(Entity, Generic[_HAFFmpegT]): """Interface object for FFmpeg.""" _attr_should_poll = False - def __init__(self, initial_state=True): + def __init__(self, ffmpeg: _HAFFmpegT, initial_state: bool = True) -> None: """Initialize ffmpeg base object.""" - self.ffmpeg = None + self.ffmpeg = ffmpeg self.initial_state = initial_state - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register dispatcher & events. This method is a coroutine. @@ -199,18 +204,18 @@ class FFmpegBase(Entity): self._async_register_events() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self.ffmpeg.is_running - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg process. This method is a coroutine. """ raise NotImplementedError() - async def _async_stop_ffmpeg(self, entity_ids): + async def _async_stop_ffmpeg(self, entity_ids: list[str] | None) -> None: """Stop a FFmpeg process. This method is a coroutine. @@ -218,7 +223,7 @@ class FFmpegBase(Entity): if entity_ids is None or self.entity_id in entity_ids: await self.ffmpeg.close() - async def _async_restart_ffmpeg(self, entity_ids): + async def _async_restart_ffmpeg(self, entity_ids: list[str] | None) -> None: """Stop a FFmpeg process. This method is a coroutine. @@ -228,10 +233,10 @@ class FFmpegBase(Entity): await self._async_start_ffmpeg(None) @callback - def _async_register_events(self): + def _async_register_events(self) -> None: """Register a FFmpeg process/device.""" - async def async_shutdown_handle(event): + async def async_shutdown_handle(event: Event) -> None: """Stop FFmpeg process.""" await self._async_stop_ffmpeg(None) @@ -241,7 +246,7 @@ class FFmpegBase(Entity): if not self.initial_state: return - async def async_start_handle(event): + async def async_start_handle(event: Event) -> None: """Start FFmpeg process.""" await self._async_start_ffmpeg(None) self.async_write_ha_state() diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index b3e9e3f909f..884629c8ae6 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -60,7 +60,7 @@ class FFmpegCamera(Camera): self._input: str = config[CONF_INPUT] self._extra_arguments: str = config[CONF_EXTRA_ARGUMENTS] - async def stream_source(self): + async def stream_source(self) -> str: """Return the stream source.""" return self._input.split(" ")[-1] @@ -95,6 +95,6 @@ class FFmpegCamera(Camera): await stream.close() @property - def name(self): + def name(self) -> str: """Return the name of this camera.""" return self._name diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index c3603f74a5a..b982d944c6a 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -1,6 +1,9 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" from __future__ import annotations +from typing import Any, TypeVar + +from haffmpeg.core import HAFFmpeg import haffmpeg.sensor as ffmpeg_sensor import voluptuous as vol @@ -14,6 +17,7 @@ from homeassistant.components.ffmpeg import ( CONF_INITIAL_STATE, CONF_INPUT, FFmpegBase, + FFmpegManager, get_ffmpeg_manager, ) from homeassistant.const import CONF_NAME, CONF_REPEAT @@ -22,6 +26,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) + CONF_RESET = "reset" CONF_CHANGES = "changes" CONF_REPEAT_TIME = "repeat_time" @@ -63,43 +69,45 @@ async def async_setup_platform( async_add_entities([entity]) -class FFmpegBinarySensor(FFmpegBase, BinarySensorEntity): +class FFmpegBinarySensor(FFmpegBase[_HAFFmpegT], BinarySensorEntity): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, config): + def __init__(self, ffmpeg: _HAFFmpegT, config: dict[str, Any]) -> None: """Init for the binary sensor noise detection.""" - super().__init__(config.get(CONF_INITIAL_STATE)) + super().__init__(ffmpeg, config[CONF_INITIAL_STATE]) - self._state = False + self._state: bool | None = False self._config = config - self._name = config.get(CONF_NAME) + self._name: str = config[CONF_NAME] @callback - def _async_callback(self, state): + def _async_callback(self, state: bool | None) -> None: """HA-FFmpeg callback for noise detection.""" self._state = state self.async_write_ha_state() @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._state @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._name -class FFmpegMotion(FFmpegBinarySensor): +class FFmpegMotion(FFmpegBinarySensor[ffmpeg_sensor.SensorMotion]): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, hass, manager, config): + def __init__( + self, hass: HomeAssistant, manager: FFmpegManager, config: dict[str, Any] + ) -> None: """Initialize FFmpeg motion binary sensor.""" - super().__init__(config) - self.ffmpeg = ffmpeg_sensor.SensorMotion(manager.binary, self._async_callback) + ffmpeg = ffmpeg_sensor.SensorMotion(manager.binary, self._async_callback) + super().__init__(ffmpeg, config) - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg instance. This method is a coroutine. @@ -109,19 +117,19 @@ class FFmpegMotion(FFmpegBinarySensor): # init config self.ffmpeg.set_options( - time_reset=self._config.get(CONF_RESET), + time_reset=self._config[CONF_RESET], time_repeat=self._config.get(CONF_REPEAT_TIME, 0), repeat=self._config.get(CONF_REPEAT, 0), - changes=self._config.get(CONF_CHANGES), + changes=self._config[CONF_CHANGES], ) # run await self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), + input_source=self._config[CONF_INPUT], extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor, from DEVICE_CLASSES.""" return BinarySensorDeviceClass.MOTION diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index a7493930a48..a802868334d 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -1,6 +1,8 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" from __future__ import annotations +from typing import Any + import haffmpeg.sensor as ffmpeg_sensor import voluptuous as vol @@ -13,6 +15,7 @@ from homeassistant.components.ffmpeg import ( CONF_INITIAL_STATE, CONF_INPUT, CONF_OUTPUT, + FFmpegManager, get_ffmpeg_manager, ) from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor @@ -59,16 +62,18 @@ async def async_setup_platform( async_add_entities([entity]) -class FFmpegNoise(FFmpegBinarySensor): +class FFmpegNoise(FFmpegBinarySensor[ffmpeg_sensor.SensorNoise]): """A binary sensor which use FFmpeg for noise detection.""" - def __init__(self, hass, manager, config): + def __init__( + self, hass: HomeAssistant, manager: FFmpegManager, config: dict[str, Any] + ) -> None: """Initialize FFmpeg noise binary sensor.""" - super().__init__(config) - self.ffmpeg = ffmpeg_sensor.SensorNoise(manager.binary, self._async_callback) + ffmpeg = ffmpeg_sensor.SensorNoise(manager.binary, self._async_callback) + super().__init__(ffmpeg, config) - async def _async_start_ffmpeg(self, entity_ids): + async def _async_start_ffmpeg(self, entity_ids: list[str] | None) -> None: """Start a FFmpeg instance. This method is a coroutine. @@ -77,18 +82,18 @@ class FFmpegNoise(FFmpegBinarySensor): return self.ffmpeg.set_options( - time_duration=self._config.get(CONF_DURATION), - time_reset=self._config.get(CONF_RESET), - peak=self._config.get(CONF_PEAK), + time_duration=self._config[CONF_DURATION], + time_reset=self._config[CONF_RESET], + peak=self._config[CONF_PEAK], ) await self.ffmpeg.open_sensor( - input_source=self._config.get(CONF_INPUT), + input_source=self._config[CONF_INPUT], output_dest=self._config.get(CONF_OUTPUT), extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass: """Return the class of this sensor, from DEVICE_CLASSES.""" return BinarySensorDeviceClass.SOUND diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 0c6ce300d01..9a88ef242e8 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -54,7 +54,7 @@ class MockFFmpegDev(ffmpeg.FFmpegBase): def __init__(self, hass, initial_state=True, entity_id="test.ffmpeg_device"): """Initialize mock.""" - super().__init__(initial_state) + super().__init__(None, initial_state) self.hass = hass self.entity_id = entity_id From 54f23ff143adbed6a6700d39a0919b17482cd1fe Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 19 Jan 2024 11:23:04 +0100 Subject: [PATCH 0762/1544] Bump plugwise to v0.36.3 (#108347) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 3476360082a..9b898305899 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.36.2"], + "requirements": ["plugwise==0.36.3"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ca4bd64f66..9412e6d1382 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1511,7 +1511,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.36.2 +plugwise==0.36.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0b527b3a33..e4ba179df47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1170,7 +1170,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.36.2 +plugwise==0.36.3 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 4abf286a961f81ec680fb6e6d13ae73856c3b846 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 19 Jan 2024 11:25:51 +0100 Subject: [PATCH 0763/1544] Bump pyDuotecno to 2024.1.2 (#108314) --- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 9f6d082cae8..7b33784a612 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2024.1.1"] + "requirements": ["pyDuotecno==2024.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9412e6d1382..ba1ad841ba2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1611,7 +1611,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.1.1 +pyDuotecno==2024.1.2 # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4ba179df47..d4f7465f2b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1249,7 +1249,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.1.1 +pyDuotecno==2024.1.2 # homeassistant.components.electrasmart pyElectra==1.2.0 From 6d979d21a664116bd623e1522780d4d789d8eaaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 00:36:48 -1000 Subject: [PATCH 0764/1544] Bump orjson to 3.9.12 (#108350) --- 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 1c9876c6499..85db3c67ec6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ janus==1.0.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.10 +orjson==3.9.12 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.1.0 diff --git a/pyproject.toml b/pyproject.toml index 4e2dfb9a77e..7fcdeac3fb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "cryptography==41.0.7", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.10", + "orjson==3.9.12", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index fb6beb22b4a..948811d4940 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ lru-dict==1.3.0 PyJWT==2.8.0 cryptography==41.0.7 pyOpenSSL==23.2.0 -orjson==3.9.10 +orjson==3.9.12 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From 3f5f1bc2f6226105eacc3ae4c2b7f07db6765df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 19 Jan 2024 13:05:55 +0100 Subject: [PATCH 0765/1544] Fix homekit_controller test (#108375) --- tests/components/homekit_controller/test_connection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 08169c006ae..35b88c1abbe 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -11,7 +11,7 @@ from homeassistant.components.homekit_controller.const import ( IDENTIFIER_LEGACY_ACCESSORY_ID, IDENTIFIER_LEGACY_SERIAL_NUMBER, ) -from homeassistant.components.thread import async_add_dataset +from homeassistant.components.thread import async_add_dataset, dataset_store from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -225,6 +225,9 @@ async def test_thread_provision(hass: HomeAssistant) -> None: "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8", ) + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + store.preferred_dataset = dataset_id accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") From 78d7562b41f98664b8fe6d8eb9db3d7ad0dd0db3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 02:16:05 -1000 Subject: [PATCH 0766/1544] Avoid json default fallback for area registry (#108358) --- homeassistant/components/config/area_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 88f619ee349..e9cdc523686 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -131,7 +131,7 @@ def websocket_update_area( def _entry_dict(entry: AreaEntry) -> dict[str, Any]: """Convert entry to API format.""" return { - "aliases": entry.aliases, + "aliases": list(entry.aliases), "area_id": entry.id, "name": entry.name, "picture": entry.picture, From 7e0e306c1fbe10d9fdfa241cd15216c457d36968 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:18:05 +0100 Subject: [PATCH 0767/1544] Enable strict typing for bluetooth_adapters (#108365) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index ff2bd9800e7..9ab2d535f0f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -104,6 +104,7 @@ homeassistant.components.blockchain.* homeassistant.components.blue_current.* homeassistant.components.blueprint.* homeassistant.components.bluetooth.* +homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* diff --git a/mypy.ini b/mypy.ini index d1918acbf66..bfeaa28ef04 100644 --- a/mypy.ini +++ b/mypy.ini @@ -800,6 +800,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bluetooth_adapters.*] +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.bluetooth_tracker.*] check_untyped_defs = true disallow_incomplete_defs = true From 15bd31e8d845139b001e10f7b288e6480c0847fe Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:33:20 +0100 Subject: [PATCH 0768/1544] Enable strict typing for api (#108363) --- .strict-typing | 1 + homeassistant/components/api/__init__.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9ab2d535f0f..a7758750d5e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -77,6 +77,7 @@ homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apache_kafka.* homeassistant.components.apcupsd.* +homeassistant.components.api.* homeassistant.components.apprise.* homeassistant.components.aprs.* homeassistant.components.aqualogic.* diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 8a5e1f0b0e0..048837dae68 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -433,7 +433,7 @@ class APIComponentsView(HomeAssistantView): @lru_cache -def _cached_template(template_str: str, hass: ha.HomeAssistant) -> template.Template: +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: """Return a cached template.""" return template.Template(template_str, hass) @@ -450,7 +450,7 @@ class APITemplateView(HomeAssistantView): try: data = await request.json() tpl = _cached_template(data["template"], request.app["hass"]) - return tpl.async_render(variables=data.get("variables"), parse_result=False) + return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return] except (ValueError, TemplateError) as ex: return self.json_message( f"Error rendering template: {ex}", HTTPStatus.BAD_REQUEST diff --git a/mypy.ini b/mypy.ini index bfeaa28ef04..163be50e6eb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -530,6 +530,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.api.*] +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.apprise.*] check_untyped_defs = true disallow_incomplete_defs = true From c6f1c4f55082095b8c15f82b4d5f347d96d43040 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:34:40 +0100 Subject: [PATCH 0769/1544] Enable strict typing for default_config (#108366) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index a7758750d5e..8ef3b40e508 100644 --- a/.strict-typing +++ b/.strict-typing @@ -133,6 +133,7 @@ homeassistant.components.crownstone.* homeassistant.components.date.* homeassistant.components.datetime.* homeassistant.components.deconz.* +homeassistant.components.default_config.* homeassistant.components.demo.* homeassistant.components.derivative.* homeassistant.components.device_automation.* diff --git a/mypy.ini b/mypy.ini index 163be50e6eb..98e6e6ed781 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1090,6 +1090,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.default_config.*] +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.demo.*] check_untyped_defs = true disallow_incomplete_defs = true From e785b2f5bb76d916acbd40ae5f2235fa25be3c7c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:35:12 +0100 Subject: [PATCH 0770/1544] Enable strict typing for my (#108369) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 8ef3b40e508..3f1574c18d7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -292,6 +292,7 @@ homeassistant.components.moon.* homeassistant.components.mopeka.* homeassistant.components.motionmount.* homeassistant.components.mqtt.* +homeassistant.components.my.* homeassistant.components.mysensors.* homeassistant.components.myuplink.* homeassistant.components.nam.* diff --git a/mypy.ini b/mypy.ini index 98e6e6ed781..460e964de95 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2681,6 +2681,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.my.*] +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.mysensors.*] check_untyped_defs = true disallow_incomplete_defs = true From d7a9b7a4ab2bbac2b86cf0351c1a901f86d5d74b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:36:44 +0100 Subject: [PATCH 0771/1544] Enable strict typing for map (#108368) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 3f1574c18d7..98b795b4bc6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -274,6 +274,7 @@ homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* +homeassistant.components.map.* homeassistant.components.mastodon.* homeassistant.components.matrix.* homeassistant.components.matter.* diff --git a/mypy.ini b/mypy.ini index 460e964de95..a2b37c6bf47 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2501,6 +2501,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.map.*] +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.mastodon.*] check_untyped_defs = true disallow_incomplete_defs = true From b07b952ae6c9b987ef33c753f865e157238e36d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:37:33 +0100 Subject: [PATCH 0772/1544] Enable strict typing for intent_script (#108367) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 98b795b4bc6..3d76bb68224 100644 --- a/.strict-typing +++ b/.strict-typing @@ -239,6 +239,7 @@ homeassistant.components.input_select.* homeassistant.components.input_text.* homeassistant.components.integration.* homeassistant.components.intent.* +homeassistant.components.intent_script.* homeassistant.components.ios.* homeassistant.components.ipp.* homeassistant.components.iqvia.* diff --git a/mypy.ini b/mypy.ini index a2b37c6bf47..ab823020c04 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2151,6 +2151,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.intent_script.*] +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.ios.*] check_untyped_defs = true disallow_incomplete_defs = true From 8c71abe421a4a56f86fffc9263414cca2ca814a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 02:44:29 -1000 Subject: [PATCH 0773/1544] Avoid json encoder default fallback for APIComponentsView (#108359) --- homeassistant/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 048837dae68..a27c386de43 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -429,7 +429,7 @@ class APIComponentsView(HomeAssistantView): def get(self, request: web.Request) -> web.Response: """Get current loaded components.""" hass: HomeAssistant = request.app["hass"] - return self.json(hass.config.components) + return self.json(list(hass.config.components)) @lru_cache From 42154bd684dd65a9a94e87fe9bada71676e77c14 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 13:45:59 +0100 Subject: [PATCH 0774/1544] Improve ifttt typing (#108308) --- homeassistant/components/ifttt/__init__.py | 7 +++- .../components/ifttt/alarm_control_panel.py | 38 +++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 0f8b7a0fa74..736efcb03a7 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -1,8 +1,11 @@ """Support to trigger Maker IFTTT recipes.""" +from __future__ import annotations + from http import HTTPStatus import json import logging +from aiohttp import web import pyfttt import requests import voluptuous as vol @@ -91,7 +94,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def handle_webhook(hass, webhook_id, request): +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request +) -> None: """Handle webhook callback.""" body = await request.text() try: diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index a0b87bd4932..b568693303a 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -81,14 +81,14 @@ def setup_platform( if DATA_IFTTT_ALARM not in hass.data: hass.data[DATA_IFTTT_ALARM] = [] - name = config.get(CONF_NAME) - code = config.get(CONF_CODE) - code_arm_required = config.get(CONF_CODE_ARM_REQUIRED) - event_away = config.get(CONF_EVENT_AWAY) - event_home = config.get(CONF_EVENT_HOME) - event_night = config.get(CONF_EVENT_NIGHT) - event_disarm = config.get(CONF_EVENT_DISARM) - optimistic = config.get(CONF_OPTIMISTIC) + name: str = config[CONF_NAME] + code: str | None = config.get(CONF_CODE) + code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + event_away: str = config[CONF_EVENT_AWAY] + event_home: str = config[CONF_EVENT_HOME] + event_night: str = config[CONF_EVENT_NIGHT] + event_disarm: str = config[CONF_EVENT_DISARM] + optimistic: bool = config[CONF_OPTIMISTIC] alarmpanel = IFTTTAlarmPanel( name, @@ -135,15 +135,15 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): def __init__( self, - name, - code, - code_arm_required, - event_away, - event_home, - event_night, - event_disarm, - optimistic, - ): + name: str, + code: str | None, + code_arm_required: bool, + event_away: str, + event_home: str, + event_night: str, + event_disarm: str, + optimistic: bool, + ) -> None: """Initialize the alarm control panel.""" self._attr_name = name self._code = code @@ -187,7 +187,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): return self.set_alarm_state(self._event_night, STATE_ALARM_ARMED_NIGHT) - def set_alarm_state(self, event, state): + def set_alarm_state(self, event: str, state: str) -> None: """Call the IFTTT trigger service to change the alarm state.""" data = {ATTR_EVENT: event} @@ -196,7 +196,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): if self._optimistic: self._attr_state = state - def push_alarm_state(self, value): + def push_alarm_state(self, value: str) -> None: """Push the alarm state to the given value.""" if value in ALLOWED_STATES: _LOGGER.debug("Pushed the alarm state to %s", value) From 4d6951584983f31a1464aa8e47040d5cbc28f569 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 03:41:32 -1000 Subject: [PATCH 0775/1544] Use shorthand attributes for mobile_app sensor platforms (#108353) --- .../components/mobile_app/binary_sensor.py | 17 ++--- homeassistant/components/mobile_app/entity.py | 65 +++++++------------ homeassistant/components/mobile_app/sensor.py | 48 +++++++------- 3 files changed, 55 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 69ecb913c98..2be71965371 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -70,13 +70,14 @@ async def async_setup_entry( class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): """Representation of an mobile app binary sensor.""" - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._config[ATTR_SENSOR_STATE] - - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON + self._async_update_attr_from_config() + + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + super()._async_update_attr_from_config() + self._attr_is_on = self._config[ATTR_SENSOR_STATE] diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 120014d1d52..76cf22cef54 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE -from homeassistant.core import callback +from homeassistant.core import State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -32,9 +32,23 @@ class MobileAppEntity(RestoreEntity): self._entry = entry self._registration = entry.data self._attr_unique_id = config[CONF_UNIQUE_ID] - self._name = self._config[CONF_NAME] + self._attr_entity_registry_enabled_default = not config.get( + ATTR_SENSOR_DISABLED + ) + self._attr_name = config[CONF_NAME] + self._async_update_attr_from_config() - async def async_added_to_hass(self): + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + config = self._config + self._attr_device_class = config.get(ATTR_SENSOR_DEVICE_CLASS) + self._attr_extra_state_attributes = config[ATTR_SENSOR_ATTRIBUTES] + self._attr_icon = config[ATTR_SENSOR_ICON] + self._attr_entity_category = config.get(ATTR_SENSOR_ENTITY_CATEGORY) + self._attr_available = config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE + + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -49,58 +63,25 @@ class MobileAppEntity(RestoreEntity): await self.async_restore_last_state(state) - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - self._config[ATTR_SENSOR_STATE] = last_state.state - self._config[ATTR_SENSOR_ATTRIBUTES] = { + config = self._config + config[ATTR_SENSOR_STATE] = last_state.state + config[ATTR_SENSOR_ATTRIBUTES] = { **last_state.attributes, **self._config[ATTR_SENSOR_ATTRIBUTES], } if ATTR_ICON in last_state.attributes: - self._config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] - - @property - def name(self): - """Return the name of the mobile app sensor.""" - return self._name - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if entity should be enabled by default.""" - return not self._config.get(ATTR_SENSOR_DISABLED) - - @property - def device_class(self): - """Return the device class.""" - return self._config.get(ATTR_SENSOR_DEVICE_CLASS) - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._config[ATTR_SENSOR_ATTRIBUTES] - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._config[ATTR_SENSOR_ICON] - - @property - def entity_category(self): - """Return the entity category, if any.""" - return self._config.get(ATTR_SENSOR_ENTITY_CATEGORY) + config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] @property def device_info(self): """Return device registry information for this entity.""" return device_info(self._registration) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE - @callback def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" self._config.update(data) + self._async_update_attr_from_config() self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index fc325b1b6e9..fd712faf121 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -2,12 +2,12 @@ from __future__ import annotations from datetime import date, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -79,26 +79,28 @@ async def async_setup_entry( class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" - async def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state: State) -> None: """Restore previous state.""" - await super().async_restore_last_state(last_state) - + config = self._config if not (last_sensor_data := await self.async_get_last_sensor_data()): # Workaround to handle migration to RestoreSensor, can be removed # in HA Core 2023.4 - self._config[ATTR_SENSOR_STATE] = None + config[ATTR_SENSOR_STATE] = None webhook_id = self._entry.data[CONF_WEBHOOK_ID] + if TYPE_CHECKING: + assert self.unique_id is not None sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) if ( self.device_class == SensorDeviceClass.TEMPERATURE and sensor_unique_id == "battery_temperature" ): - self._config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS - return + config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS + else: + config[ATTR_SENSOR_STATE] = last_sensor_data.native_value + config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement - self._config[ATTR_SENSOR_STATE] = last_sensor_data.native_value - self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement + self._async_update_attr_from_config() @property def native_value(self) -> StateType | date | datetime: @@ -106,29 +108,25 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor): if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN): return None + device_class = self.device_class + if ( - self.device_class - in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ) + device_class in (SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP) # Only parse strings: if the sensor's state is restored, the state is a # native date or datetime, not str and isinstance(state, str) and (timestamp := dt_util.parse_datetime(state)) is not None ): - if self.device_class == SensorDeviceClass.DATE: + if device_class == SensorDeviceClass.DATE: return timestamp.date() return timestamp return state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement this sensor expresses itself in.""" - return self._config.get(ATTR_SENSOR_UOM) - - @property - def state_class(self) -> str | None: - """Return state class.""" - return self._config.get(ATTR_SENSOR_STATE_CLASS) + @callback + def _async_update_attr_from_config(self) -> None: + """Update the entity from the config.""" + super()._async_update_attr_from_config() + config = self._config + self._attr_native_unit_of_measurement = config.get(ATTR_SENSOR_UOM) + self._attr_state_class = config.get(ATTR_SENSOR_STATE_CLASS) From 2e56d7d04847e811fc864ef0813b46271b085dac Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 19 Jan 2024 15:10:54 +0100 Subject: [PATCH 0776/1544] Bump openwebifpy to 4.2.1 (#107894) --- homeassistant/components/enigma2/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 42fbcb5b9bc..e298b3b714f 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==4.0.4"] + "requirements": ["openwebifpy==4.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba1ad841ba2..d945d3bb761 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1437,7 +1437,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==4.0.4 +openwebifpy==4.2.1 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 From cdf3c07488f8d2e376a19386ea8456aed486aa66 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 19 Jan 2024 15:11:27 +0100 Subject: [PATCH 0777/1544] Add icon to entity registry list for display (#108313) --- homeassistant/helpers/entity_registry.py | 1 + tests/components/config/test_entity_registry.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 022fa045a3e..b6790ff0dc3 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -136,6 +136,7 @@ ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] DISPLAY_DICT_OPTIONAL = ( ("ai", "area_id"), ("di", "device_id"), + ("ic", "icon"), ("tk", "translation_key"), ) diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index a002f2c2d50..46af23a6d1f 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -160,6 +160,7 @@ async def test_list_entities_for_display( entity_category=EntityCategory.DIAGNOSTIC, entity_id="test_domain.test", has_entity_name=True, + icon="mdi:icon", original_name="Hello World", platform="test_platform", translation_key="translations_galore", @@ -170,6 +171,7 @@ async def test_list_entities_for_display( device_id="device123", entity_id="test_domain.nameless", has_entity_name=True, + icon=None, original_name=None, platform="test_platform", unique_id="2345", @@ -231,6 +233,7 @@ async def test_list_entities_for_display( "ec": 1, "ei": "test_domain.test", "en": "Hello World", + "ic": "mdi:icon", "pl": "test_platform", "tk": "translations_galore", }, From 384b22c7773844ff730d0f4cac3297841df9b63c Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 19 Jan 2024 15:26:26 +0100 Subject: [PATCH 0778/1544] Expose TimeoutError during google_travel_time config_flow (#108179) Expose TimeoutError during config_flow --- homeassistant/components/google_travel_time/config_flow.py | 2 ++ homeassistant/components/google_travel_time/helpers.py | 2 +- homeassistant/components/google_travel_time/strings.json | 3 ++- tests/components/google_travel_time/test_config_flow.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index ec8187d91af..73a4bf87b7e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -192,6 +192,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except InvalidApiKeyException: errors["base"] = "invalid_auth" + except TimeoutError: + errors["base"] = "timeout_connect" except UnknownException: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 12394a23209..9c25d02b8a5 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -35,7 +35,7 @@ def validate_config_entry( raise UnknownException() from transport_error except Timeout as timeout_error: _LOGGER.error("Timeout error") - raise UnknownException() from timeout_error + raise TimeoutError() from timeout_error class InvalidApiKeyException(Exception): diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index e3a13a3d2e3..3cfcd3cedb3 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -14,7 +14,8 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 9e575389e72..b701fcb2143 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -115,7 +115,7 @@ async def test_timeout(hass: HomeAssistant) -> None: ) assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": "timeout_connect"} async def test_malformed_api_key(hass: HomeAssistant) -> None: From 4a0b6af8c10ac1c65055bad1c5d5c0eb7f053222 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 15:56:54 +0100 Subject: [PATCH 0779/1544] Update dwdwfsapi to 1.0.7 (#108377) --- homeassistant/components/dwd_weather_warnings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 1a497b64ae3..e74ea6fe862 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], - "requirements": ["dwdwfsapi==1.0.6"] + "requirements": ["dwdwfsapi==1.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index d945d3bb761..22b84402904 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -722,7 +722,7 @@ dropmqttapi==1.0.2 dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.6 +dwdwfsapi==1.0.7 # homeassistant.components.dweet dweepy==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4f7465f2b1..7d822a01b45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -591,7 +591,7 @@ dropmqttapi==1.0.2 dsmr-parser==1.3.1 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.6 +dwdwfsapi==1.0.7 # homeassistant.components.dynalite dynalite-devices==0.1.47 From 298b0d11053457c69eef3def6764aeed16619927 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:01:20 +0100 Subject: [PATCH 0780/1544] Add binary sensor to MotionMount integration (#107659) * Add binary sensor for `isMoving` * Sort platforms alphabetically * Update doc strings --- .coveragerc | 1 + .../components/motionmount/__init__.py | 5 +-- .../components/motionmount/binary_sensor.py | 39 +++++++++++++++++++ .../components/motionmount/strings.json | 5 +++ 4 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/motionmount/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index b7d4b0bc68b..5265493ece1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -762,6 +762,7 @@ omit = homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/binary_sensor.py homeassistant/components/motionmount/entity.py homeassistant/components/motionmount/number.py homeassistant/components/motionmount/select.py diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 4285a12a101..5c661a77955 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -13,10 +13,7 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC -PLATFORMS: list[Platform] = [ - Platform.NUMBER, - Platform.SELECT, -] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SELECT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py new file mode 100644 index 00000000000..6bbed2e90c5 --- /dev/null +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -0,0 +1,39 @@ +"""Support for MotionMount binary sensors.""" +import motionmount + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([MotionMountMovingSensor(mm, entry)]) + + +class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): + """The moving sensor of a MotionMount.""" + + _attr_device_class = BinarySensorDeviceClass.MOVING + _attr_translation_key = "motionmount_is_moving" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize moving binary sensor entity.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-moving" + + @property + def is_on(self) -> bool: + """Get on status.""" + return self.mm.is_moving or False diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 2a25611433a..94859dc90e3 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "binary_sensor": { + "motionmount_is_moving": { + "name": "Moving" + } + }, "number": { "motionmount_extension": { "name": "Extension" From c1d6f740afd7cbb75b379b23d6b9c8705def6f60 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 16:36:44 +0100 Subject: [PATCH 0781/1544] Update types packages (#108371) --- requirements_test.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 85dc72e0430..39fd545dbe6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,18 +33,18 @@ requests-mock==1.11.0 respx==0.20.2 syrupy==4.6.0 tqdm==4.66.1 -types-aiofiles==23.2.0.0 +types-aiofiles==23.2.0.20240106 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 -types-beautifulsoup4==4.12.0.6 -types-caldav==1.3.0.0 +types-beautifulsoup4==4.12.0.20240106 +types-caldav==1.3.0.20240106 types-chardet==0.1.5 -types-decorator==5.1.8.4 -types-paho-mqtt==1.6.0.7 -types-Pillow==10.0.0.3 -types-protobuf==4.24.0.2 -types-psutil==5.9.5.16 -types-python-dateutil==2.8.19.14 +types-decorator==5.1.8.20240106 +types-paho-mqtt==1.6.0.20240106 +types-Pillow==10.1.0.20240106 +types-protobuf==4.24.0.20240106 +types-psutil==5.9.5.20240106 +types-python-dateutil==2.8.19.20240106 types-python-slugify==0.1.2 types-pytz==2023.3.1.1 types-PyYAML==6.0.12.12 From ed449a5abd1d4a6cfcdc2fbf45e307f1b9d18848 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 19 Jan 2024 16:52:30 +0100 Subject: [PATCH 0782/1544] Add support for MQTT based ecovacs vacuums (#108167) * Add support for MQTT based ecovacs vacuums * renames * Add init import test * bump deebot-client * Translate continent options * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Remove continent * use ServiceValidationError * Small refactoring * Simplify * Fix tests * Enable strict typing for ecovacs * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Implement suggestions * improve test_async_setup_import * Implement suggestions * Update homeassistant/components/ecovacs/config_flow.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .coveragerc | 3 +- .strict-typing | 1 + homeassistant/components/ecovacs/__init__.py | 81 ++----- .../components/ecovacs/config_flow.py | 112 +++++++--- .../components/ecovacs/controller.py | 96 ++++++++ homeassistant/components/ecovacs/entity.py | 106 +++++++++ .../components/ecovacs/manifest.json | 4 +- homeassistant/components/ecovacs/strings.json | 49 ++++- homeassistant/components/ecovacs/vacuum.py | 206 +++++++++++++++++- mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ecovacs/conftest.py | 52 ++++- tests/components/ecovacs/const.py | 13 ++ tests/components/ecovacs/test_config_flow.py | 147 ++++++------- tests/components/ecovacs/test_init.py | 85 ++++++++ 16 files changed, 786 insertions(+), 185 deletions(-) create mode 100644 homeassistant/components/ecovacs/controller.py create mode 100644 homeassistant/components/ecovacs/entity.py create mode 100644 tests/components/ecovacs/const.py create mode 100644 tests/components/ecovacs/test_init.py diff --git a/.coveragerc b/.coveragerc index 5265493ece1..d0ce82dd735 100644 --- a/.coveragerc +++ b/.coveragerc @@ -272,7 +272,8 @@ omit = homeassistant/components/econet/climate.py homeassistant/components/econet/sensor.py homeassistant/components/econet/water_heater.py - homeassistant/components/ecovacs/__init__.py + homeassistant/components/ecovacs/controller.py + homeassistant/components/ecovacs/entity.py homeassistant/components/ecovacs/util.py homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py diff --git a/.strict-typing b/.strict-typing index 3d76bb68224..d528484cc98 100644 --- a/.strict-typing +++ b/.strict-typing @@ -154,6 +154,7 @@ homeassistant.components.duckdns.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* +homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index f8d6fc912e9..e4c8a965695 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -1,26 +1,14 @@ """Support for Ecovacs Deebot vacuums.""" -import logging - -from sucks import EcoVacsAPI, VacBot import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_COUNTRY, - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_CONTINENT, DOMAIN -from .util import get_client_device_id - -_LOGGER = logging.getLogger(__name__) - +from .controller import EcovacsController CONFIG_SCHEMA = vol.Schema( { @@ -54,56 +42,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up this integration using UI.""" + controller = EcovacsController(hass, entry.data) + await controller.initialize() - def get_devices() -> list[VacBot]: - ecovacs_api = EcoVacsAPI( - get_client_device_id(), - entry.data[CONF_USERNAME], - EcoVacsAPI.md5(entry.data[CONF_PASSWORD]), - entry.data[CONF_COUNTRY], - entry.data[CONF_CONTINENT], - ) - ecovacs_devices = ecovacs_api.devices() - - _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) - devices: list[VacBot] = [] - for device in ecovacs_devices: - _LOGGER.debug( - "Discovered Ecovacs device on account: %s with nickname %s", - device.get("did"), - device.get("nick"), - ) - vacbot = VacBot( - ecovacs_api.uid, - ecovacs_api.REALM, - ecovacs_api.resource, - ecovacs_api.user_access_token, - device, - entry.data[CONF_CONTINENT], - monitor=True, - ) - - devices.append(vacbot) - return devices - - hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = await hass.async_add_executor_job(get_devices) - - async def async_stop(event: object) -> None: - """Shut down open connections to Ecovacs XMPP server.""" - devices: list[VacBot] = hass.data[DOMAIN][entry.entry_id] - for device in devices: - _LOGGER.info( - "Shutting down connection to Ecovacs device %s", - device.vacuum.get("did"), - ) - await hass.async_add_executor_job(device.disconnect) - - # Listen for HA stop to disconnect. - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) - - if hass.data[DOMAIN][entry.entry_id]: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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 diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 05232dddb53..75a0d28ae91 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -2,18 +2,23 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast -from sucks import EcoVacsAPI +from aiohttp import ClientError +from deebot_client.authentication import Authenticator +from deebot_client.exceptions import InvalidAuthenticationError +from deebot_client.models import Configuration +from deebot_client.util import md5 +from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import AbortFlow, FlowResult -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, selector from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.loader import async_get_issue_tracker from .const import CONF_CONTINENT, DOMAIN from .util import get_client_device_id @@ -21,21 +26,34 @@ from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +async def _validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: """Validate user input.""" errors: dict[str, str] = {} + + deebot_config = Configuration( + aiohttp_client.async_get_clientsession(hass), + device_id=get_client_device_id(), + country=user_input[CONF_COUNTRY], + continent=user_input.get(CONF_CONTINENT), + ) + + authenticator = Authenticator( + deebot_config, + user_input[CONF_USERNAME], + md5(user_input[CONF_PASSWORD]), + ) + try: - EcoVacsAPI( - get_client_device_id(), - user_input[CONF_USERNAME], - EcoVacsAPI.md5(user_input[CONF_PASSWORD]), - user_input[CONF_COUNTRY], - user_input[CONF_CONTINENT], - ) - except ValueError: + await authenticator.authenticate() + except ClientError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuthenticationError: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception during login") errors["base"] = "unknown" return errors @@ -55,7 +73,7 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await _validate_input(self.hass, user_input) if not errors: return self.async_create_entry( @@ -65,7 +83,7 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - vol.Schema( + data_schema=vol.Schema( { vol.Required(CONF_USERNAME): selector.TextSelector( selector.TextSelectorConfig( @@ -77,11 +95,13 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): type=selector.TextSelectorType.PASSWORD ) ), - vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string), - vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string), + vol.Required(CONF_COUNTRY): selector.CountrySelector(), } ), - user_input, + suggested_values=user_input + or { + CONF_COUNTRY: self.hass.config.country, + }, ), errors=errors, ) @@ -89,7 +109,11 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import configuration from yaml.""" - def create_repair(error: str | None = None) -> None: + def create_repair( + error: str | None = None, placeholders: dict[str, Any] | None = None + ) -> None: + if placeholders is None: + placeholders = {} if error: async_create_issue( self.hass, @@ -100,9 +124,8 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key=f"deprecated_yaml_import_issue_{error}", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=ecovacs" - }, + translation_placeholders=placeholders + | {"url": "/config/integrations/dashboard/add?domain=ecovacs"}, ) else: async_create_issue( @@ -114,12 +137,51 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", - translation_placeholders={ + translation_placeholders=placeholders + | { "domain": DOMAIN, "integration_title": "Ecovacs", }, ) + # We need to validate the imported country and continent + # as the YAML configuration allows any string for them. + # The config flow allows only valid alpha-2 country codes + # through the CountrySelector. + # The continent will be calculated with the function get_continent + # from the country code and there is no need to specify the continent anymore. + # As the YAML configuration includes the continent, + # we check if both the entered continent and the calculated continent match. + # If not we will inform the user about the mismatch. + error = None + placeholders = None + if len(user_input[CONF_COUNTRY]) != 2: + error = "invalid_country_length" + placeholders = {"countries_url": "https://www.iso.org/obp/ui/#search/code/"} + elif len(user_input[CONF_CONTINENT]) != 2: + error = "invalid_continent_length" + placeholders = { + "continent_list": ",".join( + sorted(set(COUNTRIES_TO_CONTINENTS.values())) + ) + } + elif user_input[CONF_CONTINENT].lower() != ( + continent := get_continent(user_input[CONF_COUNTRY]) + ): + error = "continent_not_match" + placeholders = { + "continent": continent, + "github_issue_url": cast( + str, async_get_issue_tracker(self.hass, integration_domain=DOMAIN) + ), + } + + if error: + create_repair(error, placeholders) + return self.async_abort(reason=error) + + # Remove the continent from the user input as it is not needed anymore + user_input.pop(CONF_CONTINENT) try: result = await self.async_step_user(user_input) except AbortFlow as ex: diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py new file mode 100644 index 00000000000..645c5b9bc19 --- /dev/null +++ b/homeassistant/components/ecovacs/controller.py @@ -0,0 +1,96 @@ +"""Controller module.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from deebot_client.api_client import ApiClient +from deebot_client.authentication import Authenticator +from deebot_client.device import Device +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +from deebot_client.models import Configuration, DeviceInfo +from deebot_client.mqtt_client import MqttClient, MqttConfiguration +from deebot_client.util import md5 +from sucks import EcoVacsAPI, VacBot + +from homeassistant.const import ( + CONF_COUNTRY, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client + +from .util import get_client_device_id + +_LOGGER = logging.getLogger(__name__) + + +class EcovacsController: + """Ecovacs controller.""" + + def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: + """Initialize controller.""" + self._hass = hass + self.devices: list[Device] = [] + self.legacy_devices: list[VacBot] = [] + verify_ssl = config.get(CONF_VERIFY_SSL, True) + device_id = get_client_device_id() + + self._config = Configuration( + aiohttp_client.async_get_clientsession(self._hass, verify_ssl=verify_ssl), + device_id=device_id, + country=config[CONF_COUNTRY], + verify_ssl=verify_ssl, + ) + + self._authenticator = Authenticator( + self._config, + config[CONF_USERNAME], + md5(config[CONF_PASSWORD]), + ) + self._api_client = ApiClient(self._authenticator) + + mqtt_config = MqttConfiguration(config=self._config) + self._mqtt = MqttClient(mqtt_config, self._authenticator) + + async def initialize(self) -> None: + """Init controller.""" + try: + devices = await self._api_client.get_devices() + credentials = await self._authenticator.authenticate() + for device_config in devices: + if isinstance(device_config, DeviceInfo): + device = Device(device_config, self._authenticator) + await device.initialize(self._mqtt) + self.devices.append(device) + else: + # Legacy device + bot = VacBot( + credentials.user_id, + EcoVacsAPI.REALM, + self._config.device_id[0:8], + credentials.token, + device_config, + self._config.continent, + monitor=True, + ) + self.legacy_devices.append(bot) + except InvalidAuthenticationError as ex: + raise ConfigEntryError("Invalid credentials") from ex + except DeebotError as ex: + raise ConfigEntryNotReady("Error during setup") from ex + + _LOGGER.debug("Controller initialize complete") + + async def teardown(self) -> None: + """Disconnect controller.""" + for device in self.devices: + await device.teardown() + 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() diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py new file mode 100644 index 00000000000..caaefef0956 --- /dev/null +++ b/homeassistant/components/ecovacs/entity.py @@ -0,0 +1,106 @@ +"""Ecovacs mqtt entity module.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import AvailabilityEvent +from deebot_client.events.base import Event + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import DOMAIN + +_EntityDescriptionT = TypeVar("_EntityDescriptionT", bound=EntityDescription) +CapabilityT = TypeVar("CapabilityT") +EventT = TypeVar("EventT", bound=Event) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsEntityDescription( + EntityDescription, + Generic[CapabilityT], +): + """Ecovacs entity description.""" + + capability_fn: Callable[[Capabilities], CapabilityT | None] + + +class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): + """Ecovacs entity.""" + + entity_description: _EntityDescriptionT + + _attr_should_poll = False + _attr_has_entity_name = True + _always_available: bool = False + + def __init__( + self, + device: Device, + capability: CapabilityT, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(**kwargs) + self._attr_unique_id = f"{device.device_info.did}_{self.entity_description.key}" + + self._device = device + self._capability = capability + self._subscribed_events: set[type[Event]] = set() + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + device_info = self._device.device_info + info = DeviceInfo( + identifiers={(DOMAIN, device_info.did)}, + manufacturer="Ecovacs", + sw_version=self._device.fw_version, + serial_number=device_info.name, + ) + + if nick := device_info.api_device_info.get("nick"): + info["name"] = nick + + if model := device_info.api_device_info.get("deviceName"): + info["model"] = model + + if mac := self._device.mac: + info["connections"] = {(dr.CONNECTION_NETWORK_MAC, mac)} + + return info + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + if not self._always_available: + + async def on_available(event: AvailabilityEvent) -> None: + self._attr_available = event.available + self.async_write_ha_state() + + self._subscribe(AvailabilityEvent, on_available) + + def _subscribe( + self, + event_type: type[EventT], + callback: Callable[[EventT], Coroutine[Any, Any, None]], + ) -> None: + """Subscribe to events.""" + self._subscribed_events.add(event_type) + self.async_on_remove(self._device.events.subscribe(event_type, callback)) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + for event_type in self._subscribed_events: + self._device.events.request_refresh(event_type) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 286a7ce5583..d08602bbba8 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", - "loggers": ["sleekxmppfs", "sucks"], - "requirements": ["py-sucks==0.9.8"] + "loggers": ["sleekxmppfs", "sucks", "deebot_client"], + "requirements": ["py-sucks==0.9.8", "deebot-client==4.3.0"] } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 86bdef89b3b..2ae12c244a1 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -4,25 +4,52 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "user": { "data": { - "continent": "Continent", "country": "Country", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" - }, - "data_description": { - "continent": "Your two-letter continent code (na, eu, etc)", - "country": "Your two-letter country code (us, uk, etc)" } } } }, + "entity": { + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "max": "Max", + "max_plus": "Max+", + "normal": "Normal", + "quiet": "Quiet" + } + }, + "rooms": { + "name": "Rooms" + } + } + } + } + }, + "exceptions": { + "vacuum_send_command_params_dict": { + "message": "Params must be a dictionary and not a list" + }, + "vacuum_send_command_params_required": { + "message": "Params are required for the command: {command}" + } + }, "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there was a connection error when trying to import the YAML configuration.\n\nPlease verify that you have a stable internet connection and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, "deprecated_yaml_import_issue_invalid_auth": { "title": "The Ecovacs YAML configuration import failed", "description": "Configuring Ecovacs using YAML is being removed but there was an authentication error when trying to import the YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." @@ -30,6 +57,18 @@ "deprecated_yaml_import_issue_unknown": { "title": "The Ecovacs YAML configuration import failed", "description": "Configuring Ecovacs using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_country_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid country specified in the YAML configuration.\n\nPlease change the country to the [Alpha-2 code of your country]({countries_url}) and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_continent_length": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an invalid continent specified in the YAML configuration.\n\nPlease correct the continent to be one of {continent_list} and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_continent_not_match": { + "title": "The Ecovacs YAML configuration import failed", + "description": "Configuring Ecovacs using YAML is being removed but there is an unexpected continent specified in the YAML configuration.\n\nFrom the given country, the continent '{continent}' is expected. Change the continent and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.\n\nIf the contintent '{continent}' is not applicable, please open an issue on [GitHub]({github_issue_url})." } } } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 3b4f86920b6..a4927ab1e9f 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -1,9 +1,14 @@ """Support for Ecovacs Ecovacs Vacuums.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +from deebot_client.capabilities import Capabilities +from deebot_client.device import Device +from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent +from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( @@ -11,16 +16,22 @@ from homeassistant.components.vacuum import ( STATE_DOCKED, STATE_ERROR, STATE_IDLE, + STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, + 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 .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity _LOGGER = logging.getLogger(__name__) @@ -34,17 +45,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" - vacuums = [] - devices: list[sucks.VacBot] = hass.data[DOMAIN][config_entry.entry_id] - for device in devices: + vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [] + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) + vacuums.append(EcovacsLegacyVacuum(device)) + for device in controller.devices: vacuums.append(EcovacsVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) -class EcovacsVacuum(StateVacuumEntity): - """Ecovacs Vacuums such as Deebot.""" +class EcovacsLegacyVacuum(StateVacuumEntity): + """Legacy Ecovacs vacuums.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_should_poll = False @@ -65,7 +78,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device = device vacuum = self.device.vacuum - self.error = None + self.error: str | None = None self._attr_unique_id = vacuum["did"] self._attr_name = vacuum.get("nick", vacuum["did"]) @@ -76,7 +89,7 @@ class EcovacsVacuum(StateVacuumEntity): self.device.lifespanEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.errorEvents.subscribe(self.on_error) - def on_error(self, error): + def on_error(self, error: str) -> None: """Handle an error event from the robot. This will not change the entity's state. If the error caused the state @@ -116,7 +129,7 @@ class EcovacsVacuum(StateVacuumEntity): def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" if self.device.battery_status is not None: - return self.device.battery_status * 100 + return self.device.battery_status * 100 # type: ignore[no-any-return] return None @@ -130,7 +143,7 @@ class EcovacsVacuum(StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self.device.fan_speed + return self.device.fan_speed # type: ignore[no-any-return] @property def extra_state_attributes(self) -> dict[str, Any]: @@ -182,3 +195,178 @@ class EcovacsVacuum(StateVacuumEntity): ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) + + +_STATE_TO_VACUUM_STATE = { + State.IDLE: STATE_IDLE, + State.CLEANING: STATE_CLEANING, + State.RETURNING: STATE_RETURNING, + State.DOCKED: STATE_DOCKED, + State.ERROR: STATE_ERROR, + State.PAUSED: STATE_PAUSED, +} + +_ATTR_ROOMS = "rooms" + + +class EcovacsVacuum( + EcovacsEntity[Capabilities, StateVacuumEntityDescription], + StateVacuumEntity, +): + """Ecovacs vacuum.""" + + _unrecorded_attributes = frozenset({_ATTR_ROOMS}) + + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.SEND_COMMAND + | VacuumEntityFeature.LOCATE + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + + entity_description = StateVacuumEntityDescription( + key="vacuum", translation_key="vacuum", name=None + ) + + def __init__(self, device: Device) -> None: + """Initialize the vacuum.""" + capabilities = device.capabilities + super().__init__(device, capabilities) + + self._rooms: list[Room] = [] + + self._attr_fan_speed_list = [ + level.display_name for level in capabilities.fan_speed.types + ] + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_battery(event: BatteryEvent) -> None: + self._attr_battery_level = event.value + self.async_write_ha_state() + + async def on_fan_speed(event: FanSpeedEvent) -> None: + self._attr_fan_speed = event.speed.display_name + self.async_write_ha_state() + + async def on_rooms(event: RoomsEvent) -> None: + self._rooms = event.rooms + self.async_write_ha_state() + + async def on_status(event: StateEvent) -> None: + self._attr_state = _STATE_TO_VACUUM_STATE[event.state] + self.async_write_ha_state() + + self._subscribe(self._capability.battery.event, on_battery) + self._subscribe(self._capability.fan_speed.event, on_fan_speed) + self._subscribe(self._capability.state.event, on_status) + + if map_caps := self._capability.map: + self._subscribe(map_caps.rooms.event, on_rooms) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes. + + Implemented by platform classes. Convention for attribute names + is lowercase snake_case. + """ + rooms: dict[str, Any] = {} + for room in self._rooms: + # convert room name to snake_case to meet the convention + room_name = slugify(room.name) + room_values = rooms.get(room_name) + if room_values is None: + rooms[room_name] = room.id + elif isinstance(room_values, list): + room_values.append(room.id) + else: + # Convert from int to list + rooms[room_name] = [room_values, room.id] + + return { + _ATTR_ROOMS: rooms, + } + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._device.execute_command(self._capability.fan_speed.set(fan_speed)) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self._device.execute_command(self._capability.charge.execute()) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + await self._clean_command(CleanAction.STOP) + + async def async_pause(self) -> None: + """Pause the vacuum cleaner.""" + await self._clean_command(CleanAction.PAUSE) + + async def async_start(self) -> None: + """Start the vacuum cleaner.""" + await self._clean_command(CleanAction.START) + + async def _clean_command(self, action: CleanAction) -> None: + await self._device.execute_command( + self._capability.clean.action.command(action) + ) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum cleaner.""" + await self._device.execute_command(self._capability.play_sound.execute()) + + async def async_send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a command to a vacuum cleaner.""" + _LOGGER.debug("async_send_command %s with %s", command, params) + if params is None: + params = {} + elif isinstance(params, list): + raise ServiceValidationError( + "Params must be a dict!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_dict", + ) + + if command in ["spot_area", "custom_area"]: + if params is None: + raise ServiceValidationError( + f"Params are required for {command}!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_params_required", + translation_placeholders={"command": command}, + ) + + if command in "spot_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.SPOT_AREA, + str(params["rooms"]), + params.get("cleanings", 1), + ) + ) + elif command == "custom_area": + await self._device.execute_command( + self._capability.clean.action.area( + CleanMode.CUSTOM_AREA, + str(params["coordinates"]), + params.get("cleanings", 1), + ) + ) + else: + await self._device.execute_command( + self._capability.custom.set(command, params) + ) diff --git a/mypy.ini b/mypy.ini index ab823020c04..f3e3df193d3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1301,6 +1301,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ecovacs.*] +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.ecowitt.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 22b84402904..7915b5ae557 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -677,6 +677,9 @@ debugpy==1.8.0 # homeassistant.components.decora # decora==0.6 +# homeassistant.components.ecovacs +deebot-client==4.3.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d822a01b45..4ba1300638b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,6 +552,9 @@ dbus-fast==2.21.1 # homeassistant.components.debugpy debugpy==1.8.0 +# homeassistant.components.ecovacs +deebot-client==4.3.0 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 5c1cf7adae0..9ba28857cbe 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,9 +1,18 @@ """Common fixtures for the Ecovacs tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from deebot_client.api_client import ApiClient +from deebot_client.authentication import Authenticator +from deebot_client.models import Credentials import pytest +from homeassistant.components.ecovacs.const import DOMAIN + +from .const import VALID_ENTRY_DATA + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +21,44 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.ecovacs.async_setup_entry", return_value=True ) as async_setup_entry: yield async_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="username", + domain=DOMAIN, + data=VALID_ENTRY_DATA, + ) + + +@pytest.fixture +def mock_authenticator() -> Generator[Mock, None, None]: + """Mock the authenticator.""" + mock_authenticator = Mock(spec_set=Authenticator) + mock_authenticator.authenticate.return_value = Credentials("token", "user_id", 0) + with patch( + "homeassistant.components.ecovacs.controller.Authenticator", + return_value=mock_authenticator, + ), patch( + "homeassistant.components.ecovacs.config_flow.Authenticator", + return_value=mock_authenticator, + ): + yield mock_authenticator + + +@pytest.fixture +def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: + """Mock authenticator.authenticate.""" + return mock_authenticator.authenticate + + +@pytest.fixture +def mock_api_client(mock_authenticator: Mock) -> Mock: + """Mock the API client.""" + with patch( + "homeassistant.components.ecovacs.controller.ApiClient", + return_value=Mock(spec_set=ApiClient), + ) as mock_api_client: + yield mock_api_client.return_value diff --git a/tests/components/ecovacs/const.py b/tests/components/ecovacs/const.py new file mode 100644 index 00000000000..f5100e69ee2 --- /dev/null +++ b/tests/components/ecovacs/const.py @@ -0,0 +1,13 @@ +"""Test ecovacs constants.""" + + +from homeassistant.components.ecovacs.const import CONF_CONTINENT +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME + +VALID_ENTRY_DATA = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_COUNTRY: "IT", +} + +IMPORT_DATA = VALID_ENTRY_DATA | {CONF_CONTINENT: "EU"} diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index 9688634bec4..64f0758dc1f 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -1,25 +1,21 @@ """Test Ecovacs config flow.""" from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock +from aiohttp import ClientError +from deebot_client.exceptions import InvalidAuthenticationError import pytest -from sucks import EcoVacsAPI -from homeassistant.components.ecovacs.const import CONF_CONTINENT, DOMAIN +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir -from tests.common import MockConfigEntry +from .const import IMPORT_DATA, VALID_ENTRY_DATA -_USER_INPUT = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_COUNTRY: "it", - CONF_CONTINENT: "eu", -} +from tests.common import MockConfigEntry async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: @@ -31,28 +27,29 @@ async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: return await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=_USER_INPUT, + user_input=VALID_ENTRY_DATA, ) -async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, +) -> None: """Test the user config flow.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - result = await _test_user_flow(hass) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT - mock_setup_entry.assert_called() - mock_ecovacs.assert_called() + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() @pytest.mark.parametrize( ("side_effect", "reason"), [ - (ValueError, "invalid_auth"), + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), (Exception, "unknown"), ], ) @@ -61,50 +58,48 @@ async def test_user_flow_error( side_effect: Exception, reason: str, mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test handling invalid connection.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - mock_ecovacs.side_effect = side_effect - result = await _test_user_flow(hass) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": reason} - mock_ecovacs.assert_called() - mock_setup_entry.assert_not_called() + mock_authenticator_authenticate.side_effect = side_effect - mock_ecovacs.reset_mock(side_effect=True) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=_USER_INPUT, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT - mock_setup_entry.assert_called() + result = await _test_user_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": reason} + mock_authenticator_authenticate.assert_called() + mock_setup_entry.assert_not_called() + + mock_authenticator_authenticate.reset_mock(side_effect=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_ENTRY_DATA, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() async def test_import_flow( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry: AsyncMock + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test importing yaml config.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, - ) - mock_ecovacs.assert_called() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + mock_authenticator_authenticate.assert_called() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == _USER_INPUT[CONF_USERNAME] - assert result["data"] == _USER_INPUT + assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues mock_setup_entry.assert_called() @@ -113,13 +108,13 @@ async def test_import_flow_already_configured( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test importing yaml config where entry already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=_USER_INPUT) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_ENTRY_DATA) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, + data=IMPORT_DATA.copy(), ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -129,7 +124,8 @@ async def test_import_flow_already_configured( @pytest.mark.parametrize( ("side_effect", "reason"), [ - (ValueError, "invalid_auth"), + (ClientError, "cannot_connect"), + (InvalidAuthenticationError, "invalid_auth"), (Exception, "unknown"), ], ) @@ -138,23 +134,20 @@ async def test_import_flow_error( side_effect: Exception, reason: str, issue_registry: ir.IssueRegistry, + mock_authenticator_authenticate: AsyncMock, ) -> None: """Test handling invalid connection.""" - with patch( - "homeassistant.components.ecovacs.config_flow.EcoVacsAPI", - return_value=Mock(spec_set=EcoVacsAPI), - ) as mock_ecovacs: - mock_ecovacs.side_effect = side_effect + mock_authenticator_authenticate.side_effect = side_effect - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=_USER_INPUT, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == reason - assert ( - DOMAIN, - f"deprecated_yaml_import_issue_{reason}", - ) in issue_registry.issues - mock_ecovacs.assert_called() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=IMPORT_DATA.copy(), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues + mock_authenticator_authenticate.assert_called() diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py new file mode 100644 index 00000000000..e6be4e22233 --- /dev/null +++ b/tests/components/ecovacs/test_init.py @@ -0,0 +1,85 @@ +"""Test init of ecovacs.""" +from typing import Any +from unittest.mock import AsyncMock, Mock + +from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +import pytest + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_api_client") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test the Ecovacs configuration entry not ready.""" + mock_api_client.get_devices.side_effect = DeebotError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: Mock, +) -> None: + """Test auth error during setup.""" + mock_api_client.get_devices.side_effect = InvalidAuthenticationError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + ("config", "config_entries_expected"), + [ + ({}, 0), + ({DOMAIN: IMPORT_DATA.copy()}, 1), + ], +) +async def test_async_setup_import( + hass: HomeAssistant, + config: dict[str, Any], + config_entries_expected: int, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, +) -> None: + """Test async_setup config import.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected + assert mock_setup_entry.call_count == config_entries_expected + assert mock_authenticator_authenticate.call_count == config_entries_expected From 01372024f5808f1d346c522d439051f1952a81de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 19 Jan 2024 16:56:56 +0100 Subject: [PATCH 0783/1544] Add icon translations support (#103294) Co-authored-by: Robert Resch Co-authored-by: Paul Bottein --- .../components/binary_sensor/icons.json | 178 +++++++++++++++ homeassistant/components/demo/icons.json | 62 +++++ homeassistant/components/demo/number.py | 14 +- homeassistant/components/demo/remote.py | 7 +- homeassistant/components/demo/select.py | 3 - homeassistant/components/demo/switch.py | 8 +- homeassistant/components/demo/text.py | 6 - homeassistant/components/demo/time.py | 4 +- homeassistant/components/frontend/__init__.py | 24 ++ homeassistant/components/switch/icons.json | 24 ++ homeassistant/helpers/icon.py | 158 +++++++++++++ homeassistant/helpers/translation.py | 4 +- script/hassfest/__main__.py | 2 + script/hassfest/icons.py | 112 +++++++++ tests/components/frontend/test_init.py | 75 ++++++- .../google_assistant/test_smart_home.py | 1 - tests/helpers/test_icon.py | 212 ++++++++++++++++-- tests/helpers/test_translation.py | 4 +- .../custom_components/test/icons.json | 12 + .../test_embedded/icons.json | 12 + .../custom_components/test_package/icons.json | 15 ++ 21 files changed, 885 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/binary_sensor/icons.json create mode 100644 homeassistant/components/demo/icons.json create mode 100644 homeassistant/components/switch/icons.json create mode 100644 script/hassfest/icons.py create mode 100644 tests/testing_config/custom_components/test/icons.json create mode 100644 tests/testing_config/custom_components/test_embedded/icons.json create mode 100644 tests/testing_config/custom_components/test_package/icons.json diff --git a/homeassistant/components/binary_sensor/icons.json b/homeassistant/components/binary_sensor/icons.json new file mode 100644 index 00000000000..5bd1c338921 --- /dev/null +++ b/homeassistant/components/binary_sensor/icons.json @@ -0,0 +1,178 @@ +{ + "entity_component": { + "_": { + "default": "mdi:radiobox-blank", + "state": { + "on": "mdi:checkbox-marked-circle" + } + }, + "battery": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-outline" + } + }, + "battery_charging": { + "default": "mdi:battery", + "state": { + "on": "mdi:battery-charging" + } + }, + "carbon_monoxide": { + "default": "mdi:smoke-detector", + "state": { + "on": "mdi:smoke-detector-alert" + } + }, + "cold": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:snowflake" + } + }, + "connectivity": { + "default": "mdi:close-network-outline", + "state": { + "on": "mdi:check-network-outline" + } + }, + "door": { + "default": "mdi:door-closed", + "state": { + "on": "mdi:door-open" + } + }, + "garage_door": { + "default": "mdi:garage", + "state": { + "on": "mdi:garage-open" + } + }, + "gas": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "heat": { + "default": "mdi:thermometer", + "state": { + "on": "mdi:fire" + } + }, + "light": { + "default": "mdi:brightness-5", + "state": { + "on": "mdi:brightness-7" + } + }, + "lock": { + "default": "mdi:lock", + "state": { + "on": "mdi:lock-open" + } + }, + "moisture": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, + "motion": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } + }, + "moving": { + "default": "mdi:arrow-right", + "state": { + "on": "mdi:octagon" + } + }, + "occupancy": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "opening": { + "default": "mdi:square", + "state": { + "on": "mdi:square-outline" + } + }, + "plug": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "power": { + "default": "mdi:power-plug-off", + "state": { + "on": "mdi:power-plug" + } + }, + "presence": { + "default": "mdi:home-outline", + "state": { + "on": "mdi:home" + } + }, + "problem": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "running": { + "default": "mdi:stop", + "state": { + "on": "mdi:play" + } + }, + "safety": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "smoke": { + "default": "mdi:smoke-detector-variant", + "state": { + "on": "mdi:smoke-detector-variant-alert" + } + }, + "sound": { + "default": "mdi:music-note-off", + "state": { + "on": "mdi:music-note" + } + }, + "tamper": { + "default": "mdi:check-circle", + "state": { + "on": "mdi:alert-circle" + } + }, + "update": { + "default": "mdi:package", + "state": { + "on": "mdi:package-up" + } + }, + "vibration": { + "default": "mdi:crop-portrait", + "state": { + "on": "mdi:vibrate" + } + }, + "window": { + "default": "mdi:window-closed", + "state": { + "on": "mdi:window-open" + } + } + } +} diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json new file mode 100644 index 00000000000..79c18bc0a2e --- /dev/null +++ b/homeassistant/components/demo/icons.json @@ -0,0 +1,62 @@ +{ + "entity": { + "climate": { + "ubercool": { + "state_attributes": { + "fan_mode": { + "state": { + "auto_high": "mdi:fan-auto", + "auto_low": "mdi:fan-auto", + "on_high": "mdi:fan-chevron-up", + "on_low": "mdi:fan-chevron-down" + } + }, + "swing_mode": { + "state": { + "1": "mdi:numeric-1", + "2": "mdi:numeric-2", + "3": "mdi:numeric-3", + "auto": "mdi:arrow-oscillating", + "off": "mdi:arrow-oscillating-off" + } + } + } + } + }, + "number": { + "volume": { + "default": "mdi:volume-high" + }, + "pwm": { + "default": "mdi:square-wave" + }, + "range": { + "default": "mdi:square-wave" + } + }, + "select": { + "speed": { + "state": { + "light_speed": "mdi:speedometer-slow", + "ludicrous_speed": "mdi:speedometer-medium", + "ridiculous_speed": "mdi:speedometer" + } + } + }, + "sensor": { + "thermostat_mode": { + "state": { + "away": "mdi:home-export-outline", + "comfort": "mdi:home-account", + "eco": "mdi:leaf", + "sleep": "mdi:weather-night" + } + } + }, + "switch": { + "air_conditioner": { + "default": "mdi:air-conditioner" + } + } + } +} diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 5bc0462769d..db065054804 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -23,7 +23,7 @@ async def async_setup_entry( "volume1", "volume", 42.0, - "mdi:volume-high", + "volume", False, mode=NumberMode.SLIDER, ), @@ -31,7 +31,7 @@ async def async_setup_entry( "pwm1", "PWM 1", 0.42, - "mdi:square-wave", + "pwm", False, native_min_value=0.0, native_max_value=1.0, @@ -42,7 +42,7 @@ async def async_setup_entry( "large_range", "Large Range", 500, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=1000, @@ -52,7 +52,7 @@ async def async_setup_entry( "small_range", "Small Range", 128, - "mdi:square-wave", + "range", False, native_min_value=1, native_max_value=255, @@ -62,7 +62,7 @@ async def async_setup_entry( "temp1", "Temperature setting", 22, - "mdi:thermometer", + None, False, device_class=NumberDeviceClass.TEMPERATURE, native_min_value=15.0, @@ -87,7 +87,7 @@ class DemoNumber(NumberEntity): unique_id: str, device_name: str, state: float, - icon: str, + translation_key: str | None, assumed_state: bool, *, device_class: NumberDeviceClass | None = None, @@ -100,7 +100,7 @@ class DemoNumber(NumberEntity): """Initialize the Demo Number entity.""" self._attr_assumed_state = assumed_state self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_mode = mode self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 40df72b073b..f4f81a52052 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -19,8 +19,8 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoRemote("Remote One", False, None), - DemoRemote("Remote Two", True, "mdi:remote"), + DemoRemote("Remote One", False), + DemoRemote("Remote Two", True), ] ) @@ -30,11 +30,10 @@ class DemoRemote(RemoteEntity): _attr_should_poll = False - def __init__(self, name: str | None, state: bool, icon: str | None) -> None: + def __init__(self, name: str | None, state: bool) -> None: """Initialize the Demo Remote.""" self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_is_on = state - self._attr_icon = icon self._last_command_sent: str | None = None @property diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index 2a50b0151b6..58244e063f5 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -21,7 +21,6 @@ async def async_setup_entry( DemoSelect( unique_id="speed", device_name="Speed", - icon="mdi:speedometer", current_option="ridiculous_speed", options=[ "light_speed", @@ -45,7 +44,6 @@ class DemoSelect(SelectEntity): self, unique_id: str, device_name: str, - icon: str, current_option: str | None, options: list[str], translation_key: str, @@ -53,7 +51,6 @@ class DemoSelect(SelectEntity): """Initialize the Demo select entity.""" self._attr_unique_id = unique_id self._attr_current_option = current_option - self._attr_icon = icon self._attr_options = options self._attr_translation_key = translation_key self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index eac267c7c15..ac91b069d8d 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -20,13 +20,13 @@ async def async_setup_entry( """Set up the demo switch platform.""" async_add_entities( [ - DemoSwitch("switch1", "Decorative Lights", True, None, True), + DemoSwitch("switch1", "Decorative Lights", True, True), DemoSwitch( "switch2", "AC", False, - "mdi:air-conditioner", False, + translation_key="air_conditioner", device_class=SwitchDeviceClass.OUTLET, ), ] @@ -45,14 +45,14 @@ class DemoSwitch(SwitchEntity): unique_id: str, device_name: str, state: bool, - icon: str | None, assumed: bool, + translation_key: str | None = None, device_class: SwitchDeviceClass | None = None, ) -> None: """Initialize the Demo switch.""" self._attr_assumed_state = assumed self._attr_device_class = device_class - self._attr_icon = icon + self._attr_translation_key = translation_key self._attr_is_on = state self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index fecc1b95cf4..d7174002055 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -21,20 +21,17 @@ async def async_setup_entry( DemoText( unique_id="text", device_name="Text", - icon=None, native_value="Hello world", ), DemoText( unique_id="password", device_name="Password", - icon="mdi:text", native_value="Hello world", mode=TextMode.PASSWORD, ), DemoText( unique_id="text_1_to_5_char", device_name="Text with 1 to 5 characters", - icon="mdi:text", native_value="Hello", native_min=1, native_max=5, @@ -42,7 +39,6 @@ async def async_setup_entry( DemoText( unique_id="text_lowercase", device_name="Text with only lower case characters", - icon="mdi:text", native_value="world", pattern=r"[a-z]+", ), @@ -61,7 +57,6 @@ class DemoText(TextEntity): self, unique_id: str, device_name: str, - icon: str | None, native_value: str | None, mode: TextMode = TextMode.TEXT, native_max: int | None = None, @@ -71,7 +66,6 @@ class DemoText(TextEntity): """Initialize the Demo text entity.""" self._attr_unique_id = unique_id self._attr_native_value = native_value - self._attr_icon = icon self._attr_mode = mode if native_max is not None: self._attr_native_max = native_max diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index 56ab715a7f7..d0ec87386ef 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the demo time platform.""" - async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), False)]) class DemoTime(TimeEntity): @@ -33,12 +33,10 @@ class DemoTime(TimeEntity): unique_id: str, device_name: str, state: time, - icon: str, assumed_state: bool, ) -> None: """Initialize the Demo time entity.""" self._attr_assumed_state = assumed_state - self._attr_icon = icon self._attr_native_value = state self._attr_unique_id = unique_id diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d168dc2a6aa..09419f2d3bd 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations @@ -344,6 +345,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the serving of the frontend.""" await async_setup_frontend_storage(hass) + websocket_api.async_register_command(hass, websocket_get_icons) websocket_api.async_register_command(hass, websocket_get_panels) websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) @@ -647,6 +649,28 @@ class ManifestJSONView(HomeAssistantView): ) +@websocket_api.websocket_command( + { + "type": "frontend/get_icons", + vol.Required("category"): vol.In({"entity", "entity_component", "services"}), + vol.Optional("integration"): vol.All(cv.ensure_list, [str]), + } +) +@websocket_api.async_response +async def websocket_get_icons( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle get icons command.""" + resources = await async_get_icons( + hass, + msg["category"], + msg.get("integration"), + ) + connection.send_message( + websocket_api.result_message(msg["id"], {"resources": resources}) + ) + + @callback @websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels( diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json new file mode 100644 index 00000000000..00520914b9f --- /dev/null +++ b/homeassistant/components/switch/icons.json @@ -0,0 +1,24 @@ +{ + "entity_component": { + "_": { + "default": "mdi:toggle-switch-variant" + }, + "switch": { + "default": "mdi:toggle-switch-variant", + "state": { + "off": "mdi:toggle-switch-variant-off" + } + }, + "outlet": { + "default": "mdi:power-plug", + "state": { + "off": "mdi:power-plug-off" + } + } + }, + "services": { + "toggle": "mdi:toggle-switch-variant", + "turn_off": "mdi:toggle-switch-variant-off", + "turn_on": "mdi:toggle-switch-variant" + } +} diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 97e0d20927c..3486925b095 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,165 @@ """Icon helper methods.""" from __future__ import annotations +import asyncio +from collections.abc import Iterable from functools import lru_cache +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations +from homeassistant.util.json import load_json_object + +from .translation import build_resources + +ICON_LOAD_LOCK = "icon_load_lock" +ICON_CACHE = "icon_cache" + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _component_icons_path(component: str, integration: Integration) -> str | None: + """Return the icons json file location for a component. + + Ex: components/hue/icons.json + If component is just a single file, will return None. + """ + domain = component.rpartition(".")[-1] + + # If it's a component that is just one file, we don't support icons + # Example custom_components/my_component.py + if integration.file_path.name != domain: + return None + + return str(integration.file_path / "icons.json") + + +def _load_icons_files(icons_files: dict[str, str]) -> dict[str, dict[str, Any]]: + """Load and parse icons.json files.""" + return { + component: load_json_object(icons_file) + for component, icons_file in icons_files.items() + } + + +async def _async_get_component_icons( + hass: HomeAssistant, + components: set[str], + integrations: dict[str, Integration], +) -> dict[str, Any]: + """Load icons.""" + icons: dict[str, Any] = {} + + # Determine files to load + files_to_load = {} + for loaded in components: + domain = loaded.rpartition(".")[-1] + if (path := _component_icons_path(loaded, integrations[domain])) is None: + icons[loaded] = {} + else: + files_to_load[loaded] = path + + # Load files + if files_to_load and ( + load_icons_job := hass.async_add_executor_job(_load_icons_files, files_to_load) + ): + icons |= await load_icons_job + + return icons + + +class _IconsCache: + """Cache for icons.""" + + __slots__ = ("_hass", "_loaded", "_cache") + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the cache.""" + self._hass = hass + self._loaded: set[str] = set() + self._cache: dict[str, dict[str, Any]] = {} + + async def async_fetch( + self, + category: str, + components: set[str], + ) -> dict[str, dict[str, Any]]: + """Load resources into the cache.""" + if components_to_load := components - self._loaded: + await self._async_load(components_to_load) + + return { + component: result + for component in components + if (result := self._cache.get(category, {}).get(component)) + } + + async def _async_load(self, components: set[str]) -> None: + """Populate the cache for a given set of components.""" + _LOGGER.debug( + "Cache miss for: %s", + ", ".join(components), + ) + + integrations: dict[str, Integration] = {} + domains = list({loaded.rpartition(".")[-1] for loaded in components}) + ints_or_excs = await async_get_integrations(self._hass, domains) + for domain, int_or_exc in ints_or_excs.items(): + if isinstance(int_or_exc, Exception): + raise int_or_exc + integrations[domain] = int_or_exc + + icons = await _async_get_component_icons(self._hass, components, integrations) + + self._build_category_cache(components, icons) + self._loaded.update(components) + + @callback + def _build_category_cache( + self, + components: set[str], + icons: dict[str, dict[str, Any]], + ) -> None: + """Extract resources into the cache.""" + resource: dict[str, Any] | str + categories: set[str] = set() + for resource in icons.values(): + categories.update(resource) + + for category in categories: + new_resources = build_resources(icons, components, category) + for component, resource in new_resources.items(): + category_cache: dict[str, Any] = self._cache.setdefault(category, {}) + category_cache[component] = resource + + +async def async_get_icons( + hass: HomeAssistant, + category: str, + integrations: Iterable[str] | None = None, +) -> dict[str, Any]: + """Return all icons of integrations. + + If integration specified, load it for that one; otherwise default to loaded + intgrations. + """ + lock = hass.data.setdefault(ICON_LOAD_LOCK, asyncio.Lock()) + + if integrations: + components = set(integrations) + else: + components = { + component for component in hass.config.components if "." not in component + } + async with lock: + if ICON_CACHE in hass.data: + cache: _IconsCache = hass.data[ICON_CACHE] + else: + cache = hass.data[ICON_CACHE] = _IconsCache(hass) + + return await cache.async_fetch(category, components) @lru_cache diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 873d54e7165..f0b20c945db 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -129,7 +129,7 @@ def _merge_resources( return resources -def _build_resources( +def build_resources( translation_strings: dict[str, dict[str, Any]], components: set[str], category: str, @@ -304,7 +304,7 @@ class _TranslationCache: translation_strings, components, category ) else: - new_resources = _build_resources( + new_resources = build_resources( translation_strings, components, category ) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c454c69d141..308c006defc 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -17,6 +17,7 @@ from . import ( dependencies, dhcp, docker, + icons, json, manifest, metadata, @@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [ config_schema, dependencies, dhcp, + icons, json, manifest, mqtt, diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py new file mode 100644 index 00000000000..e82aed855b2 --- /dev/null +++ b/script/hassfest/icons.py @@ -0,0 +1,112 @@ +"""Validate integration icon translation files.""" +from __future__ import annotations + +from typing import Any + +import orjson +import voluptuous as vol +from voluptuous.humanize import humanize_error + +import homeassistant.helpers.config_validation as cv + +from .model import Config, Integration +from .translations import translation_key_validator + + +def icon_value_validator(value: Any) -> str: + """Validate that the icon is a valid icon.""" + value = cv.string_with_no_html(value) + if not value.startswith("mdi:"): + raise vol.Invalid( + "The icon needs to be a valid icon from Material Design Icons and start with `mdi:`" + ) + return str(value) + + +def require_default_icon_validator(value: dict) -> dict: + """Validate that a default icon is set.""" + if "_" not in value: + raise vol.Invalid( + "An entity component needs to have a default icon defined with `_`" + ) + return value + + +def icon_schema(integration_type: str) -> vol.Schema: + """Create a icon schema.""" + + state_validator = cv.schema_with_slug_keys( + icon_value_validator, + slug_validator=translation_key_validator, + ) + + def icon_schema_slug(marker: type[vol.Marker]) -> dict[vol.Marker, Any]: + return { + marker("default"): icon_value_validator, + vol.Optional("state"): state_validator, + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { + marker("default"): icon_value_validator, + marker("state"): state_validator, + }, + slug_validator=translation_key_validator, + ), + } + + base_schema = vol.Schema( + { + vol.Optional("services"): state_validator, + } + ) + + if integration_type == "entity": + return base_schema.extend( + { + vol.Required("entity_component"): vol.All( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Required), + slug_validator=vol.Any("_", cv.slug), + ), + require_default_icon_validator, + ) + } + ) + return base_schema.extend( + { + vol.Required("entity"): cv.schema_with_slug_keys( + cv.schema_with_slug_keys( + icon_schema_slug(vol.Optional), + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, + ), + } + ) + + +def validate_icon_file(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate icon file for integration.""" + icons_file = integration.path / "icons.json" + if not icons_file.is_file(): + return + + name = str(icons_file.relative_to(integration.path)) + + try: + icons = orjson.loads(icons_file.read_text()) + except ValueError as err: + integration.add_error("icons", f"Invalid JSON in {name}: {err}") + return + + schema = icon_schema(integration.integration_type) + + try: + schema(icons) + except vol.Invalid as err: + integration.add_error("icons", f"Invalid {name}: {humanize_error(icons, err)}") + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle JSON files inside integrations.""" + for integration in integrations.values(): + validate_icon_file(config, integration) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index e3f0d7f35d5..274d916f10d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -23,7 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockUser, async_capture_events, async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator MOCK_THEMES = { "happy": {"primary-color": "red", "app-header-background-color": "blue"}, @@ -664,3 +664,76 @@ async def test_static_path_cache(hass: HomeAssistant, mock_http_client) -> None: # and again to make sure the cache works resp = await mock_http_client.get("/static/does-not-exist", allow_redirects=False) assert resp.status == 404 + + +async def test_get_icons(hass: HomeAssistant, ws_client: MockHAClientWebSocket) -> None: + """Test get_icons command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: {}, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "category": "entity_component", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {}} + + +async def test_get_icons_for_integrations( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integrations command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": ["frontend", "http"], + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert set(msg["result"]["resources"]) == {"frontend", "http"} + + +async def test_get_icons_for_single_integration( + hass: HomeAssistant, ws_client: MockHAClientWebSocket +) -> None: + """Test get_icons for integration command.""" + with patch( + "homeassistant.components.frontend.async_get_icons", + side_effect=lambda hass, category, integrations: { + integration: {} for integration in integrations + }, + ): + await ws_client.send_json( + { + "id": 5, + "type": "frontend/get_icons", + "integration": "http", + "category": "entity", + } + ) + msg = await ws_client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"resources": {"http": {}}} diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bf48564c251..9063a8977f6 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -971,7 +971,6 @@ async def test_device_class_switch( None, "Demo Sensor", state=False, - icon="mdi:switch", assumed=False, device_class=device_class, ) diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index a7fe623ea7e..cf329100d75 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -1,18 +1,26 @@ """Test Home Assistant icon util methods.""" +import pathlib +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import icon +from homeassistant.loader import IntegrationNotFound +from homeassistant.setup import async_setup_component + def test_battery_icon() -> None: """Test icon generator for battery sensor.""" - from homeassistant.helpers.icon import icon_for_battery_level + assert icon.icon_for_battery_level(None, True) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(None, False) == "mdi:battery-unknown" - assert icon_for_battery_level(None, True) == "mdi:battery-unknown" - assert icon_for_battery_level(None, False) == "mdi:battery-unknown" + assert icon.icon_for_battery_level(5, True) == "mdi:battery-outline" + assert icon.icon_for_battery_level(5, False) == "mdi:battery-alert" - assert icon_for_battery_level(5, True) == "mdi:battery-outline" - assert icon_for_battery_level(5, False) == "mdi:battery-alert" - - assert icon_for_battery_level(100, True) == "mdi:battery-charging-100" - assert icon_for_battery_level(100, False) == "mdi:battery" + assert icon.icon_for_battery_level(100, True) == "mdi:battery-charging-100" + assert icon.icon_for_battery_level(100, False) == "mdi:battery" iconbase = "mdi:battery" for level in range(0, 100, 5): @@ -20,8 +28,8 @@ def test_battery_icon() -> None: "Level: %d. icon: %s, charging: %s" % ( level, - icon_for_battery_level(level, False), - icon_for_battery_level(level, True), + icon.icon_for_battery_level(level, False), + icon.icon_for_battery_level(level, True), ) ) if level <= 10: @@ -42,17 +50,183 @@ def test_battery_icon() -> None: postfix = "-alert" else: postfix = "" - assert iconbase + postfix == icon_for_battery_level(level, False) - assert iconbase + postfix_charging == icon_for_battery_level(level, True) + assert iconbase + postfix == icon.icon_for_battery_level(level, False) + assert iconbase + postfix_charging == icon.icon_for_battery_level(level, True) def test_signal_icon() -> None: """Test icon generator for signal sensor.""" - from homeassistant.helpers.icon import icon_for_signal_level + assert icon.icon_for_signal_level(None) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(0) == "mdi:signal-cellular-outline" + assert icon.icon_for_signal_level(5) == "mdi:signal-cellular-1" + assert icon.icon_for_signal_level(40) == "mdi:signal-cellular-2" + assert icon.icon_for_signal_level(80) == "mdi:signal-cellular-3" + assert icon.icon_for_signal_level(100) == "mdi:signal-cellular-3" - assert icon_for_signal_level(None) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(0) == "mdi:signal-cellular-outline" - assert icon_for_signal_level(5) == "mdi:signal-cellular-1" - assert icon_for_signal_level(40) == "mdi:signal-cellular-2" - assert icon_for_signal_level(80) == "mdi:signal-cellular-3" - assert icon_for_signal_level(100) == "mdi:signal-cellular-3" + +def test_load_icons_files(hass: HomeAssistant) -> None: + """Test the load icons files function.""" + file1 = hass.config.path("custom_components", "test", "icons.json") + file2 = hass.config.path("custom_components", "test", "invalid.json") + assert icon._load_icons_files({"test": file1, "invalid": file2}) == { + "test": { + "entity": { + "switch": { + "something": { + "state": {"away": "mdi:home-outline", "home": "mdi:home"} + } + } + }, + }, + "invalid": {}, + } + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_icons(hass: HomeAssistant) -> None: + """Test the get icon helper.""" + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + + icons = await icon.async_get_icons(hass, "entity_component") + assert icons == {} + + # Set up test switch component + assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}}) + + # Test getting icons for the entity component + icons = await icon.async_get_icons(hass, "entity_component") + assert icons["switch"]["_"]["default"] == "mdi:toggle-switch-variant" + + # Test services icons are available + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 1 + assert icons["switch"]["turn_off"] == "mdi:toggle-switch-variant-off" + + # Ensure icons file for platform isn't loaded, as that isn't supported + icons = await icon.async_get_icons(hass, "entity") + assert icons == {} + icons = await icon.async_get_icons(hass, "entity", ["test.switch"]) + assert icons == {} + + # Load up an custom integration + hass.config.components.add("test_package") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 1 + + assert icons == { + "test_package": { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + } + + icons = await icon.async_get_icons(hass, "services") + assert len(icons) == 2 + assert icons["test_package"]["enable_god_mode"] == "mdi:shield" + + # Load another one + hass.config.components.add("test_embedded") + await hass.async_block_till_done() + + icons = await icon.async_get_icons(hass, "entity") + assert len(icons) == 2 + + assert icons["test_package"] == { + "switch": { + "something": {"state": {"away": "mdi:home-outline", "home": "mdi:home"}} + } + } + + # Test getting non-existing integration + with pytest.raises( + IntegrationNotFound, match="Integration 'non_existing' not found" + ): + await icon.async_get_icons(hass, "entity", ["non_existing"]) + + +async def test_get_icons_while_loading_components(hass: HomeAssistant) -> None: + """Test the get icons helper loads icons.""" + integration = Mock(file_path=pathlib.Path(__file__)) + integration.name = "Component 1" + hass.config.components.add("component1") + load_count = 0 + + def mock_load_icons_files(files): + """Mock load icon files.""" + nonlocal load_count + load_count += 1 + return {"component1": {"entity": {"climate": {"test": {"icon": "mdi:home"}}}}} + + with patch( + "homeassistant.helpers.icon._component_icons_path", + return_value="choochoo.json", + ), patch( + "homeassistant.helpers.icon._load_icons_files", + mock_load_icons_files, + ), patch( + "homeassistant.helpers.icon.async_get_integrations", + return_value={"component1": integration}, + ): + times = 5 + all_icons = [await icon.async_get_icons(hass, "entity") for _ in range(times)] + + assert all_icons == [ + {"component1": {"climate": {"test": {"icon": "mdi:home"}}}} + for _ in range(times) + ] + assert load_count == 1 + + +async def test_caching(hass: HomeAssistant) -> None: + """Test we cache data.""" + hass.config.components.add("binary_sensor") + hass.config.components.add("switch") + + # Patch with same method so we can count invocations + with patch( + "homeassistant.helpers.icon.build_resources", + side_effect=icon.build_resources, + ) as mock_build: + load1 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + load2 = await icon.async_get_icons(hass, "entity_component") + assert len(mock_build.mock_calls) == 2 + + assert load1 == load2 + + assert load1["binary_sensor"] + assert load1["switch"] + + load_switch_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_switch_only + assert list(load_switch_only) == ["switch"] + + load_binary_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"binary_sensor"} + ) + assert load_binary_sensor_only + assert list(load_binary_sensor_only) == ["binary_sensor"] + + # Check if new loaded component, trigger load + hass.config.components.add("media_player") + with patch( + "homeassistant.helpers.icon._load_icons_files", + side_effect=icon._load_icons_files, + ) as mock_load: + load_sensor_only = await icon.async_get_icons( + hass, "entity_component", integrations={"switch"} + ) + assert load_sensor_only + assert len(mock_load.mock_calls) == 0 + + await icon.async_get_icons( + hass, "entity_component", integrations={"media_player"} + ) + assert len(mock_load.mock_calls) == 1 diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 527e1a07c23..954c9ae7616 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -478,8 +478,8 @@ async def test_caching(hass: HomeAssistant) -> None: # Patch with same method so we can count invocations with patch( - "homeassistant.helpers.translation._build_resources", - side_effect=translation._build_resources, + "homeassistant.helpers.translation.build_resources", + side_effect=translation.build_resources, ) as mock_build: load_sensor_only = await translation.async_get_translations( hass, "en", "title", integrations={"sensor"} diff --git a/tests/testing_config/custom_components/test/icons.json b/tests/testing_config/custom_components/test/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} diff --git a/tests/testing_config/custom_components/test_embedded/icons.json b/tests/testing_config/custom_components/test_embedded/icons.json new file mode 100644 index 00000000000..45ac054199d --- /dev/null +++ b/tests/testing_config/custom_components/test_embedded/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + } +} diff --git a/tests/testing_config/custom_components/test_package/icons.json b/tests/testing_config/custom_components/test_package/icons.json new file mode 100644 index 00000000000..e82168d7a1a --- /dev/null +++ b/tests/testing_config/custom_components/test_package/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "something": { + "state": { + "away": "mdi:home-outline", + "home": "mdi:home" + } + } + } + }, + "services": { + "enable_god_mode": "mdi:shield" + } +} From f0077ac27e4bb7909b8f39698d5c012218d5aeeb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 17:09:51 +0100 Subject: [PATCH 0784/1544] Update coverage to 7.4.0 (#108370) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 39fd545dbe6..06853ec93a2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.4 +coverage==7.4.0 freezegun==1.3.1 mock-open==1.4.0 mypy==1.8.0 From 7e60979abe4217f9b355e298b4a95c3d6de1d64b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 19:06:52 +0100 Subject: [PATCH 0785/1544] Improve tplink_lte typing (#108393) --- .../components/tplink_lte/__init__.py | 40 +++++++++++-------- homeassistant/components/tplink_lte/notify.py | 12 +++--- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 5ac3085520e..d64dc003576 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -1,6 +1,9 @@ """Support for TP-Link LTE modems.""" +from __future__ import annotations + import asyncio import logging +from typing import Any import aiohttp import attr @@ -15,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType @@ -59,20 +62,20 @@ CONFIG_SCHEMA = vol.Schema( class ModemData: """Class for modem state.""" - host = attr.ib() - modem = attr.ib() + host: str = attr.ib() + modem: tp_connected.Modem = attr.ib() - connected = attr.ib(init=False, default=True) + connected: bool = attr.ib(init=False, default=True) @attr.s class LTEData: """Shared state.""" - websession = attr.ib() + websession: aiohttp.ClientSession = attr.ib() modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) - def get_modem_data(self, config): + def get_modem_data(self, config: dict[str, Any]) -> ModemData | None: """Get the requested or the only modem_data value.""" if CONF_HOST in config: return self.modem_data.get(config[CONF_HOST]) @@ -107,14 +110,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _setup_lte(hass, lte_config, delay=0): +async def _setup_lte( + hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0 +) -> None: """Set up a TP-Link LTE modem.""" - host = lte_config[CONF_HOST] - password = lte_config[CONF_PASSWORD] + host: str = lte_config[CONF_HOST] + password: str = lte_config[CONF_PASSWORD] - websession = hass.data[DATA_KEY].websession - modem = tp_connected.Modem(hostname=host, websession=websession) + lte_data: LTEData = hass.data[DATA_KEY] + modem = tp_connected.Modem(hostname=host, websession=lte_data.websession) modem_data = ModemData(host, modem) @@ -124,7 +129,7 @@ async def _setup_lte(hass, lte_config, delay=0): retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) @callback - def cleanup_retry(event): + def cleanup_retry(event: Event) -> None: """Clean up retry task resources.""" if not retry_task.done(): retry_task.cancel() @@ -132,20 +137,23 @@ async def _setup_lte(hass, lte_config, delay=0): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) -async def _login(hass, modem_data, password): +async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: """Log in and complete setup.""" await modem_data.modem.login(password=password) modem_data.connected = True - hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + lte_data: LTEData = hass.data[DATA_KEY] + lte_data.modem_data[modem_data.host] = modem_data - async def cleanup(event): + async def cleanup(event: Event) -> None: """Clean up resources.""" await modem_data.modem.logout() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) -async def _retry_login(hass, modem_data, password): +async def _retry_login( + hass: HomeAssistant, modem_data: ModemData, password: str +) -> None: """Sleep and retry setup.""" _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py index 890a4ff6c88..eb742a5e4e9 100644 --- a/homeassistant/components/tplink_lte/notify.py +++ b/homeassistant/components/tplink_lte/notify.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import attr import tp_connected @@ -11,7 +12,7 @@ from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_KEY +from . import DATA_KEY, LTEData _LOGGER = logging.getLogger(__name__) @@ -31,13 +32,14 @@ async def async_get_service( class TplinkNotifyService(BaseNotificationService): """Implementation of a notification service.""" - hass = attr.ib() - config = attr.ib() + hass: HomeAssistant = attr.ib() + config: dict[str, Any] = attr.ib() - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" - modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + lte_data: LTEData = self.hass.data[DATA_KEY] + modem_data = lte_data.get_modem_data(self.config) if not modem_data: _LOGGER.error("No modem available") return From 4e11001a087c1d4e848899488085cbec0dba2de9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 19 Jan 2024 19:39:59 +0100 Subject: [PATCH 0786/1544] Update boto3 to 1.33.13 and aiobotocore to 2.9.1 (#108384) --- homeassistant/components/amazon_polly/manifest.json | 2 +- homeassistant/components/aws/manifest.json | 2 +- homeassistant/components/route53/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index 57971899cc0..55137b58832 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/amazon_polly", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.28.17"] + "requirements": ["boto3==1.33.13"] } diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index c93a8493845..61caf4c2318 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aws", "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], - "requirements": ["aiobotocore==2.6.0"] + "requirements": ["aiobotocore==2.9.1"] } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 644dcd499a0..3db91f7926f 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/route53", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.28.17"] + "requirements": ["boto3==1.33.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7915b5ae557..974fc95a0e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ aioazuredevops==1.3.5 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.6.0 +aiobotocore==2.9.1 # homeassistant.components.comelit aiocomelit==0.7.3 @@ -585,7 +585,7 @@ boschshcpy==0.2.75 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.28.17 +boto3==1.33.13 # homeassistant.components.broadlink broadlink==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ba1300638b..3e649fd709e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ aioazuredevops==1.3.5 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.6.0 +aiobotocore==2.9.1 # homeassistant.components.comelit aiocomelit==0.7.3 From 86f34f8216ae0c9980849bc7e5e2b359fea45f23 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:05:56 +0100 Subject: [PATCH 0787/1544] Add icon translations to Anova (#108399) --- homeassistant/components/anova/icons.json | 24 +++++++++++++++++++++++ homeassistant/components/anova/sensor.py | 14 ++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/anova/icons.json diff --git a/homeassistant/components/anova/icons.json b/homeassistant/components/anova/icons.json new file mode 100644 index 00000000000..9e0e88178d3 --- /dev/null +++ b/homeassistant/components/anova/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "cook_time": { + "default": "mdi:clock-outline" + }, + "target_temperature": { + "default": "mdi:thermometer" + }, + "cook_time_remaining": { + "default": "mdi:clock-outline" + }, + "heater_temperature": { + "default": "mdi:thermometer" + }, + "triac_temperature": { + "default": "mdi:thermometer" + }, + "water_temperature": { + "default": "mdi:thermometer" + } + } + } +} diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index b7657e26249..24bda4dbed6 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -42,30 +42,31 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:clock-outline", translation_key="cook_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time, ), AnovaSensorEntityDescription( - key="state", translation_key="state", value_fn=lambda data: data.state + key="state", + translation_key="state", + value_fn=lambda data: data.state, ), AnovaSensorEntityDescription( - key="mode", translation_key="mode", value_fn=lambda data: data.mode + key="mode", + translation_key="mode", + value_fn=lambda data: data.mode, ), AnovaSensorEntityDescription( key="target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="target_temperature", value_fn=lambda data: data.target_temperature, ), AnovaSensorEntityDescription( key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:clock-outline", translation_key="cook_time_remaining", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.cook_time_remaining, @@ -75,7 +76,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="heater_temperature", value_fn=lambda data: data.heater_temperature, ), @@ -84,7 +84,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="triac_temperature", value_fn=lambda data: data.triac_temperature, ), @@ -93,7 +92,6 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:thermometer", translation_key="water_temperature", value_fn=lambda data: data.water_temperature, ), From 5a1d44773020979d6bc20bdbdba1c0a414fd1fac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:06:06 +0100 Subject: [PATCH 0788/1544] Add icon translations to AirQ (#108402) --- homeassistant/components/airq/icons.json | 24 ++++++++++++++++++++++++ homeassistant/components/airq/sensor.py | 9 --------- 2 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/airq/icons.json diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json new file mode 100644 index 00000000000..fec6eb8dd86 --- /dev/null +++ b/homeassistant/components/airq/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "health_index": { + "default": "mdi:heart-pulse" + }, + "absolute_humidity": { + "default": "mdi:water" + }, + "oxygen": { + "default": "mdi:leaf" + }, + "performance_index": { + "default": "mdi:head-check" + }, + "radon": { + "default": "mdi:radioactive" + }, + "virus_index": { + "default": "mdi:virus-off" + } + } + } +} diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index f1fdfb289dd..ad05202943f 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -190,7 +190,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="health_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:heart-pulse", value=lambda data: data.get("health", 0.0) / 10.0, ), AirQEntityDescription( @@ -206,7 +205,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), - icon="mdi:water", ), AirQEntityDescription( key="h2_M1000", @@ -263,7 +261,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("oxygen"), - icon="mdi:leaf", ), AirQEntityDescription( key="o3", @@ -277,7 +274,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="performance_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), AirQEntityDescription( @@ -293,7 +289,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm1"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pm2_5", @@ -301,7 +296,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm2_5"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pm10", @@ -309,7 +303,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("pm10"), - icon="mdi:dots-hexagon", ), AirQEntityDescription( key="pressure", @@ -376,7 +369,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("radon"), - icon="mdi:radioactive", ), AirQEntityDescription( key="temperature", @@ -405,7 +397,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ translation_key="virus_index", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:virus-off", value=lambda data: data.get("virus", 0.0), ), ] From 4f4f22ba36cc0b26142f31f97571c37687d46a82 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:06:14 +0100 Subject: [PATCH 0789/1544] Add icon translations to Aurora (#108410) --- homeassistant/components/aurora/binary_sensor.py | 1 - homeassistant/components/aurora/entity.py | 2 -- homeassistant/components/aurora/icons.json | 14 ++++++++++++++ homeassistant/components/aurora/sensor.py | 1 - 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/aurora/icons.json diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 49e25e55950..94e1f3fc2da 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -19,7 +19,6 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, translation_key="visibility_alert", - icon="mdi:hazard-lights", ) async_add_entries([entity]) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 1b7dfbe88e3..3aa917862fb 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -20,7 +20,6 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self, coordinator: AuroraDataUpdateCoordinator, translation_key: str, - icon: str, ) -> None: """Initialize the Aurora Entity.""" @@ -28,7 +27,6 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" - self._attr_icon = icon self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self._attr_unique_id)}, diff --git a/homeassistant/components/aurora/icons.json b/homeassistant/components/aurora/icons.json new file mode 100644 index 00000000000..64f9c85c31f --- /dev/null +++ b/homeassistant/components/aurora/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "binary_sensor": { + "visibility_alert": { + "default": "mdi:hazard-lights" + } + }, + "sensor": { + "visibility": { + "default": "mdi:gauge" + } + } + } +} diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 3ad36591f15..7801a84d58b 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -20,7 +20,6 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, translation_key="visibility", - icon="mdi:gauge", ) async_add_entries([entity]) From fff1fc8d19ad9ff209c4eaac88a9cf14ad734e37 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:07:04 +0100 Subject: [PATCH 0790/1544] Add icon translations to August (#108396) --- homeassistant/components/august/binary_sensor.py | 1 - homeassistant/components/august/icons.json | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/august/icons.json diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 056969921f0..3c2ea5b3faa 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -140,7 +140,6 @@ SENSOR_TYPES_VIDEO_DOORBELL = ( AugustDoorbellBinarySensorEntityDescription( key="image capture", translation_key="image_capture", - icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), diff --git a/homeassistant/components/august/icons.json b/homeassistant/components/august/icons.json new file mode 100644 index 00000000000..b654b6d912a --- /dev/null +++ b/homeassistant/components/august/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "binary_sensor": { + "image_capture": { + "default": "mdi:file-image" + } + } + } +} From 300b4f161c5bd961f9d7845794d2fa5383a2f63a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:07:20 +0100 Subject: [PATCH 0791/1544] Add icon translations to Aussie Broadband (#108409) --- .../components/aussie_broadband/icons.json | 39 +++++++++++++++++++ .../components/aussie_broadband/sensor.py | 12 ------ 2 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/aussie_broadband/icons.json diff --git a/homeassistant/components/aussie_broadband/icons.json b/homeassistant/components/aussie_broadband/icons.json new file mode 100644 index 00000000000..2b1a2439904 --- /dev/null +++ b/homeassistant/components/aussie_broadband/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "data_used": { + "default": "mdi:network" + }, + "downloaded": { + "default": "mdi:download-network" + }, + "uploaded": { + "default": "mdi:upload-network" + }, + "national_calls": { + "default": "mdi:phone" + }, + "mobile_calls": { + "default": "mdi:phone" + }, + "international_calls": { + "default": "mdi:phone-plus" + }, + "sms_sent": { + "default": "mdi:message-processing" + }, + "voicemail_calls": { + "default": "mdi:phone" + }, + "other_calls": { + "default": "mdi:phone" + }, + "billing_cycle_length": { + "default": "mdi:calendar-range" + }, + "billing_cycle_remaining": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index efc8ae99ef9..d92ba503412 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -38,7 +38,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:network", ), SensorValueEntityDescription( key="downloadedMb", @@ -46,7 +45,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download-network", ), SensorValueEntityDescription( key="uploadedMb", @@ -54,21 +52,18 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload-network", ), # Mobile Phone Services sensors SensorValueEntityDescription( key="national", translation_key="national_calls", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="mobile", translation_key="mobile_calls", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -76,14 +71,12 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="international_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone-plus", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="sms", translation_key="sms_sent", state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:message-processing", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -92,7 +85,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.KILOBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:network", value=lambda x: x.get("kbytes"), ), SensorValueEntityDescription( @@ -100,7 +92,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="voicemail_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), SensorValueEntityDescription( @@ -108,7 +99,6 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( translation_key="other_calls", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:phone", value=lambda x: x.get("calls"), ), # Generic sensors @@ -116,13 +106,11 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( key="daysTotal", translation_key="billing_cycle_length", native_unit_of_measurement=UnitOfTime.DAYS, - icon="mdi:calendar-range", ), SensorValueEntityDescription( key="daysRemaining", translation_key="billing_cycle_remaining", native_unit_of_measurement=UnitOfTime.DAYS, - icon="mdi:calendar-clock", ), ) From 2f227677b61960d135d1c7b3ed8b57e0f9eb554f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:08:34 +0100 Subject: [PATCH 0792/1544] Add icon translations to awair (#108408) --- homeassistant/components/awair/icons.json | 9 +++++++++ homeassistant/components/awair/sensor.py | 2 -- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/awair/icons.json diff --git a/homeassistant/components/awair/icons.json b/homeassistant/components/awair/icons.json new file mode 100644 index 00000000000..895cac81869 --- /dev/null +++ b/homeassistant/components/awair/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "score": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 698850d6a49..03243e51b7c 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -64,7 +64,6 @@ class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMix SENSOR_TYPE_SCORE = AwairSensorEntityDescription( key=API_SCORE, - icon="mdi:blur", native_unit_of_measurement=PERCENTAGE, translation_key="score", unique_id_tag="score", # matches legacy format @@ -96,7 +95,6 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( ), AwairSensorEntityDescription( key=API_VOC, - icon="mdi:molecule", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, unique_id_tag="VOC", # matches legacy format From 3a6e640c7322486b98bf77f21bf86194c9967bb0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:09:12 +0100 Subject: [PATCH 0793/1544] Add icon translations to Airnow (#108403) --- homeassistant/components/airnow/icons.json | 18 ++++++++++++++++++ homeassistant/components/airnow/sensor.py | 6 ++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/airnow/icons.json diff --git a/homeassistant/components/airnow/icons.json b/homeassistant/components/airnow/icons.json new file mode 100644 index 00000000000..0815109b6e9 --- /dev/null +++ b/homeassistant/components/airnow/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "aqi": { + "default": "mdi:blur" + }, + "pm25": { + "default": "mdi:blur" + }, + "o3": { + "default": "mdi:blur" + }, + "station": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 9c154dc0712..bfe9e92c4a3 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -77,7 +77,7 @@ def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, - icon="mdi:blur", + translation_key="aqi", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.AQI, value_fn=lambda data: data.get(ATTR_API_AQI), @@ -94,7 +94,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( ), AirNowEntityDescription( key=ATTR_API_PM25, - icon="mdi:blur", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PM25, @@ -104,7 +104,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_O3, translation_key="o3", - icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.get(ATTR_API_O3), @@ -113,7 +112,6 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_STATION, translation_key="station", - icon="mdi:blur", value_fn=lambda data: data.get(ATTR_API_STATION), extra_state_attributes_fn=station_extra_attrs, ), From 51dca6690880352f17cf7190d5928c75e5830e7e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:10:31 +0100 Subject: [PATCH 0794/1544] Add icon translations to AsusWRT (#108397) --- homeassistant/components/asuswrt/icons.json | 30 +++++++++++++++++++++ homeassistant/components/asuswrt/sensor.py | 8 ------ 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/asuswrt/icons.json diff --git a/homeassistant/components/asuswrt/icons.json b/homeassistant/components/asuswrt/icons.json new file mode 100644 index 00000000000..a4e44496a2f --- /dev/null +++ b/homeassistant/components/asuswrt/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "devices_connected": { + "default": "mdi:router-network" + }, + "download_speed": { + "default": "mdi:download-network" + }, + "upload_speed": { + "default": "mdi:upload-network" + }, + "download": { + "default": "mdi:download" + }, + "upload": { + "default": "mdi:upload" + }, + "load_avg_1m": { + "default": "mdi:cpu-32-bit" + }, + "load_avg_5m": { + "default": "mdi:cpu-32-bit" + }, + "load_avg_15m": { + "default": "mdi:cpu-32-bit" + } + } + } +} diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index f1296befbba..3399071daa4 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -51,14 +51,12 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_CONNECTED_DEVICE[0], translation_key="devices_connected", - icon="mdi:router-network", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], translation_key="download_speed", - icon="mdi:download-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, @@ -69,7 +67,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_RATES[1], translation_key="upload_speed", - icon="mdi:upload-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, @@ -80,7 +77,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_BYTES[0], translation_key="download", - icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -91,7 +87,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_BYTES[1], translation_key="upload", - icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -102,7 +97,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[0], translation_key="load_avg_1m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -111,7 +105,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[1], translation_key="load_avg_5m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -120,7 +113,6 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[2], translation_key="load_avg_15m", - icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, From 94b39941e2c5418283cd70f94ba612e183b971c5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:11:19 +0100 Subject: [PATCH 0795/1544] Add icon translations to Airthings BLE (#108401) --- .../components/airthings_ble/icons.json | 18 ++++++++++++++++++ .../components/airthings_ble/sensor.py | 5 ----- 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/airthings_ble/icons.json diff --git a/homeassistant/components/airthings_ble/icons.json b/homeassistant/components/airthings_ble/icons.json new file mode 100644 index 00000000000..4cc618ef98c --- /dev/null +++ b/homeassistant/components/airthings_ble/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "radon_1day_avg": { + "default": "mdi:radioactive" + }, + "radon_longterm_avg": { + "default": "mdi:radioactive" + }, + "radon_1day_level": { + "default": "mdi:radioactive" + }, + "radon_longterm_level": { + "default": "mdi:radioactive" + } + } + } +} diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index c4797713bb8..39c55e0b465 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -52,24 +52,20 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { translation_key="radon_1day_avg", native_unit_of_measurement=VOLUME_BECQUEREL, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:radioactive", ), "radon_longterm_avg": SensorEntityDescription( key="radon_longterm_avg", translation_key="radon_longterm_avg", native_unit_of_measurement=VOLUME_BECQUEREL, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:radioactive", ), "radon_1day_level": SensorEntityDescription( key="radon_1day_level", translation_key="radon_1day_level", - icon="mdi:radioactive", ), "radon_longterm_level": SensorEntityDescription( key="radon_longterm_level", translation_key="radon_longterm_level", - icon="mdi:radioactive", ), "temperature": SensorEntityDescription( key="temperature", @@ -107,7 +103,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:cloud", ), "illuminance": SensorEntityDescription( key="illuminance", From 4f998acb7808ba46e9ca61c587e8b710334156a7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:28:36 +0100 Subject: [PATCH 0796/1544] Add icon translations to Ambient station (#108400) --- .../components/ambient_station/icons.json | 27 +++++++++++++++++++ .../components/ambient_station/sensor.py | 7 ----- 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/ambient_station/icons.json diff --git a/homeassistant/components/ambient_station/icons.json b/homeassistant/components/ambient_station/icons.json new file mode 100644 index 00000000000..c5103bfd12e --- /dev/null +++ b/homeassistant/components/ambient_station/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "last_rain": { + "default": "mdi:water" + }, + "lightning_strikes_per_day": { + "default": "mdi:lightning-bolt" + }, + "lightning_strikes_per_hour": { + "default": "mdi:lightning-bolt" + }, + "wind_direction": { + "default": "mdi:weather-windy" + }, + "wind_direction_average_10m": { + "default": "mdi:weather-windy" + }, + "wind_direction_average_2m": { + "default": "mdi:weather-windy" + }, + "wind_gust_direction": { + "default": "mdi:weather-windy" + } + } + } +} diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 4873da566b5..951bfc5c8ff 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -281,20 +281,17 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_LASTRAIN, translation_key="last_rain", - icon="mdi:water", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_DAY, translation_key="lightning_strikes_per_day", - icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_HOUR, translation_key="lightning_strikes_per_hour", - icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", state_class=SensorStateClass.TOTAL, ), @@ -595,25 +592,21 @@ SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_WINDDIR, translation_key="wind_direction", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, translation_key="wind_direction_average_10m", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG2M, translation_key="wind_direction_average_2m", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, translation_key="wind_gust_direction", - icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( From 5d5a2d1381fde23b9d72f4a767ebeeda555d382f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 19 Jan 2024 20:29:52 +0100 Subject: [PATCH 0797/1544] Add icon translations to Aseko (#108398) --- .../components/aseko_pool_live/binary_sensor.py | 1 - .../components/aseko_pool_live/icons.json | 17 +++++++++++++++++ .../components/aseko_pool_live/sensor.py | 2 -- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/aseko_pool_live/icons.json diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index cc91b6b97a6..e0b45ee6d4f 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -38,7 +38,6 @@ UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( AsekoBinarySensorEntityDescription( key="water_flow", translation_key="water_flow", - icon="mdi:waves-arrow-right", value_fn=lambda unit: unit.water_flow, ), AsekoBinarySensorEntityDescription( diff --git a/homeassistant/components/aseko_pool_live/icons.json b/homeassistant/components/aseko_pool_live/icons.json new file mode 100644 index 00000000000..2f8a77fc417 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "binary_sensor": { + "water_flow": { + "default": "mdi:waves-arrow-right" + } + }, + "sensor": { + "free_chlorine": { + "default": "mdi:flask" + }, + "water_temperature": { + "default": "mdi:coolant-temperature" + } + } + } +} diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 14eedd279b8..55a40195750 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -59,10 +59,8 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_native_unit_of_measurement = self._variable.unit self._attr_icon = { - "clf": "mdi:flask", "rx": "mdi:test-tube", "waterLevel": "mdi:waves", - "waterTemp": "mdi:coolant-temperature", }.get(self._variable.type) self._attr_device_class = { From 2b389739d3372040b530cb2b151aa3b3a59a043c Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sat, 20 Jan 2024 03:48:08 +0100 Subject: [PATCH 0798/1544] Bump async-upnp-client to 0.38.1 (#108382) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index e2a07a3e351..ab5d035dd54 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 6173c9a3843..d4a74725467 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.38.0"], + "requirements": ["async-upnp-client==0.38.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 2b388cf706a..780d47e4743 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.38.0" + "async-upnp-client==0.38.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index e6f18190c0b..8afed8b4fd1 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.38.0"] + "requirements": ["async-upnp-client==0.38.1"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 4b6badb0d3c..8ce32158016 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 4881d8c576d..f2a11aaf1fe 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.0"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 85db3c67ec6..67acbe7cfa6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiohttp-zlib-ng==0.3.1 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.38.0 +async-upnp-client==0.38.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 974fc95a0e3..94b071bd8d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -472,7 +472,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.38.0 +async-upnp-client==0.38.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e649fd709e..8594202bb6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -424,7 +424,7 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.38.0 +async-upnp-client==0.38.1 # homeassistant.components.sleepiq asyncsleepiq==1.4.2 From b612fafb9b978255862464b8afc1ae869c45c599 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sat, 20 Jan 2024 04:04:03 +0100 Subject: [PATCH 0799/1544] Use async_create_clientsession for enigma2 (#108395) Use async_creeate_clientsession for enigma2 --- homeassistant/components/enigma2/media_player.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index aa1b5270557..032669499d1 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -5,6 +5,7 @@ from aiohttp.client_exceptions import ClientConnectorError from openwebif.api import OpenWebIfDevice from openwebif.enums import RemoteControlCodes, SetVolumeOption import voluptuous as vol +from yarl import URL from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -22,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -88,12 +90,18 @@ async def async_setup_platform( config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - device = OpenWebIfDevice( + base_url = URL.build( + scheme="https" if config[CONF_SSL] else "http", host=config[CONF_HOST], port=config.get(CONF_PORT), - username=config.get(CONF_USERNAME), + user=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), - is_https=config[CONF_SSL], + ) + + session = async_create_clientsession(hass, verify_ssl=False, base_url=base_url) + + device = OpenWebIfDevice( + host=session, turn_off_to_deep=config.get(CONF_DEEP_STANDBY), source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) @@ -101,7 +109,6 @@ async def async_setup_platform( try: about = await device.get_about() except ClientConnectorError as err: - await device.close() raise PlatformNotReady from err async_add_entities([Enigma2Device(config[CONF_NAME], device, about)]) From fb17b451c7d78b026af3b606bbd55b6e21cecc64 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 04:05:00 +0100 Subject: [PATCH 0800/1544] Add alarm_control_panel icon translations (#108413) * Add alarm_control_panel icon translations * Nest services correctly --- .../components/alarm_control_panel/icons.json | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 homeassistant/components/alarm_control_panel/icons.json diff --git a/homeassistant/components/alarm_control_panel/icons.json b/homeassistant/components/alarm_control_panel/icons.json new file mode 100644 index 00000000000..62a9eee2915 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/icons.json @@ -0,0 +1,26 @@ +{ + "entity_component": { + "_": { + "default": "mdi:shield", + "state": { + "armed_away": "mdi:shield-lock", + "armed_custom_bypass": "mdi:security", + "armed_home": "mdi:shield-home", + "armed_night": "mdi:shield-moon", + "armed_vacation": "mdi:shield-airplane", + "disarmed": "mdi:shield-off", + "pending": "mdi:shield-outline", + "triggered": "mdi:bell-ring" + } + } + }, + "services": { + "alarm_arm_away": "mdi:shield-lock", + "alarm_arm_home": "mdi:shield-home", + "alarm_arm_night": "mdi:shield-moon", + "alarm_custom_bypass": "mdi:security", + "alarm_disarm": "mdi:shield-off", + "alarm_trigger": "mdi:bell-ring", + "arlam_arm_vacation": "mdi:shield-airplane" + } +} From 1e32f96b0c8ffaada370765bdc22d5a6d2b771b8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 04:06:15 +0100 Subject: [PATCH 0801/1544] Add button icon translations (#108415) --- homeassistant/components/button/icons.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 homeassistant/components/button/icons.json diff --git a/homeassistant/components/button/icons.json b/homeassistant/components/button/icons.json new file mode 100644 index 00000000000..71956124d7f --- /dev/null +++ b/homeassistant/components/button/icons.json @@ -0,0 +1,19 @@ +{ + "entity_component": { + "_": { + "default": "mdi:button-pointer" + }, + "restart": { + "default": "mdi:restart" + }, + "identify": { + "default": "mdi:crosshairs-question" + }, + "update": { + "default": "mdi:package-up" + } + }, + "services": { + "press": "mdi:gesture-tap-button" + } +} From 0f1cb8fa5c4327b8cfe63ada0c3bec496106d181 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 04:07:14 +0100 Subject: [PATCH 0802/1544] Add calendar icon translations (#108416) --- homeassistant/components/calendar/icons.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 homeassistant/components/calendar/icons.json diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json new file mode 100644 index 00000000000..06911cb2e6e --- /dev/null +++ b/homeassistant/components/calendar/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar" + } + }, + "services": { + "create_event": "mdi:calendar-plus", + "get_events": "mdi:calendar-month", + "list_events": "mdi:calendar-month" + } +} From 16f6854f64d544b67e0dd8fa2648981f5b6b8941 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 04:09:39 +0100 Subject: [PATCH 0803/1544] Update psutil to 5.9.8 (#108421) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 213fa9cf6be..b93bdefd838 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.7"] + "requirements": ["psutil==5.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 94b071bd8d4..e8eb10d2306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1545,7 +1545,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.7 +psutil==5.9.8 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8594202bb6d..86a00bc7e73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1195,7 +1195,7 @@ prometheus-client==0.17.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.7 +psutil==5.9.8 # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 From 89570a73a7ebbd53928de562efc18a24abe44ff7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 04:50:11 +0100 Subject: [PATCH 0804/1544] Add air_quality icon translations (#108420) --- homeassistant/components/air_quality/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/air_quality/icons.json diff --git a/homeassistant/components/air_quality/icons.json b/homeassistant/components/air_quality/icons.json new file mode 100644 index 00000000000..f15cb508ba8 --- /dev/null +++ b/homeassistant/components/air_quality/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:air-filter" + } + } +} From 3184d3b168dcf2206a6f8e3c0439cfd2f08c680e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 17:56:35 -1000 Subject: [PATCH 0805/1544] Bump thermopro-ble to 0.8.0 (#108319) --- .../components/thermopro/manifest.json | 6 +++++- homeassistant/components/thermopro/sensor.py | 11 +++++++++++ homeassistant/generated/bluetooth.py | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/thermopro/test_sensor.py | 17 ++++++++++++----- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index a0a07d3cb00..237cd39fb66 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -9,6 +9,10 @@ { "local_name": "TP39*", "connectable": false + }, + { + "local_name": "TP96*", + "connectable": false } ], "codeowners": ["@bdraco"], @@ -16,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.5.0"] + "requirements": ["thermopro-ble==0.8.0"] } diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 107385615f8..37cbf10323f 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -25,6 +25,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -59,6 +60,16 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + ( + ThermoProSensorDeviceClass.BATTERY, + Units.PERCENTAGE, + ): SensorEntityDescription( + key=f"{ThermoProSensorDeviceClass.BATTERY}_{Units.PERCENTAGE}", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index cda39d8494f..9e3504efca0 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -534,6 +534,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "thermopro", "local_name": "TP39*", }, + { + "connectable": False, + "domain": "thermopro", + "local_name": "TP96*", + }, { "domain": "tilt_ble", "manufacturer_data_start": [ diff --git a/requirements_all.txt b/requirements_all.txt index e8eb10d2306..c3c07cabae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2662,7 +2662,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.5.0 +thermopro-ble==0.8.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86a00bc7e73..35cf67e74a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2018,7 +2018,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.5.0 +thermopro-ble==0.8.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/thermopro/test_sensor.py b/tests/components/thermopro/test_sensor.py index 236a9d27df6..bead3a53dea 100644 --- a/tests/components/thermopro/test_sensor.py +++ b/tests/components/thermopro/test_sensor.py @@ -24,14 +24,21 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 inject_bluetooth_service_info(hass, TP357_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.tp357_2142_temperature") - temp_sensor_attribtes = temp_sensor.attributes + temp_sensor_attributes = temp_sensor.attributes assert temp_sensor.state == "24.1" - assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Temperature" - assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" - assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Temperature" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp357_2142_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "100" + assert battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP357 (2142) Battery" + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 52b5d2e37005e3ff1ee7cdcd296a2c23576b66df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Jan 2024 19:22:17 -1000 Subject: [PATCH 0806/1544] Avoid json encoder default fallback when serializing config (#108360) Co-authored-by: Paulus Schoutsen --- homeassistant/core.py | 9 +++--- tests/components/api/test_init.py | 22 ++++++++------ tests/components/mobile_app/test_webhook.py | 2 +- .../components/websocket_api/test_commands.py | 29 +++++++++---------- tests/test_core.py | 8 ++--- 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index bef89118de9..9c7689f483b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2416,6 +2416,7 @@ class Config: Async friendly. """ + allowlist_external_dirs = list(self.allowlist_external_dirs) return { "latitude": self.latitude, "longitude": self.longitude, @@ -2423,12 +2424,12 @@ class Config: "unit_system": self.units.as_dict(), "location_name": self.location_name, "time_zone": self.time_zone, - "components": self.components, + "components": list(self.components), "config_dir": self.config_dir, # legacy, backwards compat - "whitelist_external_dirs": self.allowlist_external_dirs, - "allowlist_external_dirs": self.allowlist_external_dirs, - "allowlist_external_urls": self.allowlist_external_urls, + "whitelist_external_dirs": allowlist_external_dirs, + "allowlist_external_dirs": allowlist_external_dirs, + "allowlist_external_urls": list(self.allowlist_external_urls), "version": __version__, "config_source": self.config_source, "recovery_mode": self.recovery_mode, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index d9c8e7481fa..0d6f2498c79 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -254,16 +254,20 @@ async def test_api_get_config(hass: HomeAssistant, mock_api_client: TestClient) """Test the return of the configuration.""" resp = await mock_api_client.get(const.URL_API_CONFIG) result = await resp.json() - if "components" in result: - result["components"] = set(result["components"]) - if "whitelist_external_dirs" in result: - result["whitelist_external_dirs"] = set(result["whitelist_external_dirs"]) - if "allowlist_external_dirs" in result: - result["allowlist_external_dirs"] = set(result["allowlist_external_dirs"]) - if "allowlist_external_urls" in result: - result["allowlist_external_urls"] = set(result["allowlist_external_urls"]) + ignore_order_keys = ( + "components", + "allowlist_external_dirs", + "whitelist_external_dirs", + "allowlist_external_urls", + ) + config = hass.config.as_dict() - assert hass.config.as_dict() == result + for key in ignore_order_keys: + if key in result: + result[key] = set(result[key]) + config[key] = set(config[key]) + + assert result == config async def test_api_get_components( diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index c5e5801cda8..f7581f03241 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -318,7 +318,7 @@ async def test_webhook_handle_get_config( "unit_system": hass_config["unit_system"], "location_name": hass_config["location_name"], "time_zone": hass_config["time_zone"], - "components": hass_config["components"], + "components": set(hass_config["components"]), "version": hass_config["version"], "theme_color": "#03A9F4", # Default frontend theme color "entities": { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 270ad9bf178..68e2e14a08c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -716,22 +716,21 @@ async def test_get_config( assert msg["type"] == const.TYPE_RESULT assert msg["success"] - if "components" in msg["result"]: - msg["result"]["components"] = set(msg["result"]["components"]) - if "whitelist_external_dirs" in msg["result"]: - msg["result"]["whitelist_external_dirs"] = set( - msg["result"]["whitelist_external_dirs"] - ) - if "allowlist_external_dirs" in msg["result"]: - msg["result"]["allowlist_external_dirs"] = set( - msg["result"]["allowlist_external_dirs"] - ) - if "allowlist_external_urls" in msg["result"]: - msg["result"]["allowlist_external_urls"] = set( - msg["result"]["allowlist_external_urls"] - ) + result = msg["result"] + ignore_order_keys = ( + "components", + "allowlist_external_dirs", + "whitelist_external_dirs", + "allowlist_external_urls", + ) + config = hass.config.as_dict() - assert msg["result"] == hass.config.as_dict() + for key in ignore_order_keys: + if key in result: + result[key] = set(result[key]) + config[key] = set(config[key]) + + assert result == config async def test_ping(websocket_client: MockHAClientWebSocket) -> None: diff --git a/tests/test_core.py b/tests/test_core.py index e6a1362a30e..01eb4c517b1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1622,11 +1622,11 @@ async def test_config_as_dict() -> None: CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(), "location_name": "Home", "time_zone": "UTC", - "components": set(), + "components": [], "config_dir": "/test/ha-config", - "whitelist_external_dirs": set(), - "allowlist_external_dirs": set(), - "allowlist_external_urls": set(), + "whitelist_external_dirs": [], + "allowlist_external_dirs": [], + "allowlist_external_urls": [], "version": __version__, "config_source": ha.ConfigSource.DEFAULT, "recovery_mode": False, From 2a58b6e56b03f4c08ee1ce21030093dfa3dbcaad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 08:35:49 +0100 Subject: [PATCH 0807/1544] Add light icon translations (#108414) --- homeassistant/components/light/icons.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 homeassistant/components/light/icons.json diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json new file mode 100644 index 00000000000..5113834e575 --- /dev/null +++ b/homeassistant/components/light/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:lightbulb" + } + }, + "services": { + "toggle": "mdi:lightbulb", + "turn_off": "mdi:lightbulb-off", + "turn_on": "mdi:lightbulb-on" + } +} From 4a824284d6207d93cbf9fcda593cd62bf467b8b1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 08:51:23 +0100 Subject: [PATCH 0808/1544] Mark flaky fritz update test as xfail (#108447) --- tests/components/fritz/test_update.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index bc677e28ebe..5cb9d4d3d69 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -47,6 +49,7 @@ async def test_update_entities_initialized( assert len(updates) == 1 +@pytest.mark.xfail(reason="Flaky test") async def test_update_available( hass: HomeAssistant, hass_client: ClientSessionGenerator, From d3bb33bd502b348ab54c74782334b59175cd3f55 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 09:10:22 +0100 Subject: [PATCH 0809/1544] Add climate icon translations (#108418) --- homeassistant/components/climate/icons.json | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 homeassistant/components/climate/icons.json diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json new file mode 100644 index 00000000000..c9e0319924e --- /dev/null +++ b/homeassistant/components/climate/icons.json @@ -0,0 +1,71 @@ +{ + "entity_component": { + "_": { + "default": "mdi:thermostat", + "state": { + "auto": "mdi:thermostat-auto", + "cool": "mdi:snowflake", + "dry": "mdi:water-percent", + "fan_mode": "mdi:fan", + "heat": "mdi:fire", + "heat_cool": "mdi:sun-snowflake-variant", + "off": "mdi:power" + }, + "state_attributes": { + "fan_mode": { + "default": "mdi:circle-medium", + "state": { + "diffuse": "mdi:weather-windy", + "focus": "mdi:target", + "high": "mdi:speedometer", + "low": "mdi:speedometer-slow", + "medium": "mdi:speedometer-medium", + "middle": "mdi:speedometer-medium", + "off": "mdi:fan-off", + "on": "mdi:fan" + } + }, + "hvac_action": { + "default": "mdi:circle-medium", + "state": { + "cooling": "mdi:snowflake", + "drying": "mdi:water-percent", + "fan": "mdi:fan", + "heating": "mdi:fire", + "idle": "mdi:clock-outline", + "off": "mdi:power", + "preheating": "mdi:heat-wave" + } + }, + "preset_mode": { + "default": "mdi:circle-medium", + "state": { + "activity": "mdi:motion-sensor", + "away": "mdi:account-arrow-right", + "boost": "mdi:rocket-launch", + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "home": "mdi:home", + "sleep": "mdi:bed" + } + }, + "swing_mode": { + "default": "mdi:circle-medium", + "state": { + "both": "mdi:arrow-all", + "horizontal": "mdi:arrow-left-right", + "off": "mdi:arrow-oscillating-off", + "on": "mdi:arrow-oscillating", + "vertical": "mdi:arrow-up-down" + } + } + } + } + }, + "services": { + "set_fan_mode": "mdi:fan", + "set_humidity": "mdi:humidity-percent", + "set_swing_mode": "mdi:arrow-oscillating", + "set_temperature": "mdi:thermometer" + } +} From 4f7ce28cb8da6a59c232da7dc2a7201ebd223217 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:03:57 +0100 Subject: [PATCH 0810/1544] Add date icon translations (#108448) --- homeassistant/components/date/icons.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 homeassistant/components/date/icons.json diff --git a/homeassistant/components/date/icons.json b/homeassistant/components/date/icons.json new file mode 100644 index 00000000000..80ec2691285 --- /dev/null +++ b/homeassistant/components/date/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar" + } + }, + "services": { + "set_value": "mdi:calendar-edit" + } +} From 5a56cf3922430383632fc497b3fd1d38918122f9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:04:14 +0100 Subject: [PATCH 0811/1544] Add datetime icon translations (#108449) --- homeassistant/components/datetime/icons.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 homeassistant/components/datetime/icons.json diff --git a/homeassistant/components/datetime/icons.json b/homeassistant/components/datetime/icons.json new file mode 100644 index 00000000000..563d03e2a8f --- /dev/null +++ b/homeassistant/components/datetime/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:calendar-clock" + } + }, + "services": { + "set_value": "mdi:calendar-edit" + } +} From 206e6dfd6207b696b5d93bdc8c13212a653c2546 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:05:16 +0100 Subject: [PATCH 0812/1544] Add sensor icon translations (#108450) * Add sensor icon translations * Add missing moisture --- homeassistant/components/sensor/icons.json | 154 +++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 homeassistant/components/sensor/icons.json diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json new file mode 100644 index 00000000000..7a709228d3f --- /dev/null +++ b/homeassistant/components/sensor/icons.json @@ -0,0 +1,154 @@ +{ + "entity_component": { + "_": { + "default": "mdi:eye" + }, + "apparent_power": { + "default": "mdi:flash" + }, + "aqi": { + "default": "mdi:air-filter" + }, + "atmospheric_pressure": { + "default": "mdi:thermometer-lines" + }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "current": { + "default": "mdi:current-ac" + }, + "data_rate": { + "default": "mdi:transmission-tower" + }, + "data_size": { + "default": "mdi:database" + }, + "date": { + "default": "mdi:calendar" + }, + "distance": { + "default": "mdi:arrow-left-right" + }, + "duration": { + "default": "mdi:progress-clock" + }, + "energy": { + "default": "mdi:lightning-bolt" + }, + "energy_storage": { + "default": "mdi:car-battery" + }, + "enum": { + "default": "mdi:eye" + }, + "frequency": { + "default": "mdi:sine-wave" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "humidity": { + "default": "mdi:water-percent" + }, + "illuminance": { + "default": "mdi:brightness-5" + }, + "irradiance": { + "default": "mdi:sun-wireless" + }, + "moisture": { + "default": "mdi:water-percent" + }, + "monetary": { + "default": "mdi:cash" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "nitrogen_monoxide": { + "default": "mdi:molecule" + }, + "nitrous_oxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "ph": { + "default": "mdi:ph" + }, + "pm1": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "power": { + "default": "mdi:flash" + }, + "power_factor": { + "default": "mdi:angle-acute" + }, + "preciptation": { + "default": "mdi:weather-rainy" + }, + "preciptation_intensity": { + "default": "mdi:weather-pouring" + }, + "pressure": { + "default": "mdi:gauge" + }, + "reactive_power": { + "default": "mdi:flash" + }, + "signal_strength": { + "default": "mdi:wifi" + }, + "sound_pressure": { + "default": "mdi:ear-hearing" + }, + "speed": { + "default": "mdi:speedometer" + }, + "sulfur_dioxide": { + "default": "mdi:molecule" + }, + "temperature": { + "default": "mdi:thermometer" + }, + "timestamp": { + "default": "mdi:clock" + }, + "volatile_organic_compounds": { + "default": "mdi:molecule" + }, + "volatile_organic_compounds_parts": { + "default": "mdi:molecule" + }, + "voltage": { + "default": "mdi:sine-wave" + }, + "volume": { + "default": "mdi:car-coolant-level" + }, + "volume_storage": { + "default": "mdi:storage-tank" + }, + "water": { + "default": "mdi:water" + }, + "weight": { + "default": "mdi:weight" + }, + "wind_speed": { + "default": "mdi:weather-windy" + } + } +} From a9723df96c8cf1523bcc89ab9d9b00c4faa24320 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:05:53 +0100 Subject: [PATCH 0813/1544] Add image icon translations (#108455) --- homeassistant/components/image/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/image/icons.json diff --git a/homeassistant/components/image/icons.json b/homeassistant/components/image/icons.json new file mode 100644 index 00000000000..cec9c99d765 --- /dev/null +++ b/homeassistant/components/image/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:image" + } + } +} From 576230da408d269cbf3ac84684a84c35a772965f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:06:29 +0100 Subject: [PATCH 0814/1544] Add number icon translations (#108452) --- homeassistant/components/number/icons.json | 151 +++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 homeassistant/components/number/icons.json diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json new file mode 100644 index 00000000000..e6f9f6aa7c1 --- /dev/null +++ b/homeassistant/components/number/icons.json @@ -0,0 +1,151 @@ +{ + "entity_component": { + "_": { + "default": "mdi:ray-vertex" + }, + "apparent_power": { + "default": "mdi:flash" + }, + "aqi": { + "default": "mdi:air-filter" + }, + "atmospheric_pressure": { + "default": "mdi:thermometer-lines" + }, + "battery": { + "default": "mdi:battery" + }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "current": { + "default": "mdi:current-ac" + }, + "data_rate": { + "default": "mdi:transmission-tower" + }, + "data_size": { + "default": "mdi:database" + }, + "distance": { + "default": "mdi:arrow-left-right" + }, + "duration": { + "default": "mdi:progress-clock" + }, + "energy": { + "default": "mdi:lightning-bolt" + }, + "energy_storage": { + "default": "mdi:car-battery" + }, + "frequency": { + "default": "mdi:sine-wave" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "humidity": { + "default": "mdi:water-percent" + }, + "illuminance": { + "default": "mdi:brightness-5" + }, + "irradiance": { + "default": "mdi:sun-wireless" + }, + "moisture": { + "default": "mdi:water-percent" + }, + "monetary": { + "default": "mdi:cash" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "nitrogen_monoxide": { + "default": "mdi:molecule" + }, + "nitrous_oxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "ph": { + "default": "mdi:ph" + }, + "pm1": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "power": { + "default": "mdi:flash" + }, + "power_factor": { + "default": "mdi:angle-acute" + }, + "preciptation": { + "default": "mdi:weather-rainy" + }, + "preciptation_intensity": { + "default": "mdi:weather-pouring" + }, + "pressure": { + "default": "mdi:gauge" + }, + "reactive_power": { + "default": "mdi:flash" + }, + "signal_strength": { + "default": "mdi:wifi" + }, + "sound_pressure": { + "default": "mdi:ear-hearing" + }, + "speed": { + "default": "mdi:speedometer" + }, + "sulfur_dioxide": { + "default": "mdi:molecule" + }, + "temperature": { + "default": "mdi:thermometer" + }, + "volatile_organic_compounds": { + "default": "mdi:molecule" + }, + "volatile_organic_compounds_parts": { + "default": "mdi:molecule" + }, + "voltage": { + "default": "mdi:sine-wave" + }, + "volume": { + "default": "mdi:car-coolant-level" + }, + "volume_storage": { + "default": "mdi:storage-tank" + }, + "water": { + "default": "mdi:water" + }, + "weight": { + "default": "mdi:weight" + }, + "wind_speed": { + "default": "mdi:weather-windy" + } + }, + "services": { + "set_value": "mdi:numeric" + } +} From dbaa02a5a89fe61f8086e8ca85327b151b152834 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:06:41 +0100 Subject: [PATCH 0815/1544] Add event icon translations (#108453) --- homeassistant/components/event/icons.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 homeassistant/components/event/icons.json diff --git a/homeassistant/components/event/icons.json b/homeassistant/components/event/icons.json new file mode 100644 index 00000000000..92f2e7a6546 --- /dev/null +++ b/homeassistant/components/event/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:eye-check" + }, + "button": { + "default": "mdi:gesture-tap-button" + }, + "doorbell": { + "default": "mdi:doorbell" + }, + "motion": { + "default": "mdi:motion-sensor" + } + } +} From b3017c0f4e3686e21809759f68b613ffa19f077b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:07:29 +0100 Subject: [PATCH 0816/1544] Add scene icon translations (#108456) --- homeassistant/components/scene/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/scene/icons.json diff --git a/homeassistant/components/scene/icons.json b/homeassistant/components/scene/icons.json new file mode 100644 index 00000000000..3ab7264b357 --- /dev/null +++ b/homeassistant/components/scene/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:palette" + } + } +} From 8dacb4f9eaaee0dbd4832f06e4605e0804928c6f Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 20 Jan 2024 11:16:56 +0100 Subject: [PATCH 0817/1544] Add icon translations to AVM FRITZ!Box Call Monitor (#108417) * Add icon translations to AVM FRITZ!Box Call Monitor * Update homeassistant/components/fritzbox_callmonitor/icons.json Co-authored-by: Franck Nijhof * Update homeassistant/components/fritzbox_callmonitor/icons.json Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- .../components/fritzbox_callmonitor/const.py | 2 -- .../components/fritzbox_callmonitor/icons.json | 14 ++++++++++++++ .../components/fritzbox_callmonitor/sensor.py | 2 -- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/fritzbox_callmonitor/icons.json diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index 75050374e52..a13a86574df 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -14,8 +14,6 @@ class FritzState(StrEnum): DISCONNECT = "DISCONNECT" -ICON_PHONE: Final = "mdi:phone" - ATTR_PREFIXES = "prefixes" FRITZ_ATTR_NAME = "name" diff --git a/homeassistant/components/fritzbox_callmonitor/icons.json b/homeassistant/components/fritzbox_callmonitor/icons.json new file mode 100644 index 00000000000..836d3159681 --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "sensor": { + "fritzbox_callmonitor": { + "default": "mdi:phone", + "state": { + "ringing": "mdi:phone-incoming", + "dialing": "mdi:phone-outgoing", + "talking": "mdi:phone-in-talk" + } + } + } + } +} diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 03ac98419c1..036c9605d0a 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -26,7 +26,6 @@ from .const import ( CONF_PREFIXES, DOMAIN, FRITZBOX_PHONEBOOK, - ICON_PHONE, MANUFACTURER, SERIAL_NUMBER, FritzState, @@ -79,7 +78,6 @@ async def async_setup_entry( class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" - _attr_icon = ICON_PHONE _attr_has_entity_name = True _attr_translation_key = DOMAIN _attr_device_class = SensorDeviceClass.ENUM From b2ba80877945df9c3c8b75321254d42e581073bb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 11:39:13 +0100 Subject: [PATCH 0818/1544] Add camera icon translations (#108419) Co-authored-by: Paulus Schoutsen --- homeassistant/components/camera/icons.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 homeassistant/components/camera/icons.json diff --git a/homeassistant/components/camera/icons.json b/homeassistant/components/camera/icons.json new file mode 100644 index 00000000000..37e71c80a67 --- /dev/null +++ b/homeassistant/components/camera/icons.json @@ -0,0 +1,19 @@ +{ + "entity_component": { + "_": { + "default": "mdi:video", + "state": { + "off": "mdi:video-off" + } + } + }, + "services": { + "disable_motion_detection": "mdi:motion-sensor-off", + "enable_motion_detection": "mdi:motion-sensor", + "play_stream": "mdi:play", + "record": "mdi:record-rec", + "snapshot": "mdi:camera", + "turn_off": "mdi:video-off", + "turn_on": "mdi:video" + } +} From 8c55f8e7f52801128f88d1def4fccb9398d6d84b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:27:36 +0100 Subject: [PATCH 0819/1544] Add lock icon translations (#108467) --- homeassistant/components/lock/icons.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 homeassistant/components/lock/icons.json diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json new file mode 100644 index 00000000000..1bf48f2ab40 --- /dev/null +++ b/homeassistant/components/lock/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:lock", + "state": { + "jammed": "mdi:lock-alert", + "locking": "mdi:lock-clock", + "unlocked": "mdi:lock-open", + "unlocking": "mdi:lock-clock" + } + } + }, + "services": { + "lock": "mdi:lock", + "open": "mdi:door-open", + "unlock": "mdi:lock-open" + } +} From 618cfe587ae169b79945085a708a2cd43577c8e9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:28:11 +0100 Subject: [PATCH 0820/1544] Ensure pre-commit runs hassfest when icons change (#108470) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79bf7e87903..0db0244edc9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata From 2c8981e100d673ebbe13ddee3b36319bc91fc761 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:28:23 +0100 Subject: [PATCH 0821/1544] Add text icon translations (#108457) --- homeassistant/components/text/icons.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 homeassistant/components/text/icons.json diff --git a/homeassistant/components/text/icons.json b/homeassistant/components/text/icons.json new file mode 100644 index 00000000000..355c439ec33 --- /dev/null +++ b/homeassistant/components/text/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:form-textbox" + } + }, + "services": { + "set_value": "mdi:form-textbox" + } +} From 5afbd34c64a1c14068d10292b8878696a77fc131 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:28:41 +0100 Subject: [PATCH 0822/1544] Add humidifier icon translations (#108465) --- homeassistant/components/humidifier/icons.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 homeassistant/components/humidifier/icons.json diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json new file mode 100644 index 00000000000..54a01ebaae5 --- /dev/null +++ b/homeassistant/components/humidifier/icons.json @@ -0,0 +1,17 @@ +{ + "entity_component": { + "_": { + "default": "mdi:air-humidifier", + "state": { + "off": "mdi:air-humidifier-off" + } + } + }, + "services": { + "set_humidity": "mdi:water-percent", + "set_mode": "mdi:air-humidifier", + "toggle": "mdi:air-humidifier", + "turn_off": "mdi:air-humidifier-off", + "turn_on": "mdi:air-humidifier" + } +} From 16a85ab910fd55de51b0c4a12cf84bd447903818 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:29:08 +0100 Subject: [PATCH 0823/1544] Add geo_location icon translations (#108463) --- homeassistant/components/geo_location/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/geo_location/icons.json diff --git a/homeassistant/components/geo_location/icons.json b/homeassistant/components/geo_location/icons.json new file mode 100644 index 00000000000..0341ec9483e --- /dev/null +++ b/homeassistant/components/geo_location/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:map-marker" + } + } +} From a3619e544eabe1b8feb7acf73bc3e37c32c68faa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 13:29:43 +0100 Subject: [PATCH 0824/1544] Add fan icon translations (#108461) --- homeassistant/components/fan/icons.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 homeassistant/components/fan/icons.json diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json new file mode 100644 index 00000000000..512427c9508 --- /dev/null +++ b/homeassistant/components/fan/icons.json @@ -0,0 +1,20 @@ +{ + "entity_component": { + "_": { + "default": "mdi:fan", + "state": { + "off": "mdi:fan-off" + } + } + }, + "services": { + "decrease_speed": "mdi:fan-minus", + "increase_speed": "mdi:fan-plus", + "oscillate": "mdi:arrow-oscillating", + "set_percentage": "mdi:fan", + "set_preset_mode": "mdi:fan-auto", + "toggle": "mdi:fan", + "turn_off": "mdi:fan-off", + "turn_on": "mdi:fan" + } +} From 1cb5bbf865f53986ec219b4f9da3966d561ad928 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 20 Jan 2024 15:12:32 +0100 Subject: [PATCH 0825/1544] Fix empty files included by !include_dir_named (#108489) Co-authored-by: Joost Lekkerkerker --- homeassistant/util/yaml/loader.py | 7 ++++++- tests/util/yaml/test_init.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index aac3e1274ee..51564b6da88 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -359,7 +359,12 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + # Special case, an empty file included by !include_dir_named is treated + # as an empty dictionary + loaded_yaml = NodeDictClass() + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 30637fe2785..93c8ed50498 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -193,7 +193,7 @@ def test_include_dir_list_recursive( ), ( {"/test/first.yaml": "1", "/test/second.yaml": None}, - {"first": 1, "second": None}, + {"first": 1, "second": {}}, ), ], ) From c2820e3cdee4847350903261b9177e6b46039f70 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 20 Jan 2024 16:03:46 +0100 Subject: [PATCH 0826/1544] Use right state class for volume and timestamp sensor in bthome (#107675) --- homeassistant/components/bthome/sensor.py | 5 ++--- tests/components/bthome/test_sensor.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index eb3a177804c..17f8f6c7a3c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -278,7 +278,6 @@ SENSOR_DESCRIPTIONS = { ): SensorEntityDescription( key=str(BTHomeSensorDeviceClass.TIMESTAMP), device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, ), # UV index (-) ( @@ -316,7 +315,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.VOLUME}_{Units.VOLUME_LITERS}", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.LITERS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), # Volume (mL) ( @@ -326,7 +325,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.VOLUME}_{Units.VOLUME_MILLILITERS}", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.MILLILITERS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), # Volume Flow Rate (m3/hour) ( diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 0220bf59d2c..481520f0434 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -819,7 +819,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_volume", "friendly_name": "Test Device 18B2 Volume", "unit_of_measurement": "L", - "state_class": "measurement", + "state_class": "total", "expected_state": "2215.1", }, ], @@ -836,7 +836,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_volume", "friendly_name": "Test Device 18B2 Volume", "unit_of_measurement": "mL", - "state_class": "measurement", + "state_class": "total", "expected_state": "34780", }, ], @@ -869,7 +869,7 @@ async def test_v1_sensors( { "sensor_entity": "sensor.test_device_18b2_timestamp", "friendly_name": "Test Device 18B2 Timestamp", - "state_class": "measurement", + "state_class": None, "expected_state": "2023-05-14T19:41:17+00:00", }, ], From 6901a80a708c577ea2e55da73c215fd28541b428 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 16:12:44 +0100 Subject: [PATCH 0827/1544] Add siren icon translations (#108473) --- homeassistant/components/siren/icons.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 homeassistant/components/siren/icons.json diff --git a/homeassistant/components/siren/icons.json b/homeassistant/components/siren/icons.json new file mode 100644 index 00000000000..0083a2540c7 --- /dev/null +++ b/homeassistant/components/siren/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:bullhorn" + } + }, + "services": { + "toggle": "mdi:bullhorn", + "turn_off": "mdi:bullhorn", + "turn_on": "mdi:bullhorn" + } +} From f40c8ce403b1053ab1162c25ab2073cb99c27b0c Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 20 Jan 2024 16:18:32 +0100 Subject: [PATCH 0828/1544] Add icon translations to Tankerkoenig (#108499) --- homeassistant/components/tankerkoenig/icons.json | 15 +++++++++++++++ homeassistant/components/tankerkoenig/sensor.py | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tankerkoenig/icons.json diff --git a/homeassistant/components/tankerkoenig/icons.json b/homeassistant/components/tankerkoenig/icons.json new file mode 100644 index 00000000000..594e016b112 --- /dev/null +++ b/homeassistant/components/tankerkoenig/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "e5": { + "default": "mdi:gas-station" + }, + "e10": { + "default": "mdi:gas-station" + }, + "diesel": { + "default": "mdi:gas-station" + } + } + } +} diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index c309536cb9c..9d839781990 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -60,7 +60,6 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): _attr_attribution = ATTRIBUTION _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = CURRENCY_EURO - _attr_icon = "mdi:gas-station" def __init__(self, fuel_type, station, coordinator, show_on_map): """Initialize the sensor.""" From 6cf8a3e5d1cbacc077a813b290d0964159786c5b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 18:08:35 +0100 Subject: [PATCH 0829/1544] Pin pandas to 2.1.4 (#108509) --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 67acbe7cfa6..386000a4ff6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -185,3 +185,6 @@ charset-normalizer==3.2.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 + +# Musle wheels for pandas 2.2.0 cannot be build for any architecture. +pandas==2.1.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 70d20f7e135..ee0eee21e59 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -178,6 +178,9 @@ charset-normalizer==3.2.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 + +# Musle wheels for pandas 2.2.0 cannot be build for any architecture. +pandas==2.1.4 """ GENERATED_MESSAGE = ( From aa6d058c10e922b5714cb89e3fb9dfa335621e43 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 20 Jan 2024 20:12:15 +0100 Subject: [PATCH 0830/1544] Update knx-frontend to 2024.1.20.105944 (#108511) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a233ca38705..4159a7a56a5 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,6 +13,6 @@ "requirements": [ "xknx==2.11.2", "xknxproject==3.4.0", - "knx-frontend==2023.6.23.191712" + "knx-frontend==2024.1.20.105944" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c3c07cabae8..5f5e21fab97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1162,7 +1162,7 @@ kegtron-ble==0.4.0 kiwiki-client==0.1.1 # homeassistant.components.knx -knx-frontend==2023.6.23.191712 +knx-frontend==2024.1.20.105944 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35cf67e74a1..e19d4196f90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -925,7 +925,7 @@ justnimbus==0.6.0 kegtron-ble==0.4.0 # homeassistant.components.knx -knx-frontend==2023.6.23.191712 +knx-frontend==2024.1.20.105944 # homeassistant.components.konnected konnected==1.2.0 From d24636b179dda105a5caf71ddda9268b1e206f37 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 20 Jan 2024 20:13:52 +0100 Subject: [PATCH 0831/1544] Upgrade nibe to 2.7.0 (#108507) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 94a2a76c814..c5c94145e4b 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.5.2"] + "requirements": ["nibe==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f5e21fab97..d5e537dc648 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1351,7 +1351,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.5.2 +nibe==2.7.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e19d4196f90..918b35516d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1072,7 +1072,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.5.2 +nibe==2.7.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From e71efa0e2b57b4e52d0d4eb6dc9790120bf2113f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:14:48 +0100 Subject: [PATCH 0832/1544] Add icon translations to HomeWizard Energy (#108506) * Add icon translations to HomeWizard Energy * Update snapshots --- .../components/homewizard/icons.json | 76 ++++++++++++ homeassistant/components/homewizard/number.py | 1 - homeassistant/components/homewizard/sensor.py | 17 --- homeassistant/components/homewizard/switch.py | 12 -- .../homewizard/snapshots/test_number.ambr | 3 +- .../homewizard/snapshots/test_sensor.ambr | 111 ++++++------------ .../homewizard/snapshots/test_switch.ambr | 12 +- 7 files changed, 118 insertions(+), 114 deletions(-) create mode 100644 homeassistant/components/homewizard/icons.json diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json new file mode 100644 index 00000000000..e6b1a34841f --- /dev/null +++ b/homeassistant/components/homewizard/icons.json @@ -0,0 +1,76 @@ +{ + "entity": { + "number": { + "status_light_brightness": { + "default": "mdi:lightbulb-on" + } + }, + "sensor": { + "active_liter_lpm": { + "default": "mdi:water" + }, + "active_tariff": { + "default": "mdi:calendar-clock" + }, + "any_power_fail_count": { + "default": "mdi:transmission-tower-off" + }, + "dsmr_version": { + "default": "mdi:counter" + }, + "gas_unique_id": { + "default": "mdi:alphabetical-variant" + }, + "long_power_fail_count": { + "default": "mdi:transmission-tower-off" + }, + "meter_model": { + "default": "mdi:gauge" + }, + "total_liter_m3": { + "default": "mdi:gauge" + }, + "unique_meter_id": { + "default": "mdi:alphabetical-variant" + }, + "voltage_sag_l1_count": { + "default": "mdi:alert" + }, + "voltage_sag_l2_count": { + "default": "mdi:alert" + }, + "voltage_sag_l3_count": { + "default": "mdi:alert" + }, + "voltage_swell_l1_count": { + "default": "mdi:alert" + }, + "voltage_swell_l2_count": { + "default": "mdi:alert" + }, + "voltage_swell_l3_count": { + "default": "mdi:alert" + }, + "wifi_ssid": { + "default": "mdi:wifi" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + }, + "switch": { + "cloud_connection": { + "default": "mdi:cloud", + "state": { + "off": "mdi:cloud-off-outline" + } + }, + "switch_lock": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-open" + } + } + } + } +} diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index ced870d7072..6145db125a1 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -29,7 +29,6 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): """Representation of status light number.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:lightbulb-on" _attr_translation_key = "status_light_brightness" _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 12655dbbc39..177fc3ef176 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -48,7 +48,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", translation_key="dsmr_version", - icon="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.smr_version is not None, value_fn=lambda data: data.smr_version, @@ -56,7 +55,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.meter_model is not None, value_fn=lambda data: data.meter_model, @@ -64,7 +62,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", - icon="mdi:alphabetical-variant", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.unique_meter_id is not None, value_fn=lambda data: data.unique_meter_id, @@ -72,7 +69,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.wifi_ssid is not None, value_fn=lambda data: data.wifi_ssid, @@ -80,7 +76,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - icon="mdi:calendar-clock", has_fn=lambda data: data.active_tariff is not None, value_fn=lambda data: ( None if data.active_tariff is None else str(data.active_tariff) @@ -91,7 +86,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="wifi_strength", translation_key="wifi_strength", - icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -315,7 +309,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_l1_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l1_count is not None, value_fn=lambda data: data.voltage_sag_l1_count, @@ -323,7 +316,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", translation_key="voltage_sag_l2_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l2_count is not None, value_fn=lambda data: data.voltage_sag_l2_count, @@ -331,7 +323,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", translation_key="voltage_sag_l3_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l3_count is not None, value_fn=lambda data: data.voltage_sag_l3_count, @@ -339,7 +330,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", translation_key="voltage_swell_l1_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l1_count is not None, value_fn=lambda data: data.voltage_swell_l1_count, @@ -347,7 +337,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", translation_key="voltage_swell_l2_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l2_count is not None, value_fn=lambda data: data.voltage_swell_l2_count, @@ -355,7 +344,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", translation_key="voltage_swell_l3_count", - icon="mdi:alert", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l3_count is not None, value_fn=lambda data: data.voltage_swell_l3_count, @@ -363,7 +351,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", - icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.any_power_fail_count is not None, value_fn=lambda data: data.any_power_fail_count, @@ -371,7 +358,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", - icon="mdi:transmission-tower-off", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.long_power_fail_count is not None, value_fn=lambda data: data.long_power_fail_count, @@ -404,7 +390,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="gas_unique_id", translation_key="gas_unique_id", - icon="mdi:alphabetical-variant", entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.gas_unique_id is not None, value_fn=lambda data: data.gas_unique_id, @@ -413,7 +398,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement="l/min", - icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, has_fn=lambda data: data.active_liter_lpm is not None, value_fn=lambda data: data.active_liter_lpm, @@ -422,7 +406,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="total_liter_m3", translation_key="total_liter_m3", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - icon="mdi:gauge", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: data.total_liter_m3 is not None, diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index fea4d7018bf..72e0f43a2cf 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -29,7 +29,6 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription): available_fn: Callable[[DeviceResponseEntry], bool] create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool] - icon_off: str | None = None is_on_fn: Callable[[DeviceResponseEntry], bool | None] set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]] @@ -48,8 +47,6 @@ SWITCHES = [ key="switch_lock", translation_key="switch_lock", entity_category=EntityCategory.CONFIG, - icon="mdi:lock", - icon_off="mdi:lock-open", create_fn=lambda coordinator: coordinator.supports_state(), available_fn=lambda data: data.state is not None, is_on_fn=lambda data: data.state.switch_lock if data.state else None, @@ -59,8 +56,6 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - icon="mdi:cloud", - icon_off="mdi:cloud-off-outline", create_fn=lambda coordinator: coordinator.supports_system(), available_fn=lambda data: data.system is not None, is_on_fn=lambda data: data.system.cloud_enabled if data.system else None, @@ -99,13 +94,6 @@ class HomeWizardSwitchEntity(HomeWizardEntity, SwitchEntity): self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - @property - def icon(self) -> str | None: - """Return the icon.""" - if self.entity_description.icon_off and self.is_on is False: - return self.entity_description.icon_off - return super().icon - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 5c7e71ea9ac..fc1c3c74a03 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Status light brightness', - 'icon': 'mdi:lightbulb-on', 'max': 100.0, 'min': 0.0, 'mode': , @@ -43,7 +42,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:lightbulb-on', + 'original_icon': None, 'original_name': 'Status light brightness', 'platform': 'homewizard', 'previous_unique_id': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index e237edee58e..032972a7901 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -788,7 +788,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:calendar-clock', + 'original_icon': None, 'original_name': 'Active tariff', 'platform': 'homewizard', 'previous_unique_id': None, @@ -803,7 +803,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'enum', 'friendly_name': 'Device Active tariff', - 'icon': 'mdi:calendar-clock', 'options': list([ '1', '2', @@ -1113,7 +1112,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1127,7 +1126,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -1191,7 +1189,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:counter', + 'original_icon': None, 'original_name': 'DSMR version', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1205,7 +1203,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device DSMR version', - 'icon': 'mdi:counter', }), 'context': , 'entity_id': 'sensor.device_dsmr_version', @@ -1267,7 +1264,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alphabetical-variant', + 'original_icon': None, 'original_name': 'Gas meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1281,7 +1278,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Gas meter identifier', - 'icon': 'mdi:alphabetical-variant', }), 'context': , 'entity_id': 'sensor.device_gas_meter_identifier', @@ -1343,7 +1339,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1357,7 +1353,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_long_power_failures_detected', @@ -1496,7 +1491,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1510,7 +1505,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_power_failures_detected', @@ -1572,7 +1566,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alphabetical-variant', + 'original_icon': None, 'original_name': 'Smart meter identifier', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1586,7 +1580,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter identifier', - 'icon': 'mdi:alphabetical-variant', }), 'context': , 'entity_id': 'sensor.device_smart_meter_identifier', @@ -1648,7 +1641,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Smart meter model', 'platform': 'homewizard', 'previous_unique_id': None, @@ -1662,7 +1655,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Smart meter model', - 'icon': 'mdi:gauge', }), 'context': , 'entity_id': 'sensor.device_smart_meter_model', @@ -2606,7 +2598,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2621,7 +2613,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -2685,7 +2676,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2699,7 +2690,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', @@ -2761,7 +2751,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2775,7 +2765,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', @@ -2837,7 +2826,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2851,7 +2840,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', @@ -2913,7 +2901,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, @@ -2927,7 +2915,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', @@ -2989,7 +2976,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3003,7 +2990,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', @@ -3065,7 +3051,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3079,7 +3065,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', @@ -3141,7 +3126,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3155,7 +3140,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -3219,7 +3203,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -3233,7 +3217,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -4268,7 +4251,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4282,7 +4265,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -4346,7 +4328,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Long power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4360,7 +4342,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Long power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_long_power_failures_detected', @@ -4499,7 +4480,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:transmission-tower-off', + 'original_icon': None, 'original_name': 'Power failures detected', 'platform': 'homewizard', 'previous_unique_id': None, @@ -4513,7 +4494,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Power failures detected', - 'icon': 'mdi:transmission-tower-off', }), 'context': , 'entity_id': 'sensor.device_power_failures_detected', @@ -5457,7 +5437,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5472,7 +5452,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -5536,7 +5515,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5550,7 +5529,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', @@ -5612,7 +5590,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5626,7 +5604,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', @@ -5688,7 +5665,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage sags detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5702,7 +5679,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage sags detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', @@ -5764,7 +5740,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 1', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5778,7 +5754,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 1', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', @@ -5840,7 +5815,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 2', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5854,7 +5829,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 2', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', @@ -5916,7 +5890,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alert', + 'original_icon': None, 'original_name': 'Voltage swells detected phase 3', 'platform': 'homewizard', 'previous_unique_id': None, @@ -5930,7 +5904,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Voltage swells detected phase 3', - 'icon': 'mdi:alert', }), 'context': , 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', @@ -6318,7 +6291,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6332,7 +6305,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -6396,7 +6368,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6410,7 +6382,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -6476,7 +6447,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water', + 'original_icon': None, 'original_name': 'Active water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6490,7 +6461,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Active water usage', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': 'l/min', }), @@ -6556,7 +6526,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Total water usage', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6571,7 +6541,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Device Total water usage', - 'icon': 'mdi:gauge', 'state_class': , 'unit_of_measurement': , }), @@ -6635,7 +6604,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6649,7 +6618,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -6713,7 +6681,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -6727,7 +6695,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -7117,7 +7084,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7131,7 +7098,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -7195,7 +7161,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7209,7 +7175,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), @@ -7765,7 +7730,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi SSID', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7779,7 +7744,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi SSID', - 'icon': 'mdi:wifi', }), 'context': , 'entity_id': 'sensor.device_wi_fi_ssid', @@ -7843,7 +7807,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:wifi', + 'original_icon': None, 'original_name': 'Wi-Fi strength', 'platform': 'homewizard', 'previous_unique_id': None, @@ -7857,7 +7821,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Wi-Fi strength', - 'icon': 'mdi:wifi', 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 0fb4680a0b1..8830ac2e9ed 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -79,7 +79,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -109,7 +108,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, @@ -155,7 +154,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Switch lock', - 'icon': 'mdi:lock-open', }), 'context': , 'entity_id': 'switch.device_switch_lock', @@ -185,7 +183,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:lock-open', + 'original_icon': None, 'original_name': 'Switch lock', 'platform': 'homewizard', 'previous_unique_id': None, @@ -231,7 +229,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -261,7 +258,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, @@ -307,7 +304,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Device Cloud connection', - 'icon': 'mdi:cloud', }), 'context': , 'entity_id': 'switch.device_cloud_connection', @@ -337,7 +333,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:cloud', + 'original_icon': None, 'original_name': 'Cloud connection', 'platform': 'homewizard', 'previous_unique_id': None, From 19bf8970d25db0314601565cd5efda0ae6ff6686 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:15:53 +0100 Subject: [PATCH 0833/1544] Add icon translations to Plugwise (#108498) --- .../components/plugwise/binary_sensor.py | 23 ---- homeassistant/components/plugwise/icons.json | 102 ++++++++++++++++++ homeassistant/components/plugwise/select.py | 4 - homeassistant/components/plugwise/sensor.py | 3 - homeassistant/components/plugwise/switch.py | 3 - 5 files changed, 102 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/plugwise/icons.json diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 0c67e20d7ab..c362652cf47 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -28,64 +28,48 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" key: BinarySensorType - icon_off: str | None = None BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( PlugwiseBinarySensorEntityDescription( key="compressor_state", translation_key="compressor_state", - icon="mdi:hvac", - icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", translation_key="cooling_enabled", - icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="dhw_state", translation_key="dhw_state", - icon="mdi:water-pump", - icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="flame_state", translation_key="flame_state", name="Flame state", - icon="mdi:fire", - icon_off="mdi:fire-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="heating_state", translation_key="heating_state", - icon="mdi:radiator", - icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="cooling_state", translation_key="cooling_state", - icon="mdi:snowflake", - icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", translation_key="slave_boiler_state", - icon="mdi:fire", - icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", translation_key="plugwise_notification", - icon="mdi:mailbox-up-outline", - icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -140,13 +124,6 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): """Return true if the binary sensor is on.""" return self.device["binary_sensors"][self.entity_description.key] - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - if (icon_off := self.entity_description.icon_off) and self.is_on is False: - return icon_off - return self.entity_description.icon - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes.""" diff --git a/homeassistant/components/plugwise/icons.json b/homeassistant/components/plugwise/icons.json new file mode 100644 index 00000000000..4af2c0b4c75 --- /dev/null +++ b/homeassistant/components/plugwise/icons.json @@ -0,0 +1,102 @@ +{ + "entity": { + "binary_sensor": { + "compressor_state": { + "default": "mdi:hvac", + "state": { + "off": "mdi:hvac-off" + } + }, + "cooling_enabled": { + "default": "mdi:snowflake-thermometer" + }, + "cooling_state": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + }, + "dhw_state": { + "default": "mdi:water-pump", + "state": { + "off": "mdi:water-pump-off" + } + }, + "flame_state": { + "default": "mdi:fire", + "state": { + "off": "mdi:fire-off" + } + }, + "heating_state": { + "default": "mdi:radiator", + "state": { + "off": "mdi:radiator-off" + } + }, + "plugwise_notification": { + "default": "mdi:mailbox-up-outline", + "state": { + "off": "mdi:mailbox-outline" + } + }, + "slave_boiler_state": { + "default": "mdi:fire", + "state": { + "off": "mdi:circle-off-outline" + } + } + }, + "climate": { + "plugwise": { + "state_attributes": { + "preset_mode": { + "state": { + "asleep": "mdi:weather-night", + "away": "mdi:account-arrow-right", + "home": "mdi:home", + "no_frost": "mdi:snowflake-melt", + "vacation": "mdi:beach" + } + } + } + } + }, + "select": { + "dhw_mode": { + "default": "mdi:shower" + }, + "gateway_mode": { + "default": "mdi:cog-outline" + }, + "regulation_mode": { + "default": "mdi:hvac" + }, + "select_schedule": { + "default": "mdi:calendar-clock" + } + }, + "sensor": { + "gas_consumed_interval": { + "default": "mdi:meter-gas" + }, + "modulation_level": { + "default": "mdi:percent" + }, + "valve_position": { + "default": "mdi:valve" + } + }, + "switch": { + "cooling_ena_switch": { + "default": "mdi:snowflake-thermometer" + }, + "dhw_cm_switch": { + "default": "mdi:water-plus" + }, + "lock": { + "default": "mdi:lock" + } + } + } +} diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index abb36bd5743..ff5eb3af4a5 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -30,14 +30,12 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_schedule", translation_key="select_schedule", - icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, STATE_ON, opt), options_key="available_schedules", ), PlugwiseSelectEntityDescription( key="select_regulation_mode", translation_key="regulation_mode", - icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), options_key="regulation_modes", @@ -45,7 +43,6 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_dhw_mode", translation_key="dhw_mode", - icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), options_key="dhw_modes", @@ -53,7 +50,6 @@ SELECT_TYPES = ( PlugwiseSelectEntityDescription( key="select_gateway_mode", translation_key="gateway_mode", - icon="mdi:cog-outline", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_gateway_mode(opt), options_key="gateway_modes", diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 95dfc2ba6a3..86992bb08f1 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -315,7 +315,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="gas_consumed_interval", translation_key="gas_consumed_interval", - icon="mdi:meter-gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), @@ -357,7 +356,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="modulation_level", translation_key="modulation_level", - icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -365,7 +363,6 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( PlugwiseSensorEntityDescription( key="valve_position", translation_key="valve_position", - icon="mdi:valve", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index dfd11127332..50e0a3cc4f8 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -33,13 +33,11 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( PlugwiseSwitchEntityDescription( key="dhw_cm_switch", translation_key="dhw_cm_switch", - icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, ), PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", - icon="mdi:lock", entity_category=EntityCategory.CONFIG, ), PlugwiseSwitchEntityDescription( @@ -51,7 +49,6 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( key="cooling_ena_switch", translation_key="cooling_ena_switch", name="Cooling", - icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, ), ) From 408ba4d8509b0519bcbde76eccadd6e0cf118253 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:20:48 +0100 Subject: [PATCH 0834/1544] Add water heater icon translations (#108491) --- .../components/water_heater/icons.json | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 homeassistant/components/water_heater/icons.json diff --git a/homeassistant/components/water_heater/icons.json b/homeassistant/components/water_heater/icons.json new file mode 100644 index 00000000000..af6996374c5 --- /dev/null +++ b/homeassistant/components/water_heater/icons.json @@ -0,0 +1,31 @@ +{ + "entity_component": { + "_": { + "default": "mdi:water-boiler", + "state": { + "off": "mdi:water-boiler-off" + }, + "state_attributes": { + "operation_mode": { + "default": "mdi:circle-medium", + "state": { + "eco": "mdi:leaf", + "electric": "mdi:lightning-bolt", + "gas": "mdi:fire-circle", + "heat_pump": "mdi:heat-wave", + "high_demand": "mdi:finance", + "off": "mdi:power", + "performance": "mdi:rocket-launch" + } + } + } + } + }, + "services": { + "set_away_mode": "mdi:account-arrow-right", + "set_operation_mode": "mdi:water-boiler", + "set_temperature": "mdi:thermometer", + "turn_off": "mdi:water-boiler-off", + "turn_on": "mdi:water-boiler" + } +} From 06d748cee7c723702125b54e04ee96b4d3d0044c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:21:28 +0100 Subject: [PATCH 0835/1544] Add weather icon translations (#108488) --- homeassistant/components/weather/icons.json | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 homeassistant/components/weather/icons.json diff --git a/homeassistant/components/weather/icons.json b/homeassistant/components/weather/icons.json new file mode 100644 index 00000000000..63daae20e3c --- /dev/null +++ b/homeassistant/components/weather/icons.json @@ -0,0 +1,28 @@ +{ + "entity_component": { + "_": { + "default": "mdi:weather-partly-cloudy", + "state": { + "clear-night": "mdi:weather-night", + "cloudy": "mdi:weather-cloudy", + "exceptional": "mdi:alert-circle-outline", + "fog": "mdi:weather-fog", + "hail": "mdi:weather-hail", + "lightning": "mdi:weather-lightning", + "lightning-rainy": "mdi:weather-lightning-rainy", + "partlycloudy": "mdi:weather-partly-cloudy", + "pouring": "mdi:weather-pouring", + "rainy": "mdi:weather-rainy", + "snowy": "mdi:weather-snowy", + "snowy-rainy": "mdi:weather-snowy-rainy", + "sunny": "mdi:weather-sunny", + "windy": "mdi:weather-windy", + "windy-variant": "mdi:weather-windy-variant" + } + } + }, + "services": { + "get_forecast": "mdi:weather-cloudy-clock", + "get_forecasts": "mdi:weather-cloudy-clock" + } +} From 858b380e6b250e832c16b98f99fce5a844ac6625 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:22:55 +0100 Subject: [PATCH 0836/1544] Add media player icon translations (#108486) --- .../components/media_player/icons.json | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 homeassistant/components/media_player/icons.json diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json new file mode 100644 index 00000000000..b79c66af0e9 --- /dev/null +++ b/homeassistant/components/media_player/icons.json @@ -0,0 +1,58 @@ +{ + "entity_component": { + "_": { + "default": "mdi:cast", + "state": { + "off": "mdi:cast", + "paused": "mdi:cast-connected", + "playing": "mdi:cast-connected" + } + }, + "receiver": { + "default": "mdi:audio-video", + "state": { + "off": "mdi:audio-video-off" + } + }, + "speaker": { + "default": "mdi:speaker", + "state": { + "off": "mdi:speaker-off", + "paused": "mdi:speaker-pause", + "playing": "mdi:speaker-play" + } + }, + "tv": { + "default": "mdi:television", + "state": { + "off": "mdi:television-off", + "paused": "mdi:television-pause", + "playing": "mdi:television-play" + } + } + }, + "services": { + "clear_playlist": "mdi:playlist-remove", + "join": "mdi:group", + "media_next_track": "mdi:skip-next", + "media_pause": "mdi:pause", + "media_play": "mdi:play", + "media_play_pause": "mdi:play-pause", + "media_previous_track": "mdi:skip-previous", + "media_seek": "mdi:fast-forward", + "media_stop": "mdi:stop", + "play_media": "mdi:play", + "repeat_set": "mdi:repeat", + "select_sound_mode": "mdi:surround-sound", + "select_source": "mdi:import", + "shuffle_set": "mdi:shuffle", + "toggle": "mdi:play-pause", + "turn_off": "mdi:power", + "turn_on": "mdi:power", + "unjoin": "mdi:ungroup", + "volume_down": "mdi:volume-minus", + "volume_mute": "mdi:volume-mute", + "volume_set": "mdi:volume", + "volume_up": "mdi:volume-plus" + } +} From 8a3b9000aa227c1687f81da5c26c914620f4c877 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:27:32 +0100 Subject: [PATCH 0837/1544] Add wake word icon translations (#108482) --- homeassistant/components/wake_word/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/wake_word/icons.json diff --git a/homeassistant/components/wake_word/icons.json b/homeassistant/components/wake_word/icons.json new file mode 100644 index 00000000000..c1deaeba5fb --- /dev/null +++ b/homeassistant/components/wake_word/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:chat-sleep" + } + } +} From bb225e4b38b69e866f2c35d5b4e81f957bb7d2f7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:28:07 +0100 Subject: [PATCH 0838/1544] Add update icon translations (#108481) * Add update icon translations * Oops --- homeassistant/components/update/icons.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 homeassistant/components/update/icons.json diff --git a/homeassistant/components/update/icons.json b/homeassistant/components/update/icons.json new file mode 100644 index 00000000000..96920c96253 --- /dev/null +++ b/homeassistant/components/update/icons.json @@ -0,0 +1,15 @@ +{ + "entity_component": { + "_": { + "default": "mdi:package-up", + "state": { + "off": "mdi:package" + } + } + }, + "services": { + "clear_skipped": "mdi:package", + "install": "mdi:package-down", + "skip": "mdi:package-check" + } +} From a0d9a1e5072388790b16ce1db8a065a1c3987052 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:28:57 +0100 Subject: [PATCH 0839/1544] Add valve icon translations (#108480) --- homeassistant/components/valve/icons.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 homeassistant/components/valve/icons.json diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json new file mode 100644 index 00000000000..349196658d4 --- /dev/null +++ b/homeassistant/components/valve/icons.json @@ -0,0 +1,20 @@ +{ + "entity_component": { + "_": { + "default": "mdi:pipe-valve" + }, + "gas": { + "default": "mdi:meter-gas" + }, + "water": { + "default": "mdi:pipe-valve" + } + }, + "services": { + "close_valve": "mdi:valve-closed", + "open_valve": "mdi:valve-open", + "set_valve_position": "mdi:valve", + "stop_valve": "mdi:stop", + "toggle": "mdi:valve-open" + } +} From 70084dcefaa55e51403dc7c1c36723bc89645d65 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:29:31 +0100 Subject: [PATCH 0840/1544] Add vacuum icon translations (#108479) --- homeassistant/components/vacuum/icons.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 homeassistant/components/vacuum/icons.json diff --git a/homeassistant/components/vacuum/icons.json b/homeassistant/components/vacuum/icons.json new file mode 100644 index 00000000000..25f0cfd03ef --- /dev/null +++ b/homeassistant/components/vacuum/icons.json @@ -0,0 +1,21 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot-vacuum" + } + }, + "services": { + "clean_spot": "mdi:target-variant", + "locate": "mdi:map-marker", + "pause": "mdi:pause", + "return_to_base": "mdi:home-import-outline", + "send_command": "mdi:send", + "set_fan_speed": "mdi:fan", + "start": "mdi:play", + "start_pause": "mdi:play-pause", + "stop": "mdi:stop", + "toggle": "mdi:play-pause", + "turn_off": "mdi:stop", + "turn_on": "mdi:play" + } +} From 0042a2fef261709407893f095e1a21af4836eb49 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 20 Jan 2024 20:31:57 +0100 Subject: [PATCH 0841/1544] Bump bthome-ble to 3.5.0 (#108475) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 53d25ce4c96..2a7cf84f16b 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.4.1"] + "requirements": ["bthome-ble==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d5e537dc648..8ec8525e36c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.4.1 +bthome-ble==3.5.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 918b35516d9..8d38f270322 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -508,7 +508,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.4.1 +bthome-ble==3.5.0 # homeassistant.components.buienradar buienradar==1.0.5 From 74ae79204a4bd8e8c044c09b266641bcaf8219ba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:32:24 +0100 Subject: [PATCH 0842/1544] Add tts icon translations (#108476) --- homeassistant/components/tts/icons.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 homeassistant/components/tts/icons.json diff --git a/homeassistant/components/tts/icons.json b/homeassistant/components/tts/icons.json new file mode 100644 index 00000000000..cda5f877b25 --- /dev/null +++ b/homeassistant/components/tts/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:speaker-message" + } + }, + "services": { + "clear_cache": "mdi:delete", + "say": "mdi:speaker-message", + "speak": "mdi:speaker-message" + } +} From 942636ffd64356c4d0959b9f8c9dbb8d22c3e5c7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:32:47 +0100 Subject: [PATCH 0843/1544] Add todo icon translations (#108477) --- homeassistant/components/todo/icons.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 homeassistant/components/todo/icons.json diff --git a/homeassistant/components/todo/icons.json b/homeassistant/components/todo/icons.json new file mode 100644 index 00000000000..05c9af74630 --- /dev/null +++ b/homeassistant/components/todo/icons.json @@ -0,0 +1,14 @@ +{ + "entity_component": { + "_": { + "default": "mdi:clipboard-list" + } + }, + "services": { + "add_item": "mdi:clipboard-plus", + "get_items": "mdi:clipboard-arrow-down", + "remove_completed_items": "mdi:clipboard-remove", + "remove_item": "mdi:clipboard-minus", + "update_item": "mdi:clipboard-edit" + } +} From 222e2c19f36ee0906416dca0f9ad011785667d3e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:33:08 +0100 Subject: [PATCH 0844/1544] Add select icon translations (#108472) --- homeassistant/components/select/icons.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 homeassistant/components/select/icons.json diff --git a/homeassistant/components/select/icons.json b/homeassistant/components/select/icons.json new file mode 100644 index 00000000000..1b440d2a1de --- /dev/null +++ b/homeassistant/components/select/icons.json @@ -0,0 +1,14 @@ +{ + "entity_component": { + "_": { + "default": "mdi:format-list-bulleted" + } + }, + "services": { + "select_first": "mdi:format-list-bulleted", + "select_last": "mdi:format-list-bulleted", + "select_next": "mdi:format-list-bulleted", + "select_option": "mdi:format-list-bulleted", + "select_previous": "mdi:format-list-bulleted" + } +} From a06308e6e673c8925bc7d093eefea4ab4becd05d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:33:27 +0100 Subject: [PATCH 0845/1544] Add stt icon translations (#108474) --- homeassistant/components/stt/icons.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 homeassistant/components/stt/icons.json diff --git a/homeassistant/components/stt/icons.json b/homeassistant/components/stt/icons.json new file mode 100644 index 00000000000..23aa9a611be --- /dev/null +++ b/homeassistant/components/stt/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:microphone-message" + } + } +} From 360ca9de34f3eaeca8c65bbb343a17fa3c1aa6b5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:33:47 +0100 Subject: [PATCH 0846/1544] Add remote icon translations (#108469) --- homeassistant/components/remote/icons.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 homeassistant/components/remote/icons.json diff --git a/homeassistant/components/remote/icons.json b/homeassistant/components/remote/icons.json new file mode 100644 index 00000000000..07526a4bc79 --- /dev/null +++ b/homeassistant/components/remote/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:remote", + "state": { + "off": "mdi:remote-off" + } + } + }, + "services": { + "delete_command": "mdi:delete", + "learn_command": "mdi:school", + "send_command": "mdi:remote", + "toggle": "mdi:remote", + "turn_off": "mdi:remote-off", + "turn_on": "mdi:remote" + } +} From 1804141b278d1e685e3b7d0ec7fed1e65673ab36 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:34:10 +0100 Subject: [PATCH 0847/1544] Add lawn mower icon translations (#108466) --- homeassistant/components/lawn_mower/icons.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 homeassistant/components/lawn_mower/icons.json diff --git a/homeassistant/components/lawn_mower/icons.json b/homeassistant/components/lawn_mower/icons.json new file mode 100644 index 00000000000..b25bf927fcd --- /dev/null +++ b/homeassistant/components/lawn_mower/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot-mower" + } + }, + "services": { + "dock": "mdi:home-import-outline", + "pause": "mdi:pause", + "start_mowing": "mdi:play" + } +} From 6374ee9378d50b9dd1b16f078aa10a2685b32b60 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:38:53 +0100 Subject: [PATCH 0848/1544] Add cover icon translations (#108460) * Add cover icon translations * States -> state --- homeassistant/components/cover/icons.json | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 homeassistant/components/cover/icons.json diff --git a/homeassistant/components/cover/icons.json b/homeassistant/components/cover/icons.json new file mode 100644 index 00000000000..d450070e631 --- /dev/null +++ b/homeassistant/components/cover/icons.json @@ -0,0 +1,96 @@ +{ + "entity_component": { + "_": { + "default": "mdi:window-open", + "state": { + "closed": "mdi:window-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "blind": { + "default": "mdi:blinds-horizontal", + "state": { + "closed": "mdi:blinds-horizontal-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "curtain": { + "default": "mdi:curtains", + "state": { + "closed": "mdi:curtains-closed", + "closing": "mdi:arrow-collapse-horizontal", + "opening": "mdi:arrow-split-vertical" + } + }, + "damper": { + "default": "mdi:circle", + "state": { + "closed": "mdi:circle-slice-8", + "closing": "mdi:circle", + "opening": "mdi:circle" + } + }, + "door": { + "default": "mdi:door-open", + "state": { + "closed": "mdi:door-closed", + "closing": "mdi:door-open", + "opening": "mdi:door-open" + } + }, + "garage": { + "default": "mdi:garage-open", + "state": { + "closed": "mdi:garage", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "gate": { + "default": "mdi:gate-open", + "state": { + "closed": "mdi:gate", + "closing": "mdi:arrow-right", + "opening": "mdi:arrow-right" + } + }, + "shade": { + "default": "mdi:roller-shade", + "state": { + "closed": "mdi:roller-shade-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "shutter": { + "default": "mdi:window-shutter-open", + "state": { + "closed": "mdi:window-shutter", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + }, + "window": { + "default": "mdi:window-open", + "state": { + "closed": "mdi:window-closed", + "closing": "mdi:arrow-down-box", + "opening": "mdi:arrow-up-box" + } + } + }, + "services": { + "close_cover": "mdi:arrow-down-box", + "close_cover_tilt": "mdi:arrow-bottom-left", + "open_cover": "mdi:arrow-up-box", + "open_cover_tilt": "mdi:arrow-top-right", + "set_cover_position": "mdi:arrow-down-box", + "set_cover_tilt_position": "mdi:arrow-top-right", + "stop_cover": "mdi:stop", + "stop_cover_tilt": "mdi:stop", + "toggle": "mdi:arrow-up-down", + "toggle_cover_tilt": "mdi:arrow-top-right-bottom-left" + } +} From 2b90d968b47e93f187314db264064abc40cde3d1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:39:05 +0100 Subject: [PATCH 0849/1544] Fix hassfest icon schema for service only (#108494) --- script/hassfest/icons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index e82aed855b2..1b993ddece3 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -73,7 +73,7 @@ def icon_schema(integration_type: str) -> vol.Schema: ) return base_schema.extend( { - vol.Required("entity"): cv.schema_with_slug_keys( + vol.Optional("entity"): cv.schema_with_slug_keys( cv.schema_with_slug_keys( icon_schema_slug(vol.Optional), slug_validator=translation_key_validator, From 88dfe8d33b2eab31c5d0c58476dc9ed6629475f0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 20 Jan 2024 20:39:12 +0100 Subject: [PATCH 0850/1544] Remove unused TypeVar from config.py (#108495) --- homeassistant/config.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index fc2feb48065..8a868018adf 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -14,7 +14,7 @@ from pathlib import Path import re import shutil from types import ModuleType -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from awesomeversion import AwesomeVersion @@ -147,9 +147,6 @@ class ConfigExceptionInfo: integration_link: str | None -_T = TypeVar("_T") - - @dataclass class IntegrationConfigInfo: """Configuration for an integration and exception information.""" From 8a5071ff82c601e6cd47dd0fa74e7e5901b85984 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 20 Jan 2024 20:42:28 +0100 Subject: [PATCH 0851/1544] Add time icon translations (#108458) --- homeassistant/components/time/icons.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 homeassistant/components/time/icons.json diff --git a/homeassistant/components/time/icons.json b/homeassistant/components/time/icons.json new file mode 100644 index 00000000000..c08e457e04d --- /dev/null +++ b/homeassistant/components/time/icons.json @@ -0,0 +1,10 @@ +{ + "entity_component": { + "_": { + "default": "mdi:clock" + } + }, + "services": { + "set_value": "mdi:clock-edit" + } +} From 1d35665107cd6589f72c9202f29d8e9763bd80a4 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 20 Jan 2024 21:04:29 +0100 Subject: [PATCH 0852/1544] Change calendar icon based on state (#108451) --- homeassistant/components/calendar/icons.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/icons.json b/homeassistant/components/calendar/icons.json index 06911cb2e6e..e4e526fe75c 100644 --- a/homeassistant/components/calendar/icons.json +++ b/homeassistant/components/calendar/icons.json @@ -1,7 +1,11 @@ { "entity_component": { "_": { - "default": "mdi:calendar" + "default": "mdi:calendar", + "state": { + "on": "mdi:calendar-check", + "off": "mdi:calendar-blank" + } } }, "services": { From d81682e02acf4de5f02cc59e2c4ef68d3808aae5 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 20 Jan 2024 21:16:32 +0100 Subject: [PATCH 0853/1544] Add sun icon translations (#108462) --- homeassistant/components/sun/icons.json | 33 +++++++++++++++++++++++++ homeassistant/components/sun/sensor.py | 9 ------- 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/sun/icons.json diff --git a/homeassistant/components/sun/icons.json b/homeassistant/components/sun/icons.json new file mode 100644 index 00000000000..9d903fd7b8e --- /dev/null +++ b/homeassistant/components/sun/icons.json @@ -0,0 +1,33 @@ +{ + "entity": { + "sensor": { + "next_dawn": { + "default": "mdi:sun-clock" + }, + "next_dusk": { + "default": "mdi:sun-clock" + }, + "next_midnight": { + "default": "mdi:sun-clock" + }, + "next_noon": { + "default": "mdi:sun-clock" + }, + "next_rising": { + "default": "mdi:sun-clock" + }, + "next_setting": { + "default": "mdi:sun-clock" + }, + "solar_elevation": { + "default": "mdi:theme-light-dark" + }, + "solar_azimuth": { + "default": "mdi:sun-angle" + }, + "solar_rising": { + "default": "mdi:sun-clock" + } + } + } +} diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 384e356fdd6..2a21b9d0246 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -39,7 +39,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_dawn", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_dawn", - icon="mdi:sun-clock", value_fn=lambda data: data.next_dawn, signal=SIGNAL_EVENTS_CHANGED, ), @@ -47,7 +46,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_dusk", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_dusk", - icon="mdi:sun-clock", value_fn=lambda data: data.next_dusk, signal=SIGNAL_EVENTS_CHANGED, ), @@ -55,7 +53,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_midnight", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_midnight", - icon="mdi:sun-clock", value_fn=lambda data: data.next_midnight, signal=SIGNAL_EVENTS_CHANGED, ), @@ -63,7 +60,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_noon", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_noon", - icon="mdi:sun-clock", value_fn=lambda data: data.next_noon, signal=SIGNAL_EVENTS_CHANGED, ), @@ -71,7 +67,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_rising", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_rising", - icon="mdi:sun-clock", value_fn=lambda data: data.next_rising, signal=SIGNAL_EVENTS_CHANGED, ), @@ -79,14 +74,12 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( key="next_setting", device_class=SensorDeviceClass.TIMESTAMP, translation_key="next_setting", - icon="mdi:sun-clock", value_fn=lambda data: data.next_setting, signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="solar_elevation", translation_key="solar_elevation", - icon="mdi:theme-light-dark", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_elevation, entity_registry_enabled_default=False, @@ -96,7 +89,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="solar_azimuth", translation_key="solar_azimuth", - icon="mdi:sun-angle", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.solar_azimuth, entity_registry_enabled_default=False, @@ -106,7 +98,6 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="solar_rising", translation_key="solar_rising", - icon="mdi:sun-clock", value_fn=lambda data: data.rising, entity_registry_enabled_default=False, signal=SIGNAL_EVENTS_CHANGED, From a042073d2ffc1e0b8985db991142e385fc7a2cc9 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sat, 20 Jan 2024 21:17:28 +0100 Subject: [PATCH 0854/1544] Add nut icon translations (#108471) --- homeassistant/components/nut/icons.json | 120 ++++++++++++++++++++++++ homeassistant/components/nut/sensor.py | 38 -------- 2 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/nut/icons.json diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json new file mode 100644 index 00000000000..a4125d8633f --- /dev/null +++ b/homeassistant/components/nut/icons.json @@ -0,0 +1,120 @@ +{ + "entity": { + "sensor": { + "ups_status_display": { + "default": "mdi:information-outline" + }, + "ups_status": { + "default": "mdi:information-outline" + }, + "ups_alarm": { + "default": "mdi:alarm" + }, + "ups_load": { + "default": "mdi:gauge" + }, + "ups_load_high": { + "default": "mdi:gauge" + }, + "ups_id": { + "default": "mdi:information-outline" + }, + "ups_test_result": { + "default": "mdi:information-outline" + }, + "ups_test_date": { + "default": "mdi:calendar" + }, + "ups_display_language": { + "default": "mdi:information-outline" + }, + "ups_contacts": { + "default": "mdi:information-outline" + }, + "ups_efficiency": { + "default": "mdi:gauge" + }, + "ups_beeper_status": { + "default": "mdi:information-outline" + }, + "ups_type": { + "default": "mdi:information-outline" + }, + "ups_watchdog_status": { + "default": "mdi:information-outline" + }, + "ups_start_auto": { + "default": "mdi:information-outline" + }, + "ups_start_battery": { + "default": "mdi:information-outline" + }, + "ups_start_reboot": { + "default": "mdi:information-outline" + }, + "ups_shutdown": { + "default": "mdi:information-outline" + }, + "battery_charge_low": { + "default": "mdi:gauge" + }, + "battery_charge_restart": { + "default": "mdi:gauge" + }, + "battery_charge_warning": { + "default": "mdi:gauge" + }, + "battery_charger_status": { + "default": "mdi:information-outline" + }, + "battery_capacity": { + "default": "mdi:flash" + }, + "battery_alarm_threshold": { + "default": "mdi:information-outline" + }, + "battery_date": { + "default": "mdi:calendar" + }, + "battery_mfr_date": { + "default": "mdi:calendar" + }, + "battery_packs": { + "default": "mdi:information-outline" + }, + "battery_packs_bad": { + "default": "mdi:information-outline" + }, + "battery_type": { + "default": "mdi:information-outline" + }, + "input_sensitivity": { + "default": "mdi:information-outline" + }, + "input_transfer_reason": { + "default": "mdi:information-outline" + }, + "input_frequency_status": { + "default": "mdi:information-outline" + }, + "input_bypass_phases": { + "default": "mdi:information-outline" + }, + "input_phases": { + "default": "mdi:information-outline" + }, + "output_l1_power_percent": { + "default": "mdi:gauge" + }, + "output_l2_power_percent": { + "default": "mdi:gauge" + }, + "output_l3_power_percent": { + "default": "mdi:gauge" + }, + "output_phases": { + "default": "mdi:information-outline" + } + } + } +} diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 165db8bb704..e4721d2d41c 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -58,17 +58,14 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", translation_key="ups_status_display", - icon="mdi:information-outline", ), "ups.status": SensorEntityDescription( key="ups.status", translation_key="ups_status", - icon="mdi:information-outline", ), "ups.alarm": SensorEntityDescription( key="ups.alarm", translation_key="ups_alarm", - icon="mdi:alarm", ), "ups.temperature": SensorEntityDescription( key="ups.temperature", @@ -83,21 +80,18 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.load", translation_key="ups_load", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), "ups.load.high": SensorEntityDescription( key="ups.load.high", translation_key="ups_load_high", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.id": SensorEntityDescription( key="ups.id", translation_key="ups_id", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -160,28 +154,24 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.test.result": SensorEntityDescription( key="ups.test.result", translation_key="ups_test_result", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.test.date": SensorEntityDescription( key="ups.test.date", translation_key="ups_test_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.display.language": SensorEntityDescription( key="ups.display.language", translation_key="ups_display_language", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.contacts": SensorEntityDescription( key="ups.contacts", translation_key="ups_contacts", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -189,7 +179,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="ups.efficiency", translation_key="ups_efficiency", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -231,49 +220,42 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.beeper.status": SensorEntityDescription( key="ups.beeper.status", translation_key="ups_beeper_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.type": SensorEntityDescription( key="ups.type", translation_key="ups_type", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.watchdog.status": SensorEntityDescription( key="ups.watchdog.status", translation_key="ups_watchdog_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.auto": SensorEntityDescription( key="ups.start.auto", translation_key="ups_start_auto", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.battery": SensorEntityDescription( key="ups.start.battery", translation_key="ups_start_battery", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.start.reboot": SensorEntityDescription( key="ups.start.reboot", translation_key="ups_start_reboot", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "ups.shutdown": SensorEntityDescription( key="ups.shutdown", translation_key="ups_shutdown", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -288,7 +270,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.low", translation_key="battery_charge_low", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -296,7 +277,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.restart", translation_key="battery_charge_restart", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -304,14 +284,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.charge.warning", translation_key="battery_charge_warning", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.charger.status": SensorEntityDescription( key="battery.charger.status", translation_key="battery_charger_status", - icon="mdi:information-outline", ), "battery.voltage": SensorEntityDescription( key="battery.voltage", @@ -350,7 +328,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="battery.capacity", translation_key="battery_capacity", native_unit_of_measurement="Ah", - icon="mdi:flash", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -407,49 +384,42 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "battery.alarm.threshold": SensorEntityDescription( key="battery.alarm.threshold", translation_key="battery_alarm_threshold", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.date": SensorEntityDescription( key="battery.date", translation_key="battery_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.mfr.date": SensorEntityDescription( key="battery.mfr.date", translation_key="battery_mfr_date", - icon="mdi:calendar", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs": SensorEntityDescription( key="battery.packs", translation_key="battery_packs", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.packs.bad": SensorEntityDescription( key="battery.packs.bad", translation_key="battery_packs_bad", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "battery.type": SensorEntityDescription( key="battery.type", translation_key="battery_type", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.sensitivity": SensorEntityDescription( key="input.sensitivity", translation_key="input_sensitivity", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -472,7 +442,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.transfer.reason": SensorEntityDescription( key="input.transfer.reason", translation_key="input_transfer_reason", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -538,7 +507,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.frequency.status": SensorEntityDescription( key="input.frequency.status", translation_key="input_frequency_status", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -617,7 +585,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.bypass.phases": SensorEntityDescription( key="input.bypass.phases", translation_key="input_bypass_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -732,7 +699,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -784,7 +750,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L1.power.percent", translation_key="output_l1_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -792,7 +757,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L2.power.percent", translation_key="output_l2_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -800,7 +764,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { key="output.L3.power.percent", translation_key="output_l3_power_percent", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -910,7 +873,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "output.phases": SensorEntityDescription( key="output.phases", translation_key="output_phases", - icon="mdi:information-outline", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), From 3c6e7b188e5c81ed5a78c108afecccd4662b763e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jan 2024 15:36:43 -1000 Subject: [PATCH 0855/1544] Remove OrderedDict from auth_store (#108546) normal dicts keep track of insert order now so this should no longer be needed --- homeassistant/auth/auth_store.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index c8f5001a515..534016a54e0 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections import OrderedDict from datetime import timedelta import hmac from logging import getLogger @@ -319,9 +318,9 @@ class AuthStore: self._set_defaults() return - users: dict[str, models.User] = OrderedDict() - groups: dict[str, models.Group] = OrderedDict() - credentials: dict[str, models.Credentials] = OrderedDict() + users: dict[str, models.User] = {} + groups: dict[str, models.Group] = {} + credentials: dict[str, models.Credentials] = {} # Soft-migrating data as we load. We are going to make sure we have a # read only group and an admin group. There are two states that we can @@ -581,9 +580,9 @@ class AuthStore: def _set_defaults(self) -> None: """Set default values for auth store.""" - self._users = OrderedDict() + self._users = {} - groups: dict[str, models.Group] = OrderedDict() + groups: dict[str, models.Group] = {} admin_group = _system_admin_group() groups[admin_group.id] = admin_group user_group = _system_user_group() From b5bb97c856a07ad02a84d25a33795da8025c4b35 Mon Sep 17 00:00:00 2001 From: Florian Kisser Date: Sun, 21 Jan 2024 02:37:13 +0100 Subject: [PATCH 0856/1544] Fix zha illuminance measured value mapping (#108547) --- homeassistant/components/zha/sensor.py | 6 +++++- tests/components/zha/test_sensor.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index c87ae9d72fb..9de4bcf75f5 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -483,8 +483,12 @@ class Illuminance(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = LIGHT_LUX - def formatter(self, value: int) -> int: + def formatter(self, value: int) -> int | None: """Convert illumination data.""" + if value == 0: + return 0 + if value == 0xFFFF: + return None return round(pow(10, ((value - 1) / 10000))) diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 4103897a000..7d67e41512a 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -136,6 +136,12 @@ async def async_test_illuminance(hass, cluster, entity_id): await send_attributes_report(hass, cluster, {1: 1, 0: 10, 2: 20}) assert_state(hass, entity_id, "1", LIGHT_LUX) + await send_attributes_report(hass, cluster, {1: 0, 0: 0, 2: 20}) + assert_state(hass, entity_id, "0", LIGHT_LUX) + + await send_attributes_report(hass, cluster, {1: 0, 0: 0xFFFF, 2: 20}) + assert_state(hass, entity_id, "unknown", LIGHT_LUX) + async def async_test_metering(hass, cluster, entity_id): """Test Smart Energy metering sensor.""" From 4d46f5ec079e886981450c4ae88adc5db928376a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 21 Jan 2024 02:37:39 +0100 Subject: [PATCH 0857/1544] Add icon translations for Pegelonline (#108554) add icon translations --- .../components/pegel_online/icons.json | 27 +++++++++++++++++++ .../components/pegel_online/sensor.py | 7 ----- 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/pegel_online/icons.json diff --git a/homeassistant/components/pegel_online/icons.json b/homeassistant/components/pegel_online/icons.json new file mode 100644 index 00000000000..b3192ba5283 --- /dev/null +++ b/homeassistant/components/pegel_online/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "air_temperature": { + "default": "mdi:thermometer-lines" + }, + "clearance_height": { + "default": "mdi:bridge" + }, + "oxygen_level": { + "default": "mdi:water-opacity" + }, + "water_speed": { + "default": "mdi:waves-arrow-right" + }, + "water_flow": { + "default": "mdi:waves" + }, + "water_level": { + "default": "mdi:waves-arrow-up" + }, + "water_temperature": { + "default": "mdi:thermometer-water" + } + } + } +} diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 5f7f431ddf7..657baf29c9f 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -42,7 +42,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="air_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer-lines", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -51,14 +50,12 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="clearance_height", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, - icon="mdi:bridge", ), PegelOnlineSensorEntityDescription( key="oxygen_level", translation_key="oxygen_level", measurement_key="oxygen_level", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:water-opacity", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -74,7 +71,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="water_speed", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.SPEED, - icon="mdi:waves-arrow-right", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -82,7 +78,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( translation_key="water_flow", measurement_key="water_flow", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:waves", entity_registry_enabled_default=False, ), PegelOnlineSensorEntityDescription( @@ -90,7 +85,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( translation_key="water_level", measurement_key="water_level", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:waves-arrow-up", ), PegelOnlineSensorEntityDescription( key="water_temperature", @@ -98,7 +92,6 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( measurement_key="water_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer-water", entity_registry_enabled_default=False, ), ) From ec15b0def2ee1d8d3715c172b7b15180b8599b19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Jan 2024 16:16:43 -1000 Subject: [PATCH 0858/1544] Always load auth storage at startup (#108543) --- homeassistant/auth/__init__.py | 4 +- homeassistant/auth/auth_store.py | 78 +++---------------- homeassistant/scripts/auth.py | 2 + tests/auth/providers/test_command_line.py | 6 +- tests/auth/providers/test_insecure_example.py | 6 +- .../providers/test_legacy_api_password.py | 6 +- tests/auth/providers/test_trusted_networks.py | 6 +- tests/auth/test_auth_store.py | 15 +++- tests/auth/test_init.py | 1 + 9 files changed, 43 insertions(+), 81 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 000dde90faa..ac9bbaaf593 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -47,6 +47,7 @@ async def auth_manager_from_config( mfa modules exist in configs. """ store = auth_store.AuthStore(hass) + await store.async_load() if provider_configs: providers = await asyncio.gather( *( @@ -73,8 +74,7 @@ async def auth_manager_from_config( for module in modules: module_hash[module.id] = module - manager = AuthManager(hass, store, provider_hash, module_hash) - return manager + return AuthManager(hass, store, provider_hash, module_hash) class AuthManagerFlowManager(data_entry_flow.FlowManager): diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 534016a54e0..5de5d087a65 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,7 +1,6 @@ """Storage for auth models.""" from __future__ import annotations -import asyncio from datetime import timedelta import hmac from logging import getLogger @@ -42,44 +41,28 @@ class AuthStore: def __init__(self, hass: HomeAssistant) -> None: """Initialize the auth store.""" self.hass = hass - self._users: dict[str, models.User] | None = None - self._groups: dict[str, models.Group] | None = None - self._perm_lookup: PermissionLookup | None = None + self._loaded = False + self._users: dict[str, models.User] = None # type: ignore[assignment] + self._groups: dict[str, models.Group] = None # type: ignore[assignment] + self._perm_lookup: PermissionLookup = None # type: ignore[assignment] self._store = Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) - self._lock = asyncio.Lock() async def async_get_groups(self) -> list[models.Group]: """Retrieve all users.""" - if self._groups is None: - await self._async_load() - assert self._groups is not None - return list(self._groups.values()) async def async_get_group(self, group_id: str) -> models.Group | None: """Retrieve all users.""" - if self._groups is None: - await self._async_load() - assert self._groups is not None - return self._groups.get(group_id) async def async_get_users(self) -> list[models.User]: """Retrieve all users.""" - if self._users is None: - await self._async_load() - assert self._users is not None - return list(self._users.values()) async def async_get_user(self, user_id: str) -> models.User | None: """Retrieve a user by id.""" - if self._users is None: - await self._async_load() - assert self._users is not None - return self._users.get(user_id) async def async_create_user( @@ -93,12 +76,6 @@ class AuthStore: local_only: bool | None = None, ) -> models.User: """Create a new user.""" - if self._users is None: - await self._async_load() - - assert self._users is not None - assert self._groups is not None - groups = [] for group_id in group_ids or []: if (group := self._groups.get(group_id)) is None: @@ -144,10 +121,6 @@ class AuthStore: async def async_remove_user(self, user: models.User) -> None: """Remove a user.""" - if self._users is None: - await self._async_load() - assert self._users is not None - self._users.pop(user.id) self._async_schedule_save() @@ -160,8 +133,6 @@ class AuthStore: local_only: bool | None = None, ) -> None: """Update a user.""" - assert self._groups is not None - if group_ids is not None: groups = [] for grid in group_ids: @@ -193,10 +164,6 @@ class AuthStore: async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): found = None @@ -244,10 +211,6 @@ class AuthStore: self, refresh_token: models.RefreshToken ) -> None: """Remove a refresh token.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): if user.refresh_tokens.pop(refresh_token.id, None): self._async_schedule_save() @@ -257,10 +220,6 @@ class AuthStore: self, token_id: str ) -> models.RefreshToken | None: """Get refresh token by id.""" - if self._users is None: - await self._async_load() - assert self._users is not None - for user in self._users.values(): refresh_token = user.refresh_tokens.get(token_id) if refresh_token is not None: @@ -272,10 +231,6 @@ class AuthStore: self, token: str ) -> models.RefreshToken | None: """Get refresh token by token.""" - if self._users is None: - await self._async_load() - assert self._users is not None - found = None for user in self._users.values(): @@ -294,25 +249,18 @@ class AuthStore: refresh_token.last_used_ip = remote_ip self._async_schedule_save() - async def _async_load(self) -> None: + async def async_load(self) -> None: """Load the users.""" - async with self._lock: - if self._users is not None: - return - await self._async_load_task() + if self._loaded: + raise RuntimeError("Auth storage is already loaded") + self._loaded = True - async def _async_load_task(self) -> None: - """Load the users.""" dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) data = await self._store.async_load() - # Make sure that we're not overriding data if 2 loads happened at the - # same time - if self._users is not None: - return - - self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg) + perm_lookup = PermissionLookup(ent_reg, dev_reg) + self._perm_lookup = perm_lookup if data is None or not isinstance(data, dict): self._set_defaults() @@ -495,17 +443,11 @@ class AuthStore: @callback def _async_schedule_save(self) -> None: """Save users.""" - if self._users is None: - return - self._store.async_delay_save(self._data_to_save, 1) @callback def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: """Return the data to store.""" - assert self._users is not None - assert self._groups is not None - users = [ { "id": user.id, diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 5714e5814a4..dd3b9b7ba48 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -9,6 +9,7 @@ from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er # mypy: allow-untyped-calls, allow-untyped-defs @@ -51,6 +52,7 @@ def run(args): async def run_command(args): """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = hass.auth.auth_providers[0] await provider.async_initialize() diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index a92d41a8c5f..016ce767bad 100644 --- a/tests/auth/providers/test_command_line.py +++ b/tests/auth/providers/test_command_line.py @@ -13,9 +13,11 @@ from homeassistant.const import CONF_TYPE @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py index 6054b7937c6..ceb8b02ae65 100644 --- a/tests/auth/providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -9,9 +9,11 @@ from homeassistant.auth.providers import insecure_example @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 3d89c577ebf..75c4f733285 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -14,9 +14,11 @@ CONFIG = {"type": "legacy_api_password", "api_password": "test-password"} @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index a098eea28e0..3ccff990b9c 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -16,9 +16,11 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def store(hass): +async def store(hass): """Mock store.""" - return auth_store.AuthStore(hass) + store = auth_store.AuthStore(hass) + await store.async_load() + return store @pytest.fixture diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 860abe76577..778095388a8 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -3,6 +3,8 @@ import asyncio from typing import Any from unittest.mock import patch +import pytest + from homeassistant.auth import auth_store from homeassistant.core import HomeAssistant @@ -67,6 +69,7 @@ async def test_loading_no_group_data_format( } store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -165,6 +168,7 @@ async def test_loading_all_access_group_data_format( } store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -205,6 +209,7 @@ async def test_loading_empty_data( ) -> None: """Test we correctly load with no existing data.""" store = auth_store.AuthStore(hass) + await store.async_load() groups = await store.async_get_groups() assert len(groups) == 3 admin_group = groups[0] @@ -232,7 +237,7 @@ async def test_system_groups_store_id_and_name( Name is stored so that we remain backwards compat with < 0.82. """ store = auth_store.AuthStore(hass) - await store._async_load() + await store.async_load() data = store._data_to_save() assert len(data["users"]) == 0 assert data["groups"] == [ @@ -242,8 +247,8 @@ async def test_system_groups_store_id_and_name( ] -async def test_loading_race_condition(hass: HomeAssistant) -> None: - """Test only one storage load called when concurrent loading occurred .""" +async def test_loading_only_once(hass: HomeAssistant) -> None: + """Test only one storage load is allowed.""" store = auth_store.AuthStore(hass) with patch( "homeassistant.helpers.entity_registry.async_get" @@ -252,6 +257,10 @@ async def test_loading_race_condition(hass: HomeAssistant) -> None: ) as mock_dev_registry, patch( "homeassistant.helpers.storage.Store.async_load", return_value=None ) as mock_load: + await store.async_load() + with pytest.raises(RuntimeError, match="Auth storage is already loaded"): + await store.async_load() + results = await asyncio.gather(store.async_get_users(), store.async_get_users()) mock_ent_registry.assert_called_once_with(hass) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 9e9b48a07f6..53c4c680700 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -343,6 +343,7 @@ async def test_saving_loading( await flush_store(manager._store._store) store2 = auth_store.AuthStore(hass) + await store2.async_load() users = await store2.async_get_users() assert len(users) == 1 assert users[0].permissions == user.permissions From fa485513d5fbcd0cd1ca79ce90d4ab845bb076f9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 21 Jan 2024 12:02:15 +0100 Subject: [PATCH 0859/1544] Ensure icon translations aren't the same as the default (#108568) --- homeassistant/components/cover/icons.json | 8 +--- .../components/media_player/icons.json | 2 +- homeassistant/components/weather/icons.json | 1 - script/hassfest/icons.py | 43 ++++++++++++++----- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/cover/icons.json b/homeassistant/components/cover/icons.json index d450070e631..f2edaaa0893 100644 --- a/homeassistant/components/cover/icons.json +++ b/homeassistant/components/cover/icons.json @@ -27,17 +27,13 @@ "damper": { "default": "mdi:circle", "state": { - "closed": "mdi:circle-slice-8", - "closing": "mdi:circle", - "opening": "mdi:circle" + "closed": "mdi:circle-slice-8" } }, "door": { "default": "mdi:door-open", "state": { - "closed": "mdi:door-closed", - "closing": "mdi:door-open", - "opening": "mdi:door-open" + "closed": "mdi:door-closed" } }, "garage": { diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index b79c66af0e9..e2769085833 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -3,7 +3,7 @@ "_": { "default": "mdi:cast", "state": { - "off": "mdi:cast", + "off": "mdi:cast-off", "paused": "mdi:cast-connected", "playing": "mdi:cast-connected" } diff --git a/homeassistant/components/weather/icons.json b/homeassistant/components/weather/icons.json index 63daae20e3c..cc53861e700 100644 --- a/homeassistant/components/weather/icons.json +++ b/homeassistant/components/weather/icons.json @@ -10,7 +10,6 @@ "hail": "mdi:weather-hail", "lightning": "mdi:weather-lightning", "lightning-rainy": "mdi:weather-lightning-rainy", - "partlycloudy": "mdi:weather-partly-cloudy", "pouring": "mdi:weather-pouring", "rainy": "mdi:weather-rainy", "snowy": "mdi:weather-snowy", diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 1b993ddece3..f5e07a0d348 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -32,6 +32,20 @@ def require_default_icon_validator(value: dict) -> dict: return value +def ensure_not_same_as_default(value: dict) -> dict: + """Validate an icon isn't the same as its default icon.""" + for translation_key, section in value.items(): + if (default := section.get("default")) and (states := section.get("state")): + for state, icon in states.items(): + if icon == default: + raise vol.Invalid( + f"The icon for state `{translation_key}.{state}` is the" + " same as the default icon and thus can be removed" + ) + + return value + + def icon_schema(integration_type: str) -> vol.Schema: """Create a icon schema.""" @@ -44,12 +58,15 @@ def icon_schema(integration_type: str) -> vol.Schema: return { marker("default"): icon_value_validator, vol.Optional("state"): state_validator, - vol.Optional("state_attributes"): cv.schema_with_slug_keys( - { - marker("default"): icon_value_validator, - marker("state"): state_validator, - }, - slug_validator=translation_key_validator, + vol.Optional("state_attributes"): vol.All( + cv.schema_with_slug_keys( + { + marker("default"): icon_value_validator, + marker("state"): state_validator, + }, + slug_validator=translation_key_validator, + ), + ensure_not_same_as_default, ), } @@ -68,18 +85,22 @@ def icon_schema(integration_type: str) -> vol.Schema: slug_validator=vol.Any("_", cv.slug), ), require_default_icon_validator, + ensure_not_same_as_default, ) } ) return base_schema.extend( { - vol.Optional("entity"): cv.schema_with_slug_keys( + vol.Optional("entity"): vol.All( cv.schema_with_slug_keys( - icon_schema_slug(vol.Optional), - slug_validator=translation_key_validator, + cv.schema_with_slug_keys( + icon_schema_slug(vol.Optional), + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, ), - slug_validator=cv.slug, - ), + ensure_not_same_as_default, + ) } ) From d885bf886ac302e353e15b730cb3696b52a90773 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 01:04:13 -1000 Subject: [PATCH 0860/1544] Ensure button platform does not restore unavailable state (#108316) --- homeassistant/components/button/__init__.py | 3 ++- tests/components/button/test_init.py | 22 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 3ecc27f8573..0acc5b63339 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -141,7 +142,7 @@ class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ """Call when the button is added to hass.""" await super().async_internal_added_to_hass() state = await self.async_get_last_state() - if state is not None and state.state is not None: + if state is not None and state.state not in (STATE_UNAVAILABLE, None): self.__set_state(state.state) def press(self) -> None: diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 2457a796d45..acf7bd39e10 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -14,7 +14,12 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -106,6 +111,21 @@ async def test_restore_state( assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00" +async def test_restore_state_does_not_restore_unavailable( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we restore state integration except for unavailable.""" + mock_restore_cache(hass, (State("button.button_1", STATE_UNAVAILABLE),)) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("button.button_1").state == STATE_UNKNOWN + + class MockFlow(ConfigFlow): """Test flow.""" From 7c86ea7e16c0574159c6fbb246929274e2482ac8 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 21 Jan 2024 12:04:46 +0100 Subject: [PATCH 0861/1544] Add sensors to the flexit_bacnet integration (#108297) * Adds sensors to the flexit_bacnet integration * Add one platform at a time * Removes commented out code And restores attributes that are needed * Review changes * More review fixes * Adds translations for the flexit_bacnet sensors * Review comments * Adds test for flexit_bacnet sensor * Refactors the sensor test * Review comment * Review comment * Review comments --- .../components/flexit_bacnet/__init__.py | 2 +- .../components/flexit_bacnet/sensor.py | 186 +++++ .../components/flexit_bacnet/strings.json | 49 ++ tests/components/flexit_bacnet/conftest.py | 15 + .../flexit_bacnet/snapshots/test_sensor.ambr | 708 ++++++++++++++++++ tests/components/flexit_bacnet/test_sensor.py | 33 + 6 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/flexit_bacnet/sensor.py create mode 100644 tests/components/flexit_bacnet/snapshots/test_sensor.ambr create mode 100644 tests/components/flexit_bacnet/test_sensor.py diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 39e06156a59..40537306c0b 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import FlexitCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py new file mode 100644 index 00000000000..590136ad5f7 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -0,0 +1,186 @@ +"""The Flexit Nordic (BACnet) integration.""" +from collections.abc import Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSensorEntityDescription(SensorEntityDescription): + """Describes a Flexit sensor entity.""" + + value_fn: Callable[[FlexitBACnet], float] + + +SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = ( + FlexitSensorEntityDescription( + key="outside_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="outside_air_temperature", + value_fn=lambda data: data.outside_air_temperature, + ), + FlexitSensorEntityDescription( + key="supply_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="supply_air_temperature", + value_fn=lambda data: data.supply_air_temperature, + ), + FlexitSensorEntityDescription( + key="exhaust_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="exhaust_air_temperature", + value_fn=lambda data: data.exhaust_air_temperature, + ), + FlexitSensorEntityDescription( + key="extract_air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="extract_air_temperature", + value_fn=lambda data: data.extract_air_temperature, + ), + FlexitSensorEntityDescription( + key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="room_temperature", + value_fn=lambda data: data.room_temperature, + ), + FlexitSensorEntityDescription( + key="fireplace_ventilation_remaining_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key="fireplace_ventilation_remaining_duration", + value_fn=lambda data: data.fireplace_ventilation_remaining_duration, + suggested_display_precision=0, + ), + FlexitSensorEntityDescription( + key="rapid_ventilation_remaining_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + translation_key="rapid_ventilation_remaining_duration", + value_fn=lambda data: data.rapid_ventilation_remaining_duration, + suggested_display_precision=0, + ), + FlexitSensorEntityDescription( + key="supply_air_fan_control_signal", + state_class=SensorStateClass.MEASUREMENT, + translation_key="supply_air_fan_control_signal", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.supply_air_fan_control_signal, + ), + FlexitSensorEntityDescription( + key="supply_air_fan_rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + translation_key="supply_air_fan_rpm", + value_fn=lambda data: data.supply_air_fan_rpm, + ), + FlexitSensorEntityDescription( + key="exhaust_air_fan_control_signal", + state_class=SensorStateClass.MEASUREMENT, + translation_key="exhaust_air_fan_control_signal", + value_fn=lambda data: data.exhaust_air_fan_control_signal, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitSensorEntityDescription( + key="exhaust_air_fan_rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + translation_key="exhaust_air_fan_rpm", + value_fn=lambda data: data.exhaust_air_fan_rpm, + ), + FlexitSensorEntityDescription( + key="electric_heater_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + translation_key="electric_heater_power", + value_fn=lambda data: data.electric_heater_power, + suggested_display_precision=3, + ), + FlexitSensorEntityDescription( + key="air_filter_operating_time", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + native_unit_of_measurement=UnitOfTime.HOURS, + translation_key="air_filter_operating_time", + value_fn=lambda data: data.air_filter_operating_time, + ), + FlexitSensorEntityDescription( + key="heat_exchanger_efficiency", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + translation_key="heat_exchanger_efficiency", + value_fn=lambda data: data.heat_exchanger_efficiency, + ), + FlexitSensorEntityDescription( + key="heat_exchanger_speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + translation_key="heat_exchanger_speed", + value_fn=lambda data: data.heat_exchanger_speed, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) sensor from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class FlexitSensor(FlexitEntity, SensorEntity): + """Representation of a Flexit (bacnet) Sensor.""" + + entity_description: FlexitSensorEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitSensorEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index fd2725c6403..b9348ebedcd 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -15,5 +15,54 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "outside_air_temperature": { + "name": "Outside air temperature" + }, + "supply_air_temperature": { + "name": "Supply air temperature" + }, + "exhaust_air_temperature": { + "name": "Exhaust air temperature" + }, + "extract_air_temperature": { + "name": "Extract air temperature" + }, + "room_temperature": { + "name": "Room temperature" + }, + "fireplace_ventilation_remaining_duration": { + "name": "Fireplace ventilation remaining duration" + }, + "rapid_ventilation_remaining_duration": { + "name": "Rapid ventilation remaining duration" + }, + "supply_air_fan_control_signal": { + "name": "Supply air fan control signal" + }, + "supply_air_fan_rpm": { + "name": "Supply air fan" + }, + "exhaust_air_fan_control_signal": { + "name": "Exhaust air fan control signal" + }, + "exhaust_air_fan_rpm": { + "name": "Exhaust air fan" + }, + "electric_heater_power": { + "name": "Electric heater power" + }, + "air_filter_operating_time": { + "name": "Air filter operating time" + }, + "heat_exchanger_efficiency": { + "name": "Heat exchanger efficiency" + }, + "heat_exchanger_speed": { + "name": "Heat exchanger speed" + } + } } } diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index 0c6153e81c0..6fc715da28e 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -44,6 +44,21 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: flexit_bacnet.air_temp_setpoint_away = 18.0 flexit_bacnet.air_temp_setpoint_home = 22.0 flexit_bacnet.ventilation_mode = 4 + flexit_bacnet.air_filter_operating_time = 8000 + flexit_bacnet.outside_air_temperature = -8.6 + flexit_bacnet.supply_air_temperature = 19.1 + flexit_bacnet.exhaust_air_temperature = -3.3 + flexit_bacnet.extract_air_temperature = 19.0 + flexit_bacnet.fireplace_ventilation_remaining_duration = 10.0 + flexit_bacnet.rapid_ventilation_remaining_duration = 30.0 + flexit_bacnet.supply_air_fan_control_signal = 74 + flexit_bacnet.supply_air_fan_rpm = 2784 + flexit_bacnet.exhaust_air_fan_control_signal = 70 + flexit_bacnet.exhaust_air_fan_rpm = 2606 + flexit_bacnet.electric_heater_power = 0.39636585116386414 + flexit_bacnet.air_filter_operating_time = 8820.0 + flexit_bacnet.heat_exchanger_efficiency = 81 + flexit_bacnet.heat_exchanger_speed = 100 yield flexit_bacnet diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c1f8ad73eb1 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -0,0 +1,708 @@ +# serializer version: 1 +# name: test_sensors[sensor.device_name_air_filter_operating_time-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.device_name_air_filter_operating_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air filter operating time', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_filter_operating_time', + 'unique_id': '0000-0001-air_filter_operating_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_air_filter_operating_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Air filter operating time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_air_filter_operating_time', + 'last_changed': , + 'last_updated': , + 'state': '8820.0', + }) +# --- +# name: test_sensors[sensor.device_name_electric_heater_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_electric_heater_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electric heater power', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater_power', + 'unique_id': '0000-0001-electric_heater_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_electric_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Name Electric heater power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_electric_heater_power', + 'last_changed': , + 'last_updated': , + 'state': '0.396365851163864', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan-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.device_name_exhaust_air_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust air fan', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_fan_rpm', + 'unique_id': '0000-0001-exhaust_air_fan_rpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Exhaust air fan', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_fan', + 'last_changed': , + 'last_updated': , + 'state': '2606', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan_control_signal-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.device_name_exhaust_air_fan_control_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Exhaust air fan control signal', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_fan_control_signal', + 'unique_id': '0000-0001-exhaust_air_fan_control_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_fan_control_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Exhaust air fan control signal', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_fan_control_signal', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_exhaust_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exhaust air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_air_temperature', + 'unique_id': '0000-0001-exhaust_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_exhaust_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Exhaust air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_exhaust_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '-3.3', + }) +# --- +# name: test_sensors[sensor.device_name_extract_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_extract_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extract air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'extract_air_temperature', + 'unique_id': '0000-0001-extract_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_extract_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Extract air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_extract_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.0', + }) +# --- +# name: test_sensors[sensor.device_name_fireplace_ventilation_remaining_duration-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.device_name_fireplace_ventilation_remaining_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace ventilation remaining duration', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_ventilation_remaining_duration', + 'unique_id': '0000-0001-fireplace_ventilation_remaining_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_fireplace_ventilation_remaining_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Fireplace ventilation remaining duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_fireplace_ventilation_remaining_duration', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_efficiency-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.device_name_heat_exchanger_efficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heat exchanger efficiency', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_exchanger_efficiency', + 'unique_id': '0000-0001-heat_exchanger_efficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_efficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Heat exchanger efficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_heat_exchanger_efficiency', + 'last_changed': , + 'last_updated': , + 'state': '81', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_speed-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.device_name_heat_exchanger_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heat exchanger speed', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_exchanger_speed', + 'unique_id': '0000-0001-heat_exchanger_speed', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_heat_exchanger_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Heat exchanger speed', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_heat_exchanger_speed', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.device_name_outside_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_outside_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_air_temperature', + 'unique_id': '0000-0001-outside_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_outside_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Outside air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_outside_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '-8.6', + }) +# --- +# name: test_sensors[sensor.device_name_rapid_ventilation_remaining_duration-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.device_name_rapid_ventilation_remaining_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rapid ventilation remaining duration', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rapid_ventilation_remaining_duration', + 'unique_id': '0000-0001-rapid_ventilation_remaining_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_rapid_ventilation_remaining_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Rapid ventilation remaining duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_rapid_ventilation_remaining_duration', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[sensor.device_name_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Room temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'room_temperature', + 'unique_id': '0000-0001-room_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Room temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_room_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.0', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan-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.device_name_supply_air_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supply air fan', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_fan_rpm', + 'unique_id': '0000-0001-supply_air_fan_rpm', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Supply air fan', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_fan', + 'last_changed': , + 'last_updated': , + 'state': '2784', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan_control_signal-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.device_name_supply_air_fan_control_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Supply air fan control signal', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_fan_control_signal', + 'unique_id': '0000-0001-supply_air_fan_control_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_fan_control_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Name Supply air fan control signal', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_fan_control_signal', + 'last_changed': , + 'last_updated': , + 'state': '74', + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_name_supply_air_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply air temperature', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'supply_air_temperature', + 'unique_id': '0000-0001-supply_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.device_name_supply_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Device Name Supply air temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_name_supply_air_temperature', + 'last_changed': , + 'last_updated': , + 'state': '19.1', + }) +# --- diff --git a/tests/components/flexit_bacnet/test_sensor.py b/tests/components/flexit_bacnet/test_sensor.py new file mode 100644 index 00000000000..2285b4c8692 --- /dev/null +++ b/tests/components/flexit_bacnet/test_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the Flexit Nordic (BACnet) sensor entities.""" +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + + +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor states are correctly collected from library.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) From ed270f558a738a84ef7263a0f9d7c7e8d06f3308 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 21 Jan 2024 12:35:21 +0100 Subject: [PATCH 0862/1544] Add binary sensors to flexit_bacnet integration (#108571) * Adds binary sensors to flexit_bacnet integration * Review comments * Removes binary sensor for electric heater Will add switch or service later --- .../components/flexit_bacnet/__init__.py | 2 +- .../components/flexit_bacnet/binary_sensor.py | 72 +++++++++++++++++++ .../components/flexit_bacnet/strings.json | 5 ++ .../snapshots/test_binary_sensor.ambr | 45 ++++++++++++ .../flexit_bacnet/test_binary_sensor.py | 35 +++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/flexit_bacnet/binary_sensor.py create mode 100644 tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/flexit_bacnet/test_binary_sensor.py diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 40537306c0b..27800af6626 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import FlexitCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py new file mode 100644 index 00000000000..b014fbca415 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -0,0 +1,72 @@ +"""The Flexit Nordic (BACnet) integration.""" +from collections.abc import Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Flexit binary sensor entity.""" + + value_fn: Callable[[FlexitBACnet], bool] + + +SENSOR_TYPES: tuple[FlexitBinarySensorEntityDescription, ...] = ( + FlexitBinarySensorEntityDescription( + key="air_filter_polluted", + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="air_filter_polluted", + value_fn=lambda data: data.air_filter_polluted, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) binary sensor from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitBinarySensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class FlexitBinarySensor(FlexitEntity, BinarySensorEntity): + """Representation of a Flexit binary Sensor.""" + + entity_description: FlexitBinarySensorEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitBinarySensorEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return value of binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index b9348ebedcd..aeb349dd1d4 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "binary_sensor": { + "air_filter_polluted": { + "name": "Air filter polluted" + } + }, "sensor": { "outside_air_temperature": { "name": "Outside air temperature" diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..a6f4137d03e --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.device_name_air_filter_polluted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.device_name_air_filter_polluted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air filter polluted', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_filter_polluted', + 'unique_id': '0000-0001-air_filter_polluted', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.device_name_air_filter_polluted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Device Name Air filter polluted', + }), + 'context': , + 'entity_id': 'binary_sensor.device_name_air_filter_polluted', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/flexit_bacnet/test_binary_sensor.py b/tests/components/flexit_bacnet/test_binary_sensor.py new file mode 100644 index 00000000000..df363086f63 --- /dev/null +++ b/tests/components/flexit_bacnet/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the Flexit Nordic (BACnet) binary sensor entities.""" +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + + +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test binary sensor states are correctly collected from library.""" + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) From 5c0a67a3d2f5b362000780565c35d2382e2b853c Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 21 Jan 2024 13:26:28 +0100 Subject: [PATCH 0863/1544] Add icon translations for lamarzocco (#108569) icon translations --- .../components/lamarzocco/binary_sensor.py | 15 --- homeassistant/components/lamarzocco/button.py | 1 - .../components/lamarzocco/icons.json | 102 ++++++++++++++++++ homeassistant/components/lamarzocco/number.py | 3 - homeassistant/components/lamarzocco/select.py | 2 - homeassistant/components/lamarzocco/sensor.py | 5 - homeassistant/components/lamarzocco/switch.py | 3 - homeassistant/components/lamarzocco/update.py | 2 - .../snapshots/test_binary_sensor.ambr | 6 +- .../lamarzocco/snapshots/test_button.ambr | 3 +- .../lamarzocco/snapshots/test_number.ambr | 15 +-- .../lamarzocco/snapshots/test_select.ambr | 12 +-- .../lamarzocco/snapshots/test_sensor.ambr | 15 +-- .../lamarzocco/snapshots/test_switch.ambr | 9 +- .../lamarzocco/snapshots/test_update.ambr | 6 +- 15 files changed, 124 insertions(+), 75 deletions(-) create mode 100644 homeassistant/components/lamarzocco/icons.json diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index a0f4033710c..0eb28fa9558 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -27,8 +27,6 @@ class LaMarzoccoBinarySensorEntityDescription( """Description of a La Marzocco binary sensor.""" is_on_fn: Callable[[LaMarzoccoClient], bool] - icon_on: str - icon_off: str ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -36,8 +34,6 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="water_tank", translation_key="water_tank", device_class=BinarySensorDeviceClass.PROBLEM, - icon_on="mdi:water-remove", - icon_off="mdi:water-check", is_on_fn=lambda lm: not lm.current_status.get("water_reservoir_contact"), entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: coordinator.local_connection_configured, @@ -46,8 +42,6 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="brew_active", translation_key="brew_active", device_class=BinarySensorDeviceClass.RUNNING, - icon_off="mdi:cup-off", - icon_on="mdi:cup-water", is_on_fn=lambda lm: bool(lm.current_status.get("brew_active")), available_fn=lambda lm: lm.websocket_connected, entity_category=EntityCategory.DIAGNOSTIC, @@ -75,15 +69,6 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): entity_description: LaMarzoccoBinarySensorEntityDescription - @property - def icon(self) -> str | None: - """Return the icon.""" - return ( - self.entity_description.icon_on - if self.is_on - else self.entity_description.icon_off - ) - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index 689250fa37b..68bae5feeb9 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -29,7 +29,6 @@ ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( LaMarzoccoButtonEntityDescription( key="start_backflush", translation_key="start_backflush", - icon="mdi:water-sync", press_fn=lambda lm: lm.start_backflush(), ), ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json new file mode 100644 index 00000000000..c893ba42848 --- /dev/null +++ b/homeassistant/components/lamarzocco/icons.json @@ -0,0 +1,102 @@ +{ + "entity": { + "binary_sensor": { + "water_tank": { + "default": "mdi:water", + "state": { + "on": "mdi:water-alert", + "off": "mdi:water-check" + } + }, + "brew_active": { + "default": "mdi:cup", + "state": { + "on": "mdi:cup-water", + "off": "mdi:cup-off" + } + } + }, + "button": { + "start_backflush": { + "default": "mdi:water-sync" + } + }, + "number": { + "coffee_temp": { + "default": "mdi:thermometer-water" + }, + "steam_temp": { + "default": "mdi:thermometer-water" + }, + "tea_water_duration": { + "default": "mdi:timer-sand" + } + }, + "select": { + "steam_temp_select": { + "default": "mdi:thermometer", + "state": { + "1": "mdi:thermometer-low", + "2": "mdi:thermometer", + "3": "mdi:thermometer-high" + } + }, + "prebrew_infusion_select": { + "default": "mdi:water-pump-off", + "state": { + "disabled": "mdi:water-pump-off", + "prebrew": "mdi:water-pump", + "preinfusion": "mdi:water-pump" + } + } + }, + "sensor": { + "drink_stats_coffee": { + "default": "mdi:chart-line" + }, + "drink_stats_flushing": { + "default": "mdi:chart-line" + }, + "shot_timer": { + "default": "mdi:timer" + }, + "current_temp_coffee": { + "default": "mdi:thermometer" + }, + "current_temp_steam": { + "default": "mdi:thermometer" + } + }, + "switch": { + "main": { + "default": "mdi:power", + "state": { + "on": "mdi:power", + "off": "mdi:power-off" + } + }, + "auto_on_off": { + "default": "mdi:alarm", + "state": { + "on": "mdi:alarm", + "off": "mdi:alarm-off" + } + }, + "steam_boiler_enable": { + "default": "mdi:water-boiler", + "state": { + "on": "mdi:water-boiler", + "off": "mdi:water-boiler-off" + } + } + }, + "update": { + "machine_firmware": { + "default": "mdi:cloud-download" + }, + "gateway_firmware": { + "default": "mdi:cloud-download" + } + } + } +} diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index c14f04f05d8..76632d4a5b8 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -44,7 +44,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="coffee_temp", translation_key="coffee_temp", - icon="mdi:coffee-maker", device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_step=PRECISION_TENTHS, @@ -56,7 +55,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="steam_temp", translation_key="steam_temp", - icon="mdi:kettle-steam", device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_step=PRECISION_WHOLE, @@ -73,7 +71,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="tea_water_duration", translation_key="tea_water_duration", - icon="mdi:water-percent", device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, native_step=PRECISION_WHOLE, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index f29dabae529..1e70000a479 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -34,7 +34,6 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( LaMarzoccoSelectEntityDescription( key="steam_temp_select", translation_key="steam_temp_select", - icon="mdi:water-thermometer", options=["1", "2", "3"], select_option_fn=lambda coordinator, option: coordinator.lm.set_steam_level( int(option) @@ -46,7 +45,6 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( LaMarzoccoSelectEntityDescription( key="prebrew_infusion_select", translation_key="prebrew_infusion_select", - icon="mdi:water-plus", options=["disabled", "prebrew", "preinfusion"], select_option_fn=lambda coordinator, option: coordinator.lm.select_pre_brew_infusion_mode(option.capitalize()), diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 63292b95ae3..c46b965850c 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -34,7 +34,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", translation_key="drink_stats_coffee", - icon="mdi:chart-line", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda lm: lm.current_status.get("drinks_k1", 0), @@ -43,7 +42,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", translation_key="drink_stats_flushing", - icon="mdi:chart-line", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda lm: lm.current_status.get("total_flushing", 0), @@ -52,7 +50,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="shot_timer", translation_key="shot_timer", - icon="mdi:timer", native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DURATION, @@ -64,7 +61,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="current_temp_coffee", translation_key="current_temp_coffee", - icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, @@ -73,7 +69,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="current_temp_steam", translation_key="current_temp_steam", - icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index fe9c6daa9cf..4cab49064e7 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -29,14 +29,12 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( LaMarzoccoSwitchEntityDescription( key="main", name=None, - icon="mdi:power", control_fn=lambda coordinator, state: coordinator.lm.set_power(state), is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], ), LaMarzoccoSwitchEntityDescription( key="auto_on_off", translation_key="auto_on_off", - icon="mdi:alarm", control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global( state ), @@ -47,7 +45,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( LaMarzoccoSwitchEntityDescription( key="steam_boiler_enable", translation_key="steam_boiler", - icon="mdi:water-boiler", control_fn=lambda coordinator, state: coordinator.lm.set_steam(state), is_on_fn=lambda coordinator: coordinator.lm.current_status[ "steam_boiler_enable" diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 6c0a5a990ad..cc3e665725b 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -40,7 +40,6 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( key="machine_firmware", translation_key="machine_firmware", device_class=UpdateDeviceClass.FIRMWARE, - icon="mdi:cloud-download", current_fw_fn=lambda lm: lm.firmware_version, latest_fw_fn=lambda lm: lm.latest_firmware_version, component=LaMarzoccoUpdateableComponent.MACHINE, @@ -50,7 +49,6 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( key="gateway_firmware", translation_key="gateway_firmware", device_class=UpdateDeviceClass.FIRMWARE, - icon="mdi:cloud-download", current_fw_fn=lambda lm: lm.gateway_version, latest_fw_fn=lambda lm: lm.latest_gateway_version, component=LaMarzoccoUpdateableComponent.GATEWAY, diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 4fb8c3cb828..12acc6757e2 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -4,7 +4,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'running', 'friendly_name': 'GS01234 Brewing active', - 'icon': 'mdi:cup-off', }), 'context': , 'entity_id': 'binary_sensor.gs01234_brewing_active', @@ -34,7 +33,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:cup-off', + 'original_icon': None, 'original_name': 'Brewing active', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -49,7 +48,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'problem', 'friendly_name': 'GS01234 Water tank empty', - 'icon': 'mdi:water-check', }), 'context': , 'entity_id': 'binary_sensor.gs01234_water_tank_empty', @@ -79,7 +77,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water-check', + 'original_icon': None, 'original_name': 'Water tank empty', 'platform': 'lamarzocco', 'previous_unique_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr index e092032e8f5..2f15c70c8cc 100644 --- a/tests/components/lamarzocco/snapshots/test_button.ambr +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Start backflush', - 'icon': 'mdi:water-sync', }), 'context': , 'entity_id': 'button.gs01234_start_backflush', @@ -33,7 +32,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-sync', + 'original_icon': None, 'original_name': 'Start backflush', 'platform': 'lamarzocco', 'previous_unique_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index d20801aed90..3c9fdce101f 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -4,7 +4,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'GS01234 Coffee target temperature', - 'icon': 'mdi:coffee-maker', 'max': 104, 'min': 85, 'mode': , @@ -44,7 +43,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:coffee-maker', + 'original_icon': None, 'original_name': 'Coffee target temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -59,7 +58,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'GS01234 Steam target temperature', - 'icon': 'mdi:kettle-steam', 'max': 131, 'min': 126, 'mode': , @@ -99,7 +97,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:kettle-steam', + 'original_icon': None, 'original_name': 'Steam target temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -114,7 +112,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'GS01234 Steam target temperature', - 'icon': 'mdi:kettle-steam', 'max': 131, 'min': 126, 'mode': , @@ -154,7 +151,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:kettle-steam', + 'original_icon': None, 'original_name': 'Steam target temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -169,7 +166,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'GS01234 Tea water duration', - 'icon': 'mdi:water-percent', 'max': 30, 'min': 0, 'mode': , @@ -209,7 +205,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water-percent', + 'original_icon': None, 'original_name': 'Tea water duration', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -224,7 +220,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'GS01234 Tea water duration', - 'icon': 'mdi:water-percent', 'max': 30, 'min': 0, 'mode': , @@ -264,7 +259,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water-percent', + 'original_icon': None, 'original_name': 'Tea water duration', 'platform': 'lamarzocco', 'previous_unique_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index e35b721436c..4f64eafb855 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Prebrew/-infusion mode', - 'icon': 'mdi:water-plus', 'options': list([ 'disabled', 'prebrew', @@ -44,7 +43,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-plus', + 'original_icon': None, 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -58,7 +57,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'LM01234 Prebrew/-infusion mode', - 'icon': 'mdi:water-plus', 'options': list([ 'disabled', 'prebrew', @@ -99,7 +97,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-plus', + 'original_icon': None, 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -113,7 +111,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'MR01234 Prebrew/-infusion mode', - 'icon': 'mdi:water-plus', 'options': list([ 'disabled', 'prebrew', @@ -154,7 +151,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-plus', + 'original_icon': None, 'original_name': 'Prebrew/-infusion mode', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -168,7 +165,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'MR01234 Steam level', - 'icon': 'mdi:water-thermometer', 'options': list([ '1', '2', @@ -209,7 +205,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-thermometer', + 'original_icon': None, 'original_name': 'Steam level', 'platform': 'lamarzocco', 'previous_unique_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index e3719a25a33..4228252f526 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -22,7 +22,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:thermometer', + 'original_icon': None, 'original_name': 'Current coffee temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -37,7 +37,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'GS01234 Current coffee temperature', - 'icon': 'mdi:thermometer', 'state_class': , 'unit_of_measurement': , }), @@ -71,7 +70,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:thermometer', + 'original_icon': None, 'original_name': 'Current steam temperature', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -86,7 +85,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'GS01234 Current steam temperature', - 'icon': 'mdi:thermometer', 'state_class': , 'unit_of_measurement': , }), @@ -120,7 +118,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:timer', + 'original_icon': None, 'original_name': 'Shot timer', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -135,7 +133,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'GS01234 Shot timer', - 'icon': 'mdi:timer', 'state_class': , 'unit_of_measurement': , }), @@ -169,7 +166,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:chart-line', + 'original_icon': None, 'original_name': 'Total coffees made', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -183,7 +180,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Total coffees made', - 'icon': 'mdi:chart-line', 'state_class': , 'unit_of_measurement': 'drinks', }), @@ -217,7 +213,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:chart-line', + 'original_icon': None, 'original_name': 'Total flushes made', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -231,7 +227,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Total flushes made', - 'icon': 'mdi:chart-line', 'state_class': , 'unit_of_measurement': 'drinks', }), diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 36df947bb70..bf7062d65bd 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -31,7 +31,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234', - 'icon': 'mdi:power', }), 'context': , 'entity_id': 'switch.gs01234', @@ -61,7 +60,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:power', + 'original_icon': None, 'original_name': None, 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -75,7 +74,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Auto on/off', - 'icon': 'mdi:alarm', }), 'context': , 'entity_id': 'switch.gs01234_auto_on_off', @@ -105,7 +103,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:alarm', + 'original_icon': None, 'original_name': 'Auto on/off', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -119,7 +117,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Steam boiler', - 'icon': 'mdi:water-boiler', }), 'context': , 'entity_id': 'switch.gs01234_steam_boiler', @@ -149,7 +146,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:water-boiler', + 'original_icon': None, 'original_name': 'Steam boiler', 'platform': 'lamarzocco', 'previous_unique_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 29d09278ea2..a1ee4de2c4b 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -6,7 +6,6 @@ 'device_class': 'firmware', 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS01234 Gateway firmware', - 'icon': 'mdi:cloud-download', 'in_progress': False, 'installed_version': 'v2.2-rc0', 'latest_version': 'v3.1-rc4', @@ -44,7 +43,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:cloud-download', + 'original_icon': None, 'original_name': 'Gateway firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, @@ -61,7 +60,6 @@ 'device_class': 'firmware', 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS01234 Machine firmware', - 'icon': 'mdi:cloud-download', 'in_progress': False, 'installed_version': '1.1', 'latest_version': '1.2', @@ -99,7 +97,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:cloud-download', + 'original_icon': None, 'original_name': 'Machine firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, From 969ee5dd9f835657edf004f6fe0e73f0fbd32666 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 21 Jan 2024 13:30:03 +0100 Subject: [PATCH 0864/1544] Add icon translation to Jellyfin (#108559) --- homeassistant/components/jellyfin/icons.json | 9 +++++++++ homeassistant/components/jellyfin/sensor.py | 2 +- tests/components/jellyfin/test_sensor.py | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/jellyfin/icons.json diff --git a/homeassistant/components/jellyfin/icons.json b/homeassistant/components/jellyfin/icons.json new file mode 100644 index 00000000000..6dcfa4b2706 --- /dev/null +++ b/homeassistant/components/jellyfin/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "watching": { + "default": "mdi:television-play" + } + } + } +} diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 0f1afd30e9b..df503d14378 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -42,8 +42,8 @@ def _count_now_playing(data: JellyfinDataT) -> int: SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = { "sessions": JellyfinSensorEntityDescription( key="watching", + translation_key="watching", name=None, - icon="mdi:television-play", native_unit_of_measurement="Watching", value_fn=_count_now_playing, ) diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 733cb795271..e1377d81100 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -27,7 +27,7 @@ async def test_watching( assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER" - assert state.attributes.get(ATTR_ICON) == "mdi:television-play" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching" assert state.state == "3" From 6a197e93aafdebcd53e12bdd653220874718e3dd Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 21 Jan 2024 13:32:56 +0100 Subject: [PATCH 0865/1544] Add icon translations to Roborock (#108508) --- .../components/roborock/binary_sensor.py | 5 - homeassistant/components/roborock/button.py | 4 - homeassistant/components/roborock/icons.json | 109 ++++++++++++++++++ homeassistant/components/roborock/number.py | 1 - homeassistant/components/roborock/sensor.py | 13 --- homeassistant/components/roborock/switch.py | 4 - homeassistant/components/roborock/time.py | 4 - 7 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/roborock/icons.json diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 03e1eabe45a..e4e65288832 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -40,7 +40,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="dry_status", translation_key="mop_drying_status", - icon="mdi:heat-wave", device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, @@ -48,7 +47,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_box_carriage_status", translation_key="mop_attached", - icon="mdi:square-rounded", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_carriage_status, @@ -56,7 +54,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_box_status", translation_key="water_box_attached", - icon="mdi:water", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_status, @@ -64,7 +61,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="water_shortage", translation_key="water_shortage", - icon="mdi:water", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, @@ -72,7 +68,6 @@ BINARY_SENSOR_DESCRIPTIONS = [ RoborockBinarySensorDescription( key="in_cleaning", translation_key="in_cleaning", - icon="mdi:vacuum", device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.in_cleaning, diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 7744c5988d8..e64b39c2383 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -35,7 +35,6 @@ class RoborockButtonDescription( CONSUMABLE_BUTTON_DESCRIPTIONS = [ RoborockButtonDescription( key="reset_sensor_consumable", - icon="mdi:eye-outline", translation_key="reset_sensor_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["sensor_dirty_time"], @@ -44,7 +43,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_air_filter_consumable", - icon="mdi:air-filter", translation_key="reset_air_filter_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["filter_work_time"], @@ -53,7 +51,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_side_brush_consumable", - icon="mdi:brush", translation_key="reset_side_brush_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["side_brush_work_time"], @@ -62,7 +59,6 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ ), RoborockButtonDescription( key="reset_main_brush_consumable", - icon="mdi:brush", translation_key="reset_main_brush_consumable", command=RoborockCommand.RESET_CONSUMABLE, param=["main_brush_work_time"], diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json new file mode 100644 index 00000000000..43e7f185433 --- /dev/null +++ b/homeassistant/components/roborock/icons.json @@ -0,0 +1,109 @@ +{ + "entity": { + "binary_sensor": { + "mop_drying_status": { + "default": "mdi:heat-wave" + }, + "mop_attached": { + "default": "mdi:square-rounded" + }, + "water_box_attached": { + "default": "mdi:water" + }, + "water_shortage": { + "default": "mdi:water" + }, + "in_cleaning": { + "default": "mdi:vacuum" + } + }, + "button": { + "reset_sensor_consumable": { + "default": "mdi:eye-outline" + }, + "reset_air_filter_consumable": { + "default": "mdi:air-filter" + }, + "reset_side_brush_consumable": { + "default": "mdi:brush" + }, + "reset_main_brush_consumable": { + "default": "mdi:brush" + } + }, + "number": { + "volume": { + "default": "mdi:volume-source" + } + }, + "sensor": { + "main_brush_time_left": { + "default": "mdi:brush" + }, + "side_brush_time_left": { + "default": "mdi:brush" + }, + "filter_time_left": { + "default": "mdi:air-filter" + }, + "sensor_time_left": { + "default": "mdi:eye-outline" + }, + "total_cleaning_time": { + "default": "mdi:history" + }, + "status": { + "default": "mdi:information-outline" + }, + "cleaning_area": { + "default": "mdi:texture-box" + }, + "total_cleaning_area": { + "default": "mdi:texture-box" + }, + "vacuum_error": { + "default": "mdi:alert-circle" + }, + "last_clean_start": { + "default": "mdi:clock-time-twelve" + }, + "last_clean_end": { + "default": "mdi:clock-time-twelve" + }, + "clean_percent": { + "default": "mdi:progress-check" + }, + "dock_error": { + "default": "mdi:garage-open" + } + }, + "switch": { + "child_lock": { + "default": "mdi:account-lock" + }, + "status_indicator": { + "default": "mdi:alarm-light-outline" + }, + "dnd_switch": { + "default": "mdi:bell-cancel" + }, + "off_peak_switch": { + "default": "mdi:power-plug" + } + }, + "time": { + "dnd_start_time": { + "default": "mdi:bell-cancel" + }, + "dnd_end_time": { + "default": "mdi:bell-ring" + }, + "off_peak_start": { + "default": "mdi:power-plug" + }, + "off_peak_end": { + "default": "mdi:power-plug-off" + } + } + } +} diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 8957c487a64..2218e5ec2ce 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -44,7 +44,6 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ RoborockNumberDescription( key="volume", translation_key="volume", - icon="mdi:volume-source", native_min_value=0, native_max_value=100, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index e3cea00476f..d5258879acb 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -65,7 +65,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="main_brush_time_left", - icon="mdi:brush", device_class=SensorDeviceClass.DURATION, translation_key="main_brush_time_left", value_fn=lambda data: data.consumable.main_brush_time_left, @@ -75,7 +74,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="side_brush_time_left", - icon="mdi:brush", device_class=SensorDeviceClass.DURATION, translation_key="side_brush_time_left", value_fn=lambda data: data.consumable.side_brush_time_left, @@ -85,7 +83,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="filter_time_left", - icon="mdi:air-filter", device_class=SensorDeviceClass.DURATION, translation_key="filter_time_left", value_fn=lambda data: data.consumable.filter_time_left, @@ -95,7 +92,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, key="sensor_time_left", - icon="mdi:eye-outline", device_class=SensorDeviceClass.DURATION, translation_key="sensor_time_left", value_fn=lambda data: data.consumable.sensor_time_left, @@ -113,14 +109,12 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=UnitOfTime.SECONDS, key="total_cleaning_time", translation_key="total_cleaning_time", - icon="mdi:history", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( key="status", - icon="mdi:information-outline", device_class=SensorDeviceClass.ENUM, translation_key="status", value_fn=lambda data: data.status.state_name, @@ -130,7 +124,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="cleaning_area", - icon="mdi:texture-box", translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, @@ -138,7 +131,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="total_cleaning_area", - icon="mdi:texture-box", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, @@ -146,7 +138,6 @@ SENSOR_DESCRIPTIONS = [ ), RoborockSensorDescription( key="vacuum_error", - icon="mdi:alert-circle", translation_key="vacuum_error", device_class=SensorDeviceClass.ENUM, value_fn=lambda data: data.status.error_code_name, @@ -165,7 +156,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( key="last_clean_start", translation_key="last_clean_start", - icon="mdi:clock-time-twelve", value_fn=lambda data: data.last_clean_record.begin_datetime if data.last_clean_record is not None else None, @@ -175,7 +165,6 @@ SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( key="last_clean_end", translation_key="last_clean_end", - icon="mdi:clock-time-twelve", value_fn=lambda data: data.last_clean_record.end_datetime if data.last_clean_record is not None else None, @@ -185,7 +174,6 @@ SENSOR_DESCRIPTIONS = [ # Only available on some newer models RoborockSensorDescription( key="clean_percent", - icon="mdi:progress-check", translation_key="clean_percent", value_fn=lambda data: data.status.clean_percent, entity_category=EntityCategory.DIAGNOSTIC, @@ -194,7 +182,6 @@ SENSOR_DESCRIPTIONS = [ # Only available with more than just the basic dock RoborockSensorDescription( key="dock_error", - icon="mdi:garage-open", translation_key="dock_error", value_fn=_dock_error_value_fn, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 37e8488dd22..acd3e2613af 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -52,7 +52,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="lock_status", key="child_lock", translation_key="child_lock", - icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -63,7 +62,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="status", key="status_indicator", translation_key="status_indicator", - icon="mdi:alarm-light-outline", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -81,7 +79,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="enabled", key="dnd_switch", translation_key="dnd_switch", - icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, ), RoborockSwitchDescription( @@ -99,7 +96,6 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ attribute="enabled", key="off_peak_switch", translation_key="off_peak_switch", - icon="mdi:power-plug", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 7a8d21fc0f1..71dee773fa4 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -46,7 +46,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="dnd_start_time", translation_key="dnd_start_time", - icon="mdi:bell-cancel", cache_key=CacheableAttribute.dnd_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -64,7 +63,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="dnd_end_time", translation_key="dnd_end_time", - icon="mdi:bell-ring", cache_key=CacheableAttribute.dnd_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -82,7 +80,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="off_peak_start", translation_key="off_peak_start", - icon="mdi:power-plug", cache_key=CacheableAttribute.valley_electricity_timer, update_value=lambda cache, desired_time: cache.update_value( [ @@ -101,7 +98,6 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ RoborockTimeDescription( key="off_peak_end", translation_key="off_peak_end", - icon="mdi:power-plug-off", cache_key=CacheableAttribute.valley_electricity_timer, update_value=lambda cache, desired_time: cache.update_value( [ From 48c434da867f8e9e06c75175f53758b82071b05b Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 21 Jan 2024 13:34:26 +0100 Subject: [PATCH 0866/1544] Add icon translations to DWD Weather Warnings (#108501) --- .../components/dwd_weather_warnings/icons.json | 12 ++++++++++++ .../components/dwd_weather_warnings/sensor.py | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/dwd_weather_warnings/icons.json diff --git a/homeassistant/components/dwd_weather_warnings/icons.json b/homeassistant/components/dwd_weather_warnings/icons.json new file mode 100644 index 00000000000..abee79acf21 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "current_warning_level": { + "default": "mdi:close-octagon-outline" + }, + "advance_warning_level": { + "default": "mdi:close-octagon-outline" + } + } + } +} diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index e88fb3c408b..d3e3b4a3772 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -44,12 +44,10 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CURRENT_WARNING_SENSOR, translation_key=CURRENT_WARNING_SENSOR, - icon="mdi:close-octagon-outline", ), SensorEntityDescription( key=ADVANCE_WARNING_SENSOR, translation_key=ADVANCE_WARNING_SENSOR, - icon="mdi:close-octagon-outline", ), ) From fcaa2fcf039c54fb5333b5c9f1720a5dbcc9e0f6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 21 Jan 2024 13:44:32 +0100 Subject: [PATCH 0867/1544] Make remaining WLED entities translatable (#108534) --- .../components/wled/binary_sensor.py | 2 +- homeassistant/components/wled/light.py | 3 +- homeassistant/components/wled/number.py | 7 ++-- homeassistant/components/wled/select.py | 5 +-- homeassistant/components/wled/strings.json | 34 +++++++++++++++++++ homeassistant/components/wled/switch.py | 5 +-- .../wled/snapshots/test_binary_sensor.ambr | 2 +- .../wled/snapshots/test_number.ambr | 12 +++---- .../wled/snapshots/test_select.ambr | 2 +- .../wled/snapshots/test_switch.ambr | 2 +- 10 files changed, 56 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/wled/binary_sensor.py b/homeassistant/components/wled/binary_sensor.py index a248ea57c7d..6191235f423 100644 --- a/homeassistant/components/wled/binary_sensor.py +++ b/homeassistant/components/wled/binary_sensor.py @@ -34,7 +34,7 @@ class WLEDUpdateBinarySensor(WLEDEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.UPDATE - _attr_name = "Firmware" + _attr_translation_key = "firmware" # Disabled by default, as this entity is deprecated. _attr_entity_registry_enabled_default = False diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index b793654c886..5ca86978f0f 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -121,7 +121,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if segment == 0: self._attr_name = None else: - self._attr_name = f"Segment {segment}" + self._attr_translation_key = "segment" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( f"{self.coordinator.data.info.mac_address}_{self._segment}" diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 0fa7d464722..5b88165207f 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -49,7 +49,7 @@ class WLEDNumberEntityDescription(NumberEntityDescription): NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, - name="Speed", + translation_key="speed", icon="mdi:speedometer", entity_category=EntityCategory.CONFIG, native_step=1, @@ -59,7 +59,7 @@ NUMBERS = [ ), WLEDNumberEntityDescription( key=ATTR_INTENSITY, - name="Intensity", + translation_key="intensity", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, @@ -87,7 +87,8 @@ class WLEDNumber(WLEDEntity, NumberEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} {description.name}" + self._attr_translation_key = f"segment_{description.translation_key}" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( f"{coordinator.data.info.mac_address}_{description.key}_{segment}" diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 977c76025ac..7df43a4250d 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -139,7 +139,7 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:palette-outline" - _attr_name = "Color palette" + _attr_translation_key = "color_palette" _segment: int def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: @@ -149,7 +149,8 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} color palette" + self._attr_translation_key = "segment_color_palette" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" self._attr_options = [ diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index eff6dfab572..22b1e451a68 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -35,12 +35,40 @@ } }, "entity": { + "binary_sensor": { + "firmware": { + "name": "Firmware" + } + }, "light": { "main": { "name": "Main" + }, + "segment": { + "name": "Segment {segment}" + } + }, + "number": { + "intensity": { + "name": "Intensity" + }, + "segment_intensity": { + "name": "Segment {segment} intensity" + }, + "speed": { + "name": "Speed" + }, + "segment_speed": { + "name": "Segment {segment} speed" } }, "select": { + "color_palette": { + "name": "Color palette" + }, + "segment_color_palette": { + "name": "Segment {segment} color palette" + }, "live_override": { "name": "Live override", "state": { @@ -97,6 +125,12 @@ }, "sync_receive": { "name": "Sync receive" + }, + "reverse": { + "name": "Reverse" + }, + "segment_reverse": { + "name": "Segment {segment} reverse" } } }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 680684e96df..1fb300bd01d 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -159,7 +159,7 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:swap-horizontal-bold" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Reverse" + _attr_translation_key = "reverse" _segment: int def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: @@ -169,7 +169,8 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): # Segment 0 uses a simpler name, which is more natural for when using # a single segment / using WLED with one big LED strip. if segment != 0: - self._attr_name = f"Segment {segment} reverse" + self._attr_translation_key = "segment_reverse" + self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = f"{coordinator.data.info.mac_address}_reverse_{segment}" self._segment = segment diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr index 6fc9b2497b5..03d1d4f61dc 100644 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -38,7 +38,7 @@ 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'firmware', 'unique_id': 'aabbccddeeff_update', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 47dafe039b2..5539f1f4503 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -2,7 +2,7 @@ # name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'friendly_name': 'WLED RGB Light Segment 1 intensity', 'max': 255, 'min': 0, 'mode': , @@ -42,11 +42,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Segment 1 Intensity', + 'original_name': 'Segment 1 intensity', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_intensity', 'unique_id': 'aabbccddeeff_intensity_1', 'unit_of_measurement': None, }) @@ -86,7 +86,7 @@ # name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'WLED RGB Light Segment 1 Speed', + 'friendly_name': 'WLED RGB Light Segment 1 speed', 'icon': 'mdi:speedometer', 'max': 255, 'min': 0, @@ -127,11 +127,11 @@ }), 'original_device_class': None, 'original_icon': 'mdi:speedometer', - 'original_name': 'Segment 1 Speed', + 'original_name': 'Segment 1 speed', 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_speed', 'unique_id': 'aabbccddeeff_speed_1', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 92604f86d2d..9c8cb52b4a6 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -230,7 +230,7 @@ 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'segment_color_palette', 'unique_id': 'aabbccddeeff_palette_1', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index feecfd1e1ff..8031624c75b 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -117,7 +117,7 @@ 'platform': 'wled', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'reverse', 'unique_id': 'aabbccddeeff_reverse_0', 'unit_of_measurement': None, }) From a7c94dda73bbe043c3fd5b94ea201a23ab9b35f9 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Mon, 22 Jan 2024 00:03:28 +1100 Subject: [PATCH 0868/1544] Add unique id to geonetnz_volcano sensors (#108556) add unique id to each sensor --- homeassistant/components/geonetnz_volcano/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 583b75a24eb..f02e076b66c 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -63,6 +63,7 @@ class GeonetnzVolcanoSensor(SensorEntity): self._config_entry_id = config_entry_id self._feed_manager = feed_manager self._external_id = external_id + self._attr_unique_id = f"{config_entry_id}_{external_id}" self._unit_system = unit_system self._title = None self._distance = None From c3da51db4e12d6192c6616eade0d33e35f0543da Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 21 Jan 2024 14:57:00 +0100 Subject: [PATCH 0869/1544] Icon translation for imap mail count sensor (#108576) --- homeassistant/components/imap/icons.json | 12 ++++++++++++ homeassistant/components/imap/sensor.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/imap/icons.json diff --git a/homeassistant/components/imap/icons.json b/homeassistant/components/imap/icons.json new file mode 100644 index 00000000000..a4a79aef60e --- /dev/null +++ b/homeassistant/components/imap/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "imap_mail_count": { + "default": "mdi:email-alert-outline", + "state": { + "0": "mdi:email-check-outline" + } + } + } + } +} diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 92a66fabe49..07e77b31470 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -20,6 +20,8 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, + translation_key="imap_mail_count", + name=None, ) @@ -40,9 +42,7 @@ class ImapSensor( ): """Representation of an IMAP sensor.""" - _attr_icon = "mdi:email-outline" _attr_has_entity_name = True - _attr_name = None def __init__( self, From 9b3d3b3b2d9e4a9a57cbf53c8e89bbf2ffdf44b5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 21 Jan 2024 15:25:12 +0000 Subject: [PATCH 0870/1544] Add authentication to tplink integration for newer devices (#105143) * Add authentication flows to tplink integration to enable newer device protocol support * Add websession passing to tplink integration discover methods * Use SmartDevice.connect() * Update to use DeviceConfig * Use credential hashes * Bump python-kasa to 0.6.0.dev0 * Fix tests and address review comments * Add autodetection for L530, P110, and L900 This adds mac address prefixes for the devices I have. The wildcards are left quite lax assuming different series may share the same prefix. * Bump tplink to 0.6.0.dev1 * Add config flow tests * Use short_mac if alias is None and try legacy connect on discovery timeout * Add config_flow tests * Add init tests * Migrate to aiohttp * add some more ouis * final * ip change fix * add fixmes * fix O(n) searching * fix O(n) searching * move code that cannot fail outside of try block * fix missing reauth_successful string * add doc strings, cleanups * error message by password * dry * adjust discovery timeout * integration discovery already formats mac * tweaks * cleanups * cleanups * Update post review and fix broken tests * Fix TODOs and FIXMEs in test_config_flow * Add pragma no cover * bump, apply suggestions * remove no cover * use iden check * Apply suggestions from code review * Fix branched test and update integration title * legacy typing * Update homeassistant/components/tplink/__init__.py * lint * Remove more unused consts * Update test docstrings * Add sdb9696 to tplink codeowners * Update docstring on test for invalid DeviceConfig * Update test stored credentials test --------- Co-authored-by: Teemu Rytilahti Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/tplink/__init__.py | 125 ++- .../components/tplink/config_flow.py | 330 ++++++- homeassistant/components/tplink/const.py | 9 +- homeassistant/components/tplink/manifest.json | 34 +- homeassistant/components/tplink/strings.json | 31 +- homeassistant/components/tplink/switch.py | 6 +- homeassistant/generated/dhcp.py | 35 + homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/__init__.py | 99 ++- tests/components/tplink/conftest.py | 119 ++- tests/components/tplink/test_config_flow.py | 830 ++++++++++++++++-- tests/components/tplink/test_init.py | 137 ++- tests/components/tplink/test_light.py | 27 +- tests/components/tplink/test_sensor.py | 16 +- tests/components/tplink/test_switch.py | 14 +- 18 files changed, 1661 insertions(+), 161 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 73110f757fc..f4a1d72edc0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1371,8 +1371,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco -/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 +/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco @sdb9696 /homeassistant/components/tplink_omada/ @MarkGodwin /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 4efd7ffdf0b..4b684abf280 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -3,37 +3,66 @@ from __future__ import annotations import asyncio from datetime import timedelta +import logging from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from aiohttp import ClientSession +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, +) +from kasa.httpclient import get_cookie_jar from homeassistant import config_entries from homeassistant.components import network from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_ALIAS, + CONF_AUTHENTICATION, CONF_HOST, CONF_MAC, - CONF_NAME, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, ) +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PLATFORMS +from .const import ( + CONF_DEVICE_CONFIG, + CONNECT_TIMEOUT, + DISCOVERY_TIMEOUT, + DOMAIN, + PLATFORMS, +) from .coordinator import TPLinkDataUpdateCoordinator from .models import TPLinkData DISCOVERY_INTERVAL = timedelta(minutes=15) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + + +def create_async_tplink_clientsession(hass: HomeAssistant) -> ClientSession: + """Return aiohttp clientsession with cookie jar configured.""" + return async_create_clientsession( + hass, verify_ssl=False, cookie_jar=get_cookie_jar() + ) + @callback def async_trigger_discovery( @@ -47,17 +76,31 @@ def async_trigger_discovery( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, data={ - CONF_NAME: device.alias, + CONF_ALIAS: device.alias or mac_alias(device.mac), CONF_HOST: device.host, CONF_MAC: formatted_mac, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: """Discover TPLink devices on configured network interfaces.""" + + credentials = await get_credentials(hass) broadcast_addresses = await network.async_get_ipv4_broadcast_addresses(hass) - tasks = [Discover.discover(target=str(address)) for address in broadcast_addresses] + tasks = [ + Discover.discover( + target=str(address), + discovery_timeout=DISCOVERY_TIMEOUT, + timeout=CONNECT_TIMEOUT, + credentials=credentials, + ) + for address in broadcast_addresses + ] discovered_devices: dict[str, SmartDevice] = {} for device_list in await asyncio.gather(*tasks): for device in device_list.values(): @@ -67,7 +110,7 @@ async def async_discover_devices(hass: HomeAssistant) -> dict[str, SmartDevice]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) if discovered_devices := await async_discover_devices(hass): async_trigger_discovery(hass, discovered_devices) @@ -86,12 +129,51 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" - host = entry.data[CONF_HOST] + host: str = entry.data[CONF_HOST] + credentials = await get_credentials(hass) + + config: DeviceConfig | None = None + if config_dict := entry.data.get(CONF_DEVICE_CONFIG): + try: + config = DeviceConfig.from_dict(config_dict) + except SmartDeviceException: + _LOGGER.warning( + "Invalid connection type dict for %s: %s", host, config_dict + ) + + if not config: + config = DeviceConfig(host) + + config.timeout = CONNECT_TIMEOUT + if config.uses_http is True: + config.http_client = create_async_tplink_clientsession(hass) + if credentials: + config.credentials = credentials try: - device: SmartDevice = await Discover.discover_single(host, timeout=10) + device: SmartDevice = await SmartDevice.connect(config=config) + except AuthenticationException as ex: + raise ConfigEntryAuthFailed from ex except SmartDeviceException as ex: raise ConfigEntryNotReady from ex + device_config_dict = device.config.to_dict( + credentials_hash=device.credentials_hash, exclude_credentials=True + ) + updates: dict[str, Any] = {} + if device_config_dict != config_dict: + updates[CONF_DEVICE_CONFIG] = device_config_dict + if entry.data.get(CONF_ALIAS) != device.alias: + updates[CONF_ALIAS] = device.alias + if entry.data.get(CONF_MODEL) != device.model: + updates[CONF_MODEL] = device.model + if updates: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + **updates, + }, + ) found_mac = dr.format_mac(device.mac) if found_mac != entry.unique_id: # If the mac address of the device does not match the unique_id @@ -130,6 +212,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) await device.protocol.close() + return unload_ok @@ -141,3 +224,25 @@ def legacy_device_id(device: SmartDevice) -> str: if "_" not in device_id: return device_id return device_id.split("_")[1] + + +async def get_credentials(hass: HomeAssistant) -> Credentials | None: + """Retrieve the credentials from hass data.""" + if DOMAIN in hass.data and CONF_AUTHENTICATION in hass.data[DOMAIN]: + auth = hass.data[DOMAIN][CONF_AUTHENTICATION] + return Credentials(auth[CONF_USERNAME], auth[CONF_PASSWORD]) + + return None + + +async def set_credentials(hass: HomeAssistant, username: str, password: str) -> None: + """Save the credentials to HASS data.""" + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = { + CONF_USERNAME: username, + CONF_PASSWORD: password, + } + + +def mac_alias(mac: str) -> str: + """Convert a MAC address to a short address for the UI.""" + return mac.replace(":", "")[-4:].upper() diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index a783c7b902f..68a40d81415 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -1,28 +1,57 @@ """Config flow for TP-Link.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from kasa import SmartDevice, SmartDeviceException -from kasa.discover import Discover +from kasa import ( + AuthenticationException, + Credentials, + DeviceConfig, + Discover, + SmartDevice, + SmartDeviceException, + TimeoutException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigEntryState +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType -from . import async_discover_devices -from .const import DOMAIN +from . import ( + async_discover_devices, + create_async_tplink_clientsession, + get_credentials, + mac_alias, + set_credentials, +) +from .const import CONF_DEVICE_CONFIG, CONNECT_TIMEOUT, DOMAIN + +STEP_AUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" VERSION = 1 + MINOR_VERSION = 2 + reauth_entry: ConfigEntry | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -40,27 +69,114 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle integration discovery.""" return await self._async_handle_discovery( - discovery_info[CONF_HOST], discovery_info[CONF_MAC] + discovery_info[CONF_HOST], + discovery_info[CONF_MAC], + discovery_info[CONF_DEVICE_CONFIG], ) - async def _async_handle_discovery(self, host: str, mac: str) -> FlowResult: + @callback + def _update_config_if_entry_in_setup_error( + self, entry: ConfigEntry, host: str, config: dict + ) -> None: + """If discovery encounters a device that is in SETUP_ERROR update the device config.""" + if entry.state is not ConfigEntryState.SETUP_ERROR: + return + entry_data = entry.data + entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) + if entry_config_dict == config and entry_data[CONF_HOST] == host: + return + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + raise AbortFlow("already_configured") + + async def _async_handle_discovery( + self, host: str, formatted_mac: str, config: dict | None = None + ) -> FlowResult: """Handle any discovery.""" - await self.async_set_unique_id(dr.format_mac(mac)) + current_entry = await self.async_set_unique_id( + formatted_mac, raise_on_progress=False + ) + if config and current_entry: + self._update_config_if_entry_in_setup_error(current_entry, host, config) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) self.context[CONF_HOST] = host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == host: return self.async_abort(reason="already_in_progress") - + credentials = await get_credentials(self.hass) try: - self._discovered_device = await self._async_try_connect( - host, raise_on_progress=True + await self._async_try_discover_and_update( + host, credentials, raise_on_progress=True ) + except AuthenticationException: + return await self.async_step_discovery_auth_confirm() except SmartDeviceException: return self.async_abort(reason="cannot_connect") + return await self.async_step_discovery_confirm() + async def async_step_discovery_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + assert self._discovered_device is not None + errors = {} + + credentials = await get_credentials(self.hass) + if credentials and credentials != self._discovered_device.config.credentials: + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + pass # Authentication exceptions should continue to the rest of the step + else: + self._discovered_device = device + return await self.async_step_discovery_confirm() + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + self._discovered_device = device + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return await self.async_step_discovery_confirm() + + placeholders = self._async_make_placeholders_from_discovery() + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="discovery_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, + ) + + def _async_make_placeholders_from_discovery(self) -> dict[str, str]: + """Make placeholders for the discovery steps.""" + discovered_device = self._discovered_device + assert discovered_device is not None + return { + "name": discovered_device.alias or mac_alias(discovered_device.mac), + "model": discovered_device.model, + "host": discovered_device.host, + } + async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,11 +186,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self._async_create_entry_from_device(self._discovered_device) self._set_confirm_only() - placeholders = { - "name": self._discovered_device.alias, - "model": self._discovered_device.model, - "host": self._discovered_device.host, - } + placeholders = self._async_make_placeholders_from_discovery() self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_confirm", description_placeholders=placeholders @@ -88,8 +200,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() + self._async_abort_entries_match({CONF_HOST: host}) + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) try: - device = await self._async_try_connect(host, raise_on_progress=False) + device = await self._async_try_discover_and_update( + host, credentials, raise_on_progress=False + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() except SmartDeviceException: errors["base"] = "cannot_connect" else: @@ -101,6 +220,37 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_user_auth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that auth is required.""" + errors = {} + host = self.context[CONF_HOST] + assert self._discovered_device is not None + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self._async_create_entry_from_device(device) + + return self.async_show_form( + step_id="user_auth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders={CONF_HOST: host}, + ) + async def async_step_pick_device( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -108,7 +258,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: mac = user_input[CONF_DEVICE] await self.async_set_unique_id(mac, raise_on_progress=False) - return self._async_create_entry_from_device(self._discovered_devices[mac]) + self._discovered_device = self._discovered_devices[mac] + host = self._discovered_device.host + + self.context[CONF_HOST] = host + credentials = await get_credentials(self.hass) + + try: + device = await self._async_try_connect( + self._discovered_device, credentials + ) + except AuthenticationException: + return await self.async_step_user_auth_confirm() + except SmartDeviceException: + return self.async_abort(reason="cannot_connect") + return self._async_create_entry_from_device(device) configured_devices = { entry.unique_id for entry in self._async_current_entries() @@ -116,7 +280,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_devices = await async_discover_devices(self.hass) devices_name = { formatted_mac: ( - f"{device.alias} {device.model} ({device.host}) {formatted_mac}" + f"{device.alias or mac_alias(device.mac)} {device.model} ({device.host}) {formatted_mac}" ) for formatted_mac, device in self._discovered_devices.items() if formatted_mac not in configured_devices @@ -129,6 +293,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}), ) + async def _async_reload_requires_auth_entries(self) -> None: + """Reload any in progress config flow that now have credentials.""" + _config_entries = self.hass.config_entries + + if reauth_entry := self.reauth_entry: + await _config_entries.async_reload(reauth_entry.entry_id) + + for flow in _config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ): + context: dict[str, Any] = flow["context"] + if context.get("source") != SOURCE_REAUTH: + continue + entry_id: str = context["entry_id"] + if entry := _config_entries.async_get_entry(entry_id): + await _config_entries.async_reload(entry.entry_id) + if entry.state is ConfigEntryState.LOADED: + _config_entries.flow.async_abort(flow["flow_id"]) + @callback def _async_create_entry_from_device(self, device: SmartDevice) -> FlowResult: """Create a config entry from a smart device.""" @@ -137,16 +320,113 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=f"{device.alias} {device.model}", data={ CONF_HOST: device.host, + CONF_ALIAS: device.alias, + CONF_MODEL: device.model, + CONF_DEVICE_CONFIG: device.config.to_dict( + credentials_hash=device.credentials_hash, + exclude_credentials=True, + ), }, ) + async def _async_try_discover_and_update( + self, + host: str, + credentials: Credentials | None, + raise_on_progress: bool, + ) -> SmartDevice: + """Try to discover the device and call update. + + Will try to connect to legacy devices if discovery fails. + """ + try: + self._discovered_device = await Discover.discover_single( + host, credentials=credentials + ) + except TimeoutException: + # Try connect() to legacy devices if discovery fails + self._discovered_device = await SmartDevice.connect( + config=DeviceConfig(host) + ) + else: + if self._discovered_device.config.uses_http: + self._discovered_device.config.http_client = ( + create_async_tplink_clientsession(self.hass) + ) + await self._discovered_device.update() + await self.async_set_unique_id( + dr.format_mac(self._discovered_device.mac), + raise_on_progress=raise_on_progress, + ) + return self._discovered_device + async def _async_try_connect( - self, host: str, raise_on_progress: bool = True + self, + discovered_device: SmartDevice, + credentials: Credentials | None, ) -> SmartDevice: """Try to connect.""" - self._async_abort_entries_match({CONF_HOST: host}) - device: SmartDevice = await Discover.discover_single(host) + self._async_abort_entries_match({CONF_HOST: discovered_device.host}) + + config = discovered_device.config + if credentials: + config.credentials = credentials + config.timeout = CONNECT_TIMEOUT + if config.uses_http: + config.http_client = create_async_tplink_clientsession(self.hass) + + self._discovered_device = await SmartDevice.connect(config=config) await self.async_set_unique_id( - dr.format_mac(device.mac), raise_on_progress=raise_on_progress + dr.format_mac(self._discovered_device.mac), + raise_on_progress=False, + ) + return self._discovered_device + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Start the reauthentication flow if the device needs updated credentials.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + reauth_entry = self.reauth_entry + assert reauth_entry is not None + entry_data = reauth_entry.data + host = entry_data[CONF_HOST] + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + credentials = Credentials(username, password) + try: + await self._async_try_discover_and_update( + host, + credentials=credentials, + raise_on_progress=True, + ) + except AuthenticationException: + errors[CONF_PASSWORD] = "invalid_auth" + except SmartDeviceException: + errors["base"] = "cannot_connect" + else: + await set_credentials(self.hass, username, password) + self.hass.async_create_task(self._async_reload_requires_auth_entries()) + return self.async_abort(reason="reauth_successful") + + # Old config entries will not have these values. + alias = entry_data.get(CONF_ALIAS) or "unknown" + model = entry_data.get(CONF_MODEL) or "unknown" + + placeholders = {"name": alias, "model": model, "host": host} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + errors=errors, + description_placeholders=placeholders, ) - return device diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 22b5741fceb..57047af8092 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -7,15 +7,14 @@ from homeassistant.const import Platform DOMAIN = "tplink" +DISCOVERY_TIMEOUT = 5 # Home Assistant will complain if startup takes > 10s +CONNECT_TIMEOUT = 5 + ATTR_CURRENT_A: Final = "current_a" ATTR_CURRENT_POWER_W: Final = "current_power_w" ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" -CONF_DIMMER: Final = "dimmer" -CONF_LIGHT: Final = "light" -CONF_STRIP: Final = "strip" -CONF_SWITCH: Final = "switch" -CONF_SENSOR: Final = "sensor" +CONF_DEVICE_CONFIG: Final = "device_config" PLATFORMS: Final = [Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 162344f04ec..5791e429d71 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -1,13 +1,17 @@ { "domain": "tplink", - "name": "TP-Link Kasa Smart", - "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], + "name": "TP-Link Smart Home", + "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco", "@sdb9696"], "config_flow": true, "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, + { + "hostname": "e[sp]*", + "macaddress": "3C52A1*" + }, { "hostname": "e[sp]*", "macaddress": "54AF97*" @@ -32,6 +36,10 @@ "hostname": "hs*", "macaddress": "9C5322*" }, + { + "hostname": "k[lps]*", + "macaddress": "5091E3*" + }, { "hostname": "k[lps]*", "macaddress": "9C5322*" @@ -163,11 +171,31 @@ { "hostname": "k[lps]*", "macaddress": "1C61B4*" + }, + { + "hostname": "l5*", + "macaddress": "5CE931*" + }, + { + "hostname": "p1*", + "macaddress": "482254*" + }, + { + "hostname": "p1*", + "macaddress": "30DE4B*" + }, + { + "hostname": "l9*", + "macaddress": "A842A1*" + }, + { + "hostname": "l9*", + "macaddress": "3460F9*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.4"] + "requirements": ["python-kasa[speedups]==0.6.0.1"] } diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 3b4024c07b4..3c4711d1632 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -18,6 +18,34 @@ }, "discovery_confirm": { "description": "Do you want to set up {name} {model} ({host})?" + }, + "user_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "discovery_auth_confirm": { + "title": "Authenticate", + "description": "The device requires authentication, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below." + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The device needs updated credentials, please input your credentials below.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -25,7 +53,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index b1ca848260f..9a54a952666 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -41,7 +41,9 @@ async def async_setup_entry( elif device.is_plug: entities.append(SmartPlugSwitch(device, parent_coordinator)) - entities.append(SmartPlugLedSwitch(device, parent_coordinator)) + # this will be removed on the led is implemented + if hasattr(device, "led"): + entities.append(SmartPlugLedSwitch(device, parent_coordinator)) async_add_entities(entities) @@ -86,7 +88,7 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - _attr_name = None + _attr_name: str | None = None def __init__( self, diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 33d069c5663..a63c814d598 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -603,6 +603,11 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "tplink", "registered_devices": True, }, + { + "domain": "tplink", + "hostname": "e[sp]*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "e[sp]*", @@ -633,6 +638,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "hs*", "macaddress": "9C5322*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "5091E3*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -798,6 +808,31 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "1C61B4*", }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "5CE931*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "482254*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "30DE4B*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "A842A1*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "3460F9*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2aa315a2daf..1cb43016efc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6086,7 +6086,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "TP-Link Kasa Smart" + "name": "TP-Link Smart Home" }, "tplink_omada": { "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 8ec8525e36c..cd43f8a1339 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2213,7 +2213,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.0.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d38f270322..a2618222da8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1683,7 +1683,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.4 +python-kasa[speedups]==0.6.0.1 # homeassistant.components.matter python-matter-server==5.1.1 diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 9006a058c57..4a79f39f6a7 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -3,6 +3,10 @@ from unittest.mock import AsyncMock, MagicMock, patch from kasa import ( + ConnectionType, + DeviceConfig, + DeviceFamilyType, + EncryptType, SmartBulb, SmartDevice, SmartDimmer, @@ -13,7 +17,13 @@ from kasa import ( from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol -from homeassistant.components.tplink import CONF_HOST +from homeassistant.components.tplink import ( + CONF_ALIAS, + CONF_DEVICE_CONFIG, + CONF_HOST, + CONF_MODEL, + Credentials, +) from homeassistant.components.tplink.const import DOMAIN from homeassistant.core import HomeAssistant @@ -22,10 +32,61 @@ from tests.common import MockConfigEntry MODULE = "homeassistant.components.tplink" MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow" IP_ADDRESS = "127.0.0.1" +IP_ADDRESS2 = "127.0.0.2" ALIAS = "My Bulb" MODEL = "HS100" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +MAC_ADDRESS2 = "11:22:33:44:55:66" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" +CREDENTIALS_HASH_LEGACY = "" +DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) +DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict( + credentials_hash=CREDENTIALS_HASH_LEGACY, exclude_credentials=True +) +CREDENTIALS = Credentials("foo", "bar") +CREDENTIALS_HASH_AUTH = "abcdefghijklmnopqrstuv==" +DEVICE_CONFIG_AUTH = DeviceConfig( + IP_ADDRESS, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_AUTH2 = DeviceConfig( + IP_ADDRESS2, + credentials=CREDENTIALS, + connection_type=ConnectionType( + DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Klap + ), + uses_http=True, +) +DEVICE_CONFIG_DICT_AUTH = DEVICE_CONFIG_AUTH.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) +DEVICE_CONFIG_DICT_AUTH2 = DEVICE_CONFIG_AUTH2.to_dict( + credentials_hash=CREDENTIALS_HASH_AUTH, exclude_credentials=True +) + +CREATE_ENTRY_DATA_LEGACY = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, +} + +CREATE_ENTRY_DATA_AUTH = { + CONF_HOST: IP_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, +} +CREATE_ENTRY_DATA_AUTH2 = { + CONF_HOST: IP_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH2, +} def _mock_protocol() -> TPLinkSmartHomeProtocol: @@ -34,11 +95,16 @@ def _mock_protocol() -> TPLinkSmartHomeProtocol: return protocol -def _mocked_bulb() -> SmartBulb: +def _mocked_bulb( + device_config=DEVICE_CONFIG_LEGACY, + credentials_hash=CREDENTIALS_HASH_LEGACY, + mac=MAC_ADDRESS, + alias=ALIAS, +) -> SmartBulb: bulb = MagicMock(auto_spec=SmartBulb, name="Mocked bulb") bulb.update = AsyncMock() - bulb.mac = MAC_ADDRESS - bulb.alias = ALIAS + bulb.mac = mac + bulb.alias = alias bulb.model = MODEL bulb.host = IP_ADDRESS bulb.brightness = 50 @@ -52,7 +118,7 @@ def _mocked_bulb() -> SmartBulb: bulb.effect = None bulb.effect_list = None bulb.hsv = (10, 30, 5) - bulb.device_id = MAC_ADDRESS + bulb.device_id = mac bulb.valid_temperature_range.min = 4000 bulb.valid_temperature_range.max = 9000 bulb.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} @@ -62,6 +128,8 @@ def _mocked_bulb() -> SmartBulb: bulb.set_hsv = AsyncMock() bulb.set_color_temp = AsyncMock() bulb.protocol = _mock_protocol() + bulb.config = device_config + bulb.credentials_hash = credentials_hash return bulb @@ -103,6 +171,8 @@ def _mocked_smart_light_strip() -> SmartLightStrip: strip.set_effect = AsyncMock() strip.set_custom_effect = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY return strip @@ -134,6 +204,8 @@ def _mocked_dimmer() -> SmartDimmer: dimmer.set_color_temp = AsyncMock() dimmer.set_led = AsyncMock() dimmer.protocol = _mock_protocol() + dimmer.config = DEVICE_CONFIG_LEGACY + dimmer.credentials_hash = CREDENTIALS_HASH_LEGACY return dimmer @@ -155,6 +227,8 @@ def _mocked_plug() -> SmartPlug: plug.turn_on = AsyncMock() plug.set_led = AsyncMock() plug.protocol = _mock_protocol() + plug.config = DEVICE_CONFIG_LEGACY + plug.credentials_hash = CREDENTIALS_HASH_LEGACY return plug @@ -176,6 +250,8 @@ def _mocked_strip() -> SmartStrip: strip.turn_on = AsyncMock() strip.set_led = AsyncMock() strip.protocol = _mock_protocol() + strip.config = DEVICE_CONFIG_LEGACY + strip.credentials_hash = CREDENTIALS_HASH_LEGACY plug0 = _mocked_plug() plug0.alias = "Plug0" plug0.device_id = "bb:bb:cc:dd:ee:ff_PLUG0DEVICEID" @@ -212,6 +288,15 @@ def _patch_single_discovery(device=None, no_device=False): ) +def _patch_connect(device=None, no_device=False): + async def _connect(*args, **kwargs): + if no_device: + raise SmartDeviceException + return device if device else _mocked_bulb() + + return patch("homeassistant.components.tplink.SmartDevice.connect", new=_connect) + + async def initialize_config_entry_for_device( hass: HomeAssistant, dev: SmartDevice ) -> MockConfigEntry: @@ -225,7 +310,9 @@ async def initialize_config_entry_for_device( ) config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_single_discovery( + device=dev + ), _patch_connect(device=dev): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 20ce09b9ec8..7e7e6961b91 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,18 +1,75 @@ """tplink conftest.""" +from collections.abc import Generator +import copy +from unittest.mock import DEFAULT, AsyncMock, patch + import pytest -from . import _patch_discovery +from homeassistant.components.tplink import DOMAIN +from homeassistant.core import HomeAssistant -from tests.common import mock_device_registry, mock_registry +from . import ( + CREATE_ENTRY_DATA_LEGACY, + CREDENTIALS_HASH_AUTH, + DEVICE_CONFIG_AUTH, + IP_ADDRESS, + IP_ADDRESS2, + MAC_ADDRESS, + MAC_ADDRESS2, + _mocked_bulb, +) + +from tests.common import MockConfigEntry, mock_device_registry, mock_registry @pytest.fixture def mock_discovery(): """Mock python-kasa discovery.""" - with _patch_discovery() as mock_discover: - mock_discover.return_value = {} - yield mock_discover + with patch.multiple( + "homeassistant.components.tplink.Discover", + discover=DEFAULT, + discover_single=DEFAULT, + ) as mock_discovery: + device = _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + devices = { + "127.0.0.1": _mocked_bulb( + device_config=copy.deepcopy(DEVICE_CONFIG_AUTH), + credentials_hash=CREDENTIALS_HASH_AUTH, + alias=None, + ) + } + mock_discovery["discover"].return_value = devices + mock_discovery["discover_single"].return_value = device + mock_discovery["mock_device"] = device + yield mock_discovery + + +@pytest.fixture +def mock_connect(): + """Mock python-kasa connect.""" + with patch("homeassistant.components.tplink.SmartDevice.connect") as mock_connect: + devices = { + IP_ADDRESS: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, credentials_hash=CREDENTIALS_HASH_AUTH + ), + IP_ADDRESS2: _mocked_bulb( + device_config=DEVICE_CONFIG_AUTH, + credentials_hash=CREDENTIALS_HASH_AUTH, + mac=MAC_ADDRESS2, + ), + } + + def get_device(config): + nonlocal devices + return devices[config.host] + + mock_connect.side_effect = get_device + yield {"connect": mock_connect, "mock_devices": devices} @pytest.fixture(name="device_reg") @@ -30,3 +87,55 @@ def entity_reg_fixture(hass): @pytest.fixture(autouse=True) def tplink_mock_get_source_ip(mock_get_source_ip): """Mock network util's async_get_source_ip.""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + ) as mock_setup_entry: + mock_setup_entry["async_setup"].return_value = True + mock_setup_entry["async_setup_entry"].return_value = True + yield mock_setup_entry + + +@pytest.fixture +def mock_init() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch.multiple( + "homeassistant.components.tplink", + async_setup=DEFAULT, + async_setup_entry=DEFAULT, + async_unload_entry=DEFAULT, + ) as mock_init: + mock_init["async_setup"].return_value = True + mock_init["async_setup_entry"].return_value = True + mock_init["async_unload_entry"].return_value = True + yield mock_init + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_LEGACY}, + unique_id=MAC_ADDRESS, + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 65be41a5655..96cfbead5e4 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1,21 +1,42 @@ """Test the tplink config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from kasa import TimeoutException import pytest from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.tplink import DOMAIN -from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.components.tplink import ( + DOMAIN, + AuthenticationException, + Credentials, + SmartDeviceException, +) +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG +from homeassistant.const import ( + CONF_ALIAS, + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( ALIAS, + CREATE_ENTRY_DATA_AUTH, + CREATE_ENTRY_DATA_AUTH2, + CREATE_ENTRY_DATA_LEGACY, DEFAULT_ENTRY_TITLE, + DEVICE_CONFIG_DICT_AUTH, + DEVICE_CONFIG_DICT_LEGACY, IP_ADDRESS, MAC_ADDRESS, + MAC_ADDRESS2, MODULE, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -25,7 +46,7 @@ from tests.common import MockConfigEntry async def test_discovery(hass: HomeAssistant) -> None: """Test setting up discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,7 +75,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -67,7 +88,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result3["type"] == "create_entry" assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == {CONF_HOST: IP_ADDRESS} + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -75,18 +96,244 @@ async def test_discovery(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "no_devices_found" +async def test_discovery_auth( + hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init +) -> None: + """Test authenticated discovery.""" + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "discovery_confirm" + assert not result2["errors"] + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], user_input={} + ) + + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_discovery_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test handling of discovery authentication errors.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "discovery_confirm" + + await hass.async_block_till_done() + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_confirm" + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +async def test_discovery_new_credentials_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test setting up discovery with new invalid credentials.""" + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + + mock_connect["connect"].side_effect = AuthenticationException + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "discovery_auth_confirm" + assert not result["errors"] + + assert mock_connect["connect"].call_count == 0 + + with patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ) + + assert mock_connect["connect"].call_count == 1 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_auth_confirm" + + await hass.async_block_till_done() + + mock_connect["connect"].side_effect = default_connect_side_effect + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "discovery_confirm" + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {}, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: """Test setting up discovery.""" config_entry = MockConfigEntry( @@ -94,22 +341,24 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No ) config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(no_device=True): + with _patch_discovery(), _patch_single_discovery(no_device=True), _patch_connect( + no_device=True + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] @@ -118,29 +367,27 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "pick_device" assert not result2["errors"] - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup_entry", return_value=True ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DEVICE: MAC_ADDRESS} ) - assert result3["type"] == "create_entry" + assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result3["data"] == CREATE_ENTRY_DATA_LEGACY await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -149,15 +396,15 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> No result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -167,11 +414,11 @@ async def test_discovery_no_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(): + with _patch_discovery(no_device=True), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "no_devices_found" @@ -180,46 +427,48 @@ async def test_manual(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] # Cannot connect (timeout) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "form" + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} # Success - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result4 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result4["type"] == "create_entry" + assert result4["type"] is FlowResultType.CREATE_ENTRY assert result4["title"] == DEFAULT_ENTRY_TITLE - assert result4["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result4["data"] == CREATE_ENTRY_DATA_LEGACY # Duplicate result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: IP_ADDRESS} ) await hass.async_block_till_done() - assert result2["type"] == "abort" + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -228,11 +477,13 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with _patch_discovery(no_device=True), _patch_single_discovery(), patch( + with _patch_discovery( + no_device=True + ), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ), patch(f"{MODULE}.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( @@ -240,26 +491,133 @@ async def test_manual_no_capabilities(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == CREATE_ENTRY_DATA_LEGACY + + +async def test_manual_auth( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == CREATE_ENTRY_DATA_AUTH + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_manual_auth_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, + error_type, + errors_msg, + error_placement, +) -> None: + """Test manually setup auth errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_discovery["mock_device"].update.side_effect = AuthenticationException + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + await hass.async_block_till_done() + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "user_auth_confirm" + assert result3["errors"] == {error_placement: errors_msg} + + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["data"] == CREATE_ENTRY_DATA_AUTH + + await hass.async_block_till_done() async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: """Test we get the form with discovery and abort for dhcp source when we get both.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -268,10 +626,10 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT + assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_in_progress" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -280,10 +638,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - assert result3["type"] == FlowResultType.ABORT + assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "already_in_progress" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -305,7 +665,12 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -314,16 +679,16 @@ async def test_discovered_by_dhcp_or_discovery( ) -> None: """Test we can setup when discovered from dhcp or discovery.""" - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with _patch_discovery(), _patch_single_discovery(), patch( + with _patch_discovery(), _patch_single_discovery(), _patch_connect(), patch( f"{MODULE}.async_setup", return_value=True ) as mock_async_setup, patch( f"{MODULE}.async_setup_entry", return_value=True @@ -331,10 +696,8 @@ async def test_discovered_by_dhcp_or_discovery( result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["data"] == { - CONF_HOST: IP_ADDRESS, - } + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == CREATE_ENTRY_DATA_LEGACY assert mock_async_setup.called assert mock_async_setup_entry.called @@ -348,7 +711,12 @@ async def test_discovered_by_dhcp_or_discovery( ), ( config_entries.SOURCE_INTEGRATION_DISCOVERY, - {CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS}, + { + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + }, ), ], ) @@ -357,10 +725,350 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ) -> None: """Test we abort if we cannot get the unique id when discovered from dhcp.""" - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state == config_entries.ConfigEntryState.LOADED + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + + +async def test_reauth_update_from_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + +async def test_reauth_update_from_discovery_with_ip_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + + +async def test_reauth_no_update_if_config_and_ip_the_same( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth discovery does not update when the host and config are the same.""" + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.data = { + **mock_config_entry.data, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS + + +@pytest.mark.parametrize( + ("error_type", "errors_msg", "error_placement"), + [ + (AuthenticationException, "invalid_auth", CONF_PASSWORD), + (SmartDeviceException, "cannot_connect", "base"), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + errors_msg, + error_placement, +) -> None: + """Test reauth errors.""" + mock_added_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + assert mock_added_config_entry.state is config_entries.ConfigEntryState.LOADED + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + [result] = flows + assert result["step_id"] == "reauth_confirm" + + mock_discovery["mock_device"].update.side_effect = error_type + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {error_placement: errors_msg} + + mock_discovery["discover_single"].reset_mock() + mock_discovery["mock_device"].update.reset_mock(side_effect=True) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error_type", "expected_flow"), + [ + (AuthenticationException, FlowResultType.FORM), + (SmartDeviceException, FlowResultType.ABORT), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_pick_device_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + expected_flow, +) -> None: + """Test errors on pick_device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + default_connect_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = error_type + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + assert result3["type"] == expected_flow + + if expected_flow != FlowResultType.ABORT: + mock_connect["connect"].side_effect = default_connect_side_effect + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + assert result4["type"] == FlowResultType.CREATE_ENTRY + + +async def test_discovery_timeout_connect( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + mock_init, +) -> None: + """Test discovery tries legacy connect on timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_discovery["discover_single"].side_effect = TimeoutException + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + assert mock_connect["connect"].call_count == 0 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert mock_connect["connect"].call_count == 1 + + +async def test_reauth_update_other_flows( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + # mock_init, +) -> None: + """Test reauth updates other reauth flows.""" + mock_config_entry2 = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH2}, + unique_id=MAC_ADDRESS2, + ) + default_side_effect = mock_connect["connect"].side_effect + mock_connect["connect"].side_effect = AuthenticationException() + mock_config_entry.add_to_hass(hass) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry2.state == config_entries.ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_ERROR + mock_connect["connect"].side_effect = default_side_effect + + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 2 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + credentials = Credentials("fake_username", "fake_password") + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=credentials + ) + mock_discovery["mock_device"].update.assert_called_once_with() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index c40560d2a89..e6297cf6553 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,25 +1,35 @@ """Tests for the TP-Link component.""" from __future__ import annotations +import copy from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant import setup from homeassistant.components import tplink -from homeassistant.components.tplink.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STARTED, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + CREATE_ENTRY_DATA_AUTH, + DEVICE_CONFIG_AUTH, IP_ADDRESS, MAC_ADDRESS, _mocked_dimmer, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -57,7 +67,7 @@ async def test_config_entry_reload(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.LOADED @@ -72,7 +82,9 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): + with _patch_discovery(no_device=True), _patch_single_discovery( + no_device=True + ), _patch_connect(no_device=True): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -102,7 +114,9 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( original_name="Rollout dimmer", ) - with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): + with _patch_discovery(device=dimmer), _patch_single_discovery( + device=dimmer + ), _patch_connect(device=dimmer): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -126,7 +140,7 @@ async def test_config_entry_wrong_mac_Address( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_mac ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): + with _patch_discovery(), _patch_single_discovery(), _patch_connect(): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY @@ -135,3 +149,110 @@ async def test_config_entry_wrong_mac_Address( "Unexpected device found at 127.0.0.1; expected aa:bb:cc:dd:ee:f0, found aa:bb:cc:dd:ee:ff" in caplog.text ) + + +async def test_config_entry_device_config( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded with DeviceConfig.""" + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_with_stored_credentials( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test that a config entry can be loaded when stored credentials are set.""" + stored_credentials = tplink.Credentials("fake_username1", "fake_password1") + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + auth = { + CONF_USERNAME: stored_credentials.username, + CONF_PASSWORD: stored_credentials.password, + } + + hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + config = DEVICE_CONFIG_AUTH + assert config.credentials != stored_credentials + config.credentials = stored_credentials + mock_connect["connect"].assert_called_once_with(config=config) + + +async def test_config_entry_device_config_invalid( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + caplog, +) -> None: + """Test that an invalid device config logs an error and loads the config entry.""" + entry_data = copy.deepcopy(CREATE_ENTRY_DATA_AUTH) + entry_data[CONF_DEVICE_CONFIG] = {"foo": "bar"} + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**entry_data}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert ( + f"Invalid connection type dict for {IP_ADDRESS}: {entry_data.get(CONF_DEVICE_CONFIG)}" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("error_type", "entry_state", "reauth_flows"), + [ + (tplink.AuthenticationException, ConfigEntryState.SETUP_ERROR, True), + (tplink.SmartDeviceException, ConfigEntryState.SETUP_RETRY, False), + ], + ids=["invalid-auth", "unknown-error"], +) +async def test_config_entry_errors( + hass: HomeAssistant, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, + error_type, + entry_state, + reauth_flows, +) -> None: + """Test that device exceptions are handled correctly during init.""" + mock_connect["connect"].side_effect = error_type + mock_config_entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data={**CREATE_ENTRY_DATA_AUTH}, + unique_id=MAC_ADDRESS, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is entry_state + assert ( + any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + == reauth_flows + ) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index ada454e0192..c541551a250 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -33,6 +33,7 @@ from . import ( MAC_ADDRESS, _mocked_bulb, _mocked_smart_light_strip, + _patch_connect, _patch_discovery, _patch_single_discovery, ) @@ -48,7 +49,7 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) bulb = _mocked_bulb() bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +70,7 @@ async def test_color_light( ) already_migrated_config_entry.add_to_hass(hass) bulb.color_temp = None - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -151,7 +152,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.is_variable_color_temp = False type(bulb).color_temp = PropertyMock(side_effect=Exception) - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -212,7 +213,7 @@ async def test_color_temp_light( bulb.color_temp = 4000 bulb.is_variable_color_temp = True - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -295,7 +296,7 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None: bulb.is_color = False bulb.is_variable_color_temp = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -340,7 +341,7 @@ async def test_on_off_light(hass: HomeAssistant) -> None: bulb.is_variable_color_temp = False bulb.is_dimmable = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -375,7 +376,7 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None: bulb.is_dimmable = False bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -397,7 +398,7 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: bulb.is_dimmer = True bulb.is_on = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -421,7 +422,9 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_single_discovery( + device=strip + ), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -501,7 +504,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -664,7 +667,7 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> "name": "Custom", "enable": 0, } - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -691,7 +694,7 @@ async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: already_migrated_config_entry.add_to_hass(hass) strip = _mocked_smart_light_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 5413e036d96..b67ed031df3 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -8,13 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import ( - MAC_ADDRESS, - _mocked_bulb, - _mocked_plug, - _patch_discovery, - _patch_single_discovery, -) +from . import MAC_ADDRESS, _mocked_bulb, _mocked_plug, _patch_connect, _patch_discovery from tests.common import MockConfigEntry @@ -35,7 +29,7 @@ async def test_color_light_with_an_emeter(hass: HomeAssistant) -> None: current=5, ) bulb.emeter_today = 5000.0036 - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -75,7 +69,7 @@ async def test_plug_with_an_emeter(hass: HomeAssistant) -> None: current=5.035, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -103,7 +97,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: bulb = _mocked_bulb() bulb.color_temp = None bulb.has_emeter = False - with _patch_discovery(device=bulb), _patch_single_discovery(device=bulb): + with _patch_discovery(device=bulb), _patch_connect(device=bulb): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -139,7 +133,7 @@ async def test_sensor_unique_id(hass: HomeAssistant) -> None: current=5, ) plug.emeter_today = None - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 05286e5ff48..372651ea250 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -20,8 +20,8 @@ from . import ( _mocked_dimmer, _mocked_plug, _mocked_strip, + _patch_connect, _patch_discovery, - _patch_single_discovery, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -34,7 +34,7 @@ async def test_plug(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: domain=DOMAIN, data={}, unique_id=MAC_ADDRESS ) already_migrated_config_entry.add_to_hass(hass) - with _patch_discovery(device=dev), _patch_single_discovery(device=dev): + with _patch_discovery(device=dev), _patch_connect(device=dev): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -100,7 +100,7 @@ async def test_plug_unique_id(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -116,7 +116,7 @@ async def test_plug_update_fails(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) plug = _mocked_plug() - with _patch_discovery(device=plug), _patch_single_discovery(device=plug): + with _patch_discovery(device=plug), _patch_connect(device=plug): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -138,7 +138,7 @@ async def test_strip(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() @@ -186,7 +186,7 @@ async def test_strip_unique_ids(hass: HomeAssistant) -> None: ) already_migrated_config_entry.add_to_hass(hass) strip = _mocked_strip() - with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + with _patch_discovery(device=strip), _patch_connect(device=strip): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() From b4ab1bac56ca37f747933f3761589d8d7f4538d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 21 Jan 2024 18:02:31 +0100 Subject: [PATCH 0871/1544] Fix numbered list in github config flow (#108587) --- homeassistant/components/github/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 7b7ae91b9fd..be753f7f785 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -9,7 +9,7 @@ } }, "progress": { - "wait_for_device": "1. Open {url} \n2.Paste the following key to authorize the integration: \n```\n{code}\n```\n" + "wait_for_device": "1. Open {url} \n2. Paste the following key to authorize the integration: \n```\n{code}\n```\n" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", From 6525dad57a575d131265226465fbd5c08433f9d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 08:17:55 -1000 Subject: [PATCH 0872/1544] Add bthome event platform (#108268) Co-authored-by: Ernst Klamer --- homeassistant/components/bthome/__init__.py | 55 +++++--- .../components/bthome/coordinator.py | 4 +- .../components/bthome/device_trigger.py | 14 +- homeassistant/components/bthome/event.py | 133 ++++++++++++++++++ homeassistant/components/bthome/strings.json | 28 ++++ tests/components/bthome/test_event.py | 116 +++++++++++++++ 6 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/bthome/event.py create mode 100644 tests/components/bthome/test_event.py diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 566609b998b..0031f09bb81 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate from bthome_ble.parser import EncryptionScheme @@ -19,6 +20,7 @@ from homeassistant.helpers.device_registry import ( DeviceRegistry, async_get, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( BTHOME_BLE_EVENT, @@ -30,7 +32,7 @@ from .const import ( ) from .coordinator import BTHomePassiveBluetoothProcessorCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -47,7 +49,7 @@ def process_service_info( coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - discovered_device_classes = coordinator.discovered_device_classes + discovered_event_classes = coordinator.discovered_event_classes if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: hass.config_entries.async_update_entry( entry, @@ -67,28 +69,35 @@ def process_service_info( sw_version=sensor_device_info.sw_version, hw_version=sensor_device_info.hw_version, ) + # event_class may be postfixed with a number, ie 'button_2' + # but if there is only one button then it will be 'button' event_class = event.device_key.key event_type = event.event_type - if event_class not in discovered_device_classes: - discovered_device_classes.add(event_class) + ble_event = BTHomeBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + + if event_class not in discovered_event_classes: + discovered_event_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_event_classes)}, + ) + async_dispatcher_send( + hass, format_discovered_event_class(address), event_class, ble_event ) - hass.bus.async_fire( - BTHOME_BLE_EVENT, - dict( - BTHomeBleEvent( - device_id=device.id, - address=address, - event_class=event_class, # ie 'button' - event_type=event_type, # ie 'press' - event_properties=event.event_properties, - ) - ), + hass.bus.async_fire(BTHOME_BLE_EVENT, cast(dict, ble_event)) + async_dispatcher_send( + hass, + format_event_dispatcher_name(address, event_class), + ble_event, ) # If payload is encrypted and the bindkey is not verified then we need to reauth @@ -98,6 +107,16 @@ def process_service_info( return update +def format_event_dispatcher_name(address: str, event_class: str) -> str: + """Format an event dispatcher name.""" + return f"{DOMAIN}_event_{address}_{event_class}" + + +def format_discovered_event_class(address: str) -> str: + """Format a discovered event class.""" + return f"{DOMAIN}_discovered_event_class_{address}" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BTHome Bluetooth from a config entry.""" address = entry.unique_id @@ -120,9 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, data, service_info, device_registry ), device_data=data, - discovered_device_classes=set( - entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) - ), + discovered_event_classes=set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])), connectable=False, entry=entry, ) diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index bb743be7c7f..837ad58b7c2 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -30,13 +30,13 @@ class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordi mode: BluetoothScanningMode, update_method: Callable[[BluetoothServiceInfoBleak], Any], device_data: BTHomeBluetoothDeviceData, - discovered_device_classes: set[str], + discovered_event_classes: set[str], entry: ConfigEntry, connectable: bool = False, ) -> None: """Initialize the BTHome Bluetooth Passive Update Processor Coordinator.""" super().__init__(hass, logger, address, mode, update_method, connectable) - self.discovered_device_classes = discovered_device_classes + self.discovered_event_classes = discovered_event_classes self.device_data = device_data self.entry = entry diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 6bcc1635aff..834b08ad39d 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -87,6 +87,9 @@ async def async_get_triggers( None, ) assert bthome_config_entry is not None + event_classes: list[str] = bthome_config_entry.data.get( + CONF_DISCOVERED_EVENT_CLASSES, [] + ) return [ { # Required fields of TRIGGER_BASE_SCHEMA @@ -97,10 +100,15 @@ async def async_get_triggers( CONF_TYPE: event_class, CONF_SUBTYPE: event_type, } - for event_class in bthome_config_entry.data.get( - CONF_DISCOVERED_EVENT_CLASSES, [] + for event_class in event_classes + for event_type in TRIGGERS_BY_EVENT_CLASS.get( + event_class.split("_")[0], + # If the device has multiple buttons they will have + # event classes like button_1 button_2, button_3, etc + # but if there is only one button then it will be + # button without a number postfix. + (), ) - for event_type in TRIGGERS_BY_EVENT_CLASS.get(event_class, []) ] diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py new file mode 100644 index 00000000000..39ad66d1d13 --- /dev/null +++ b/homeassistant/components/bthome/event.py @@ -0,0 +1,133 @@ +"""Support for bthome event entities.""" +from __future__ import annotations + +from dataclasses import replace + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import format_discovered_event_class, format_event_dispatcher_name +from .const import ( + DOMAIN, + EVENT_CLASS_BUTTON, + EVENT_CLASS_DIMMER, + EVENT_PROPERTIES, + EVENT_TYPE, + BTHomeBleEvent, +) +from .coordinator import BTHomePassiveBluetoothProcessorCoordinator + +DESCRIPTIONS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: EventEntityDescription( + key=EVENT_CLASS_BUTTON, + translation_key="button", + event_types=[ + "press", + "double_press", + "triple_press", + "long_press", + "long_double_press", + "long_triple_press", + ], + device_class=EventDeviceClass.BUTTON, + ), + EVENT_CLASS_DIMMER: EventEntityDescription( + key=EVENT_CLASS_DIMMER, + translation_key="dimmer", + event_types=["rotate_left", "rotate_right"], + ), +} + + +class BTHomeEventEntity(EventEntity): + """Representation of a BTHome event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + address: str, + event_class: str, + event: BTHomeBleEvent | None, + ) -> None: + """Initialise a BTHome event entity.""" + self._update_signal = format_event_dispatcher_name(address, event_class) + # event_class is something like "button" or "dimmer" + # and it maybe postfixed with "_1", "_2", "_3", etc + # If there is only one button then it will be "button" + base_event_class, _, postfix = event_class.partition("_") + base_description = DESCRIPTIONS_BY_EVENT_CLASS[base_event_class] + self.entity_description = replace(base_description, key=event_class) + postfix_name = f" {postfix}" if postfix else "" + self._attr_name = f"{base_event_class.title()}{postfix_name}" + # Matches logic in PassiveBluetoothProcessorEntity + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + self._attr_unique_id = f"{address}-{event_class}" + # If the event is provided then we can set the initial state + # since the event itself is likely what triggered the creation + # of this entity. We have to do this at creation time since + # entities are created dynamically and would otherwise miss + # the initial state. + if event: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._update_signal, + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: BTHomeBleEvent) -> None: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up BTHome event.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + address = coordinator.address + ent_reg = er.async_get(hass) + async_add_entities( + # Matches logic in PassiveBluetoothProcessorEntity + BTHomeEventEntity(address_event_class[0], address_event_class[2], None) + for ent_reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + if ent_reg_entry.domain == "event" + and (address_event_class := ent_reg_entry.unique_id.partition("-")) + ) + + @callback + def _async_discovered_event_class(event_class: str, event: BTHomeBleEvent) -> None: + """Handle a newly discovered event class with or without a postfix.""" + async_add_entities([BTHomeEventEntity(address, event_class, event)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + format_discovered_event_class(address), + _async_discovered_event_class, + ) + ) diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 39ba3baa3fd..50c5c7bada6 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -44,5 +44,33 @@ "button": "Button \"{subtype}\"", "dimmer": "Dimmer \"{subtype}\"" } + }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "double_press": "Double press", + "triple_press": "Triple press", + "long_press": "Long press", + "long_double_press": "Long double press", + "long_triple_press": "Long triple press" + } + } + } + }, + "dimmer": { + "state_attributes": { + "event_type": { + "state": { + "rotate_left": "Rotate left", + "rotate_right": "Rotate right" + } + } + } + } + } } } diff --git a/tests/components/bthome/test_event.py b/tests/components/bthome/test_event.py new file mode 100644 index 00000000000..f6cf3fd49c7 --- /dev/null +++ b/tests/components/bthome/test_event.py @@ -0,0 +1,116 @@ +"""Test the BTHome events.""" + +import pytest + +from homeassistant.components.bthome.const import DOMAIN +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import make_bthome_v2_adv + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + BluetoothServiceInfoBleak, + inject_bluetooth_service_info, +) + + +@pytest.mark.parametrize( + ("mac_address", "advertisement", "bind_key", "result"), + [ + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x3A\x00\x3A\x01\x3A\x03", + ), + None, + [ + { + "entity": "event.test_device_18b2_button_2", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button 2", + ATTR_EVENT_TYPE: "press", + }, + { + "entity": "event.test_device_18b2_button_3", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button 3", + ATTR_EVENT_TYPE: "triple_press", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x3A\x04", + ), + None, + [ + { + "entity": "event.test_device_18b2_button", + ATTR_FRIENDLY_NAME: "Test Device 18B2 Button", + ATTR_EVENT_TYPE: "long_press", + } + ], + ), + ], +) +async def test_v2_events( + hass: HomeAssistant, + mac_address: str, + advertisement: BluetoothServiceInfoBleak, + bind_key: str | None, + result: list[dict[str, str]], +) -> None: + """Test the different BTHome V2 events.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Ensure entities are restored + for meas in result: + state = hass.states.get(meas["entity"]) + assert state != STATE_UNAVAILABLE + + # Now inject again + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 702529627e330940903a2b688512ebe049712dfc Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 21 Jan 2024 20:56:16 +0100 Subject: [PATCH 0873/1544] Add missing property in flexit bacnet test (#108606) Add missing property on mocked device Also update the snapshot --- tests/components/flexit_bacnet/conftest.py | 1 + .../components/flexit_bacnet/snapshots/test_binary_sensor.ambr | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index 6fc715da28e..f0117b41536 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -59,6 +59,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: flexit_bacnet.air_filter_operating_time = 8820.0 flexit_bacnet.heat_exchanger_efficiency = 81 flexit_bacnet.heat_exchanger_speed = 100 + flexit_bacnet.air_filter_polluted = False yield flexit_bacnet diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr index a6f4137d03e..bf53de3569c 100644 --- a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -40,6 +40,6 @@ 'entity_id': 'binary_sensor.device_name_air_filter_polluted', 'last_changed': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- From 4d5a5110011ae1a51509f091e3b2c3ef54a5bdda Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 21 Jan 2024 21:23:11 +0100 Subject: [PATCH 0874/1544] Add icon translations to co2signal (#108611) --- homeassistant/components/co2signal/icons.json | 12 ++++++++++++ homeassistant/components/co2signal/sensor.py | 1 - .../components/co2signal/snapshots/test_sensor.ambr | 6 ++---- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/co2signal/icons.json diff --git a/homeassistant/components/co2signal/icons.json b/homeassistant/components/co2signal/icons.json new file mode 100644 index 00000000000..e934fc49e41 --- /dev/null +++ b/homeassistant/components/co2signal/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "carbon_intensity": { + "default": "mdi:molecule-co2" + }, + "fossil_fuel_percentage": { + "default": "mdi:molecule-co2" + } + } + } +} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 9f955e35ed8..bff17becede 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -67,7 +67,6 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): entity_description: CO2SensorEntityDescription _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - _attr_icon = "mdi:molecule-co2" _attr_state_class = SensorStateClass.MEASUREMENT def __init__( diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr index eb4364ed0d6..d671640e316 100644 --- a/tests/components/co2signal/snapshots/test_sensor.ambr +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -22,7 +22,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:molecule-co2', + 'original_icon': None, 'original_name': 'CO2 intensity', 'platform': 'co2signal', 'previous_unique_id': None, @@ -38,7 +38,6 @@ 'attribution': 'Data provided by Electricity Maps', 'country_code': 'FR', 'friendly_name': 'Electricity Maps CO2 intensity', - 'icon': 'mdi:molecule-co2', 'state_class': , 'unit_of_measurement': 'gCO2eq/kWh', }), @@ -72,7 +71,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:molecule-co2', + 'original_icon': None, 'original_name': 'Grid fossil fuel percentage', 'platform': 'co2signal', 'previous_unique_id': None, @@ -88,7 +87,6 @@ 'attribution': 'Data provided by Electricity Maps', 'country_code': 'FR', 'friendly_name': 'Electricity Maps Grid fossil fuel percentage', - 'icon': 'mdi:molecule-co2', 'state_class': , 'unit_of_measurement': '%', }), From e94493f83d468f7b69d19194a089ba0d95aca8ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 10:34:06 -1000 Subject: [PATCH 0875/1544] Use more shorthand attributes in tplink (#108284) * Use more shorthand attributes in tplink * naming * unused --- homeassistant/components/tplink/entity.py | 5 -- homeassistant/components/tplink/light.py | 67 ++++++++++++----------- homeassistant/components/tplink/switch.py | 47 ++++++++++------ 3 files changed, 67 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 84781597b93..577e1995d4a 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -50,8 +50,3 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): sw_version=device.hw_info["sw_ver"], hw_version=device.hw_info["hw_ver"], ) - - @property - def is_on(self) -> bool: - """Return true if switch is on.""" - return bool(self.device.is_on) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c57fc9bfd85..70a078928d9 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -194,6 +194,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): if not modes: modes.add(ColorMode.ONOFF) self._attr_supported_color_modes = modes + self._async_update_attrs() @callback def _async_extract_brightness_transition( @@ -271,24 +272,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): transition = int(transition * 1_000) await self.device.turn_off(transition=transition) - @property - def color_temp_kelvin(self) -> int: - """Return the color temperature of this light.""" - return cast(int, self.device.color_temp) - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return round((cast(int, self.device.brightness) * 255.0) / 100.0) - - @property - def hs_color(self) -> tuple[int, int] | None: - """Return the color.""" - hue, saturation, _ = self.device.hsv - return hue, saturation - - @property - def color_mode(self) -> ColorMode: + def _determine_color_mode(self) -> ColorMode: """Return the active color mode.""" if self.device.is_color: if self.device.is_variable_color_temp and self.device.color_temp: @@ -299,6 +283,27 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return ColorMode.BRIGHTNESS + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + device = self.device + self._attr_is_on = device.is_on + if device.is_dimmable: + self._attr_brightness = round((device.brightness * 255.0) / 100.0) + color_mode = self._determine_color_mode() + self._attr_color_mode = color_mode + if color_mode is ColorMode.COLOR_TEMP: + self._attr_color_temp_kelvin = device.color_temp + elif color_mode is ColorMode.HS: + hue, saturation, _ = device.hsv + self._attr_hs_color = hue, saturation + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + class TPLinkSmartLightStrip(TPLinkSmartBulb): """Representation of a TPLink Smart Light Strip.""" @@ -306,19 +311,19 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): device: SmartLightStrip _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - @property - def effect_list(self) -> list[str] | None: - """Return the list of available effects.""" - if effect_list := self.device.effect_list: - return cast(list[str], effect_list) - return None - - @property - def effect(self) -> str | None: - """Return the current effect.""" - if (effect := self.device.effect) and effect["enable"]: - return cast(str, effect["name"]) - return None + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + super()._async_update_attrs() + device = self.device + if (effect := device.effect) and effect["enable"]: + self._attr_effect = effect["name"] + else: + self._attr_effect = None + if effect_list := device.effect_list: + self._attr_effect_list = effect_list + else: + self._attr_effect_list = None @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 9a54a952666..3e81870d80f 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -9,7 +9,7 @@ from kasa import SmartDevice, SmartPlug from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import legacy_device_id @@ -61,13 +61,8 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): ) -> None: """Initialize the LED switch.""" super().__init__(device, coordinator) - self._attr_unique_id = f"{self.device.mac}_led" - - @property - def icon(self) -> str: - """Return the icon for the LED.""" - return "mdi:led-on" if self.is_on else "mdi:led-off" + self._async_update_attrs() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -79,10 +74,18 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Turn the LED switch off.""" await self.device.set_led(False) - @property - def is_on(self) -> bool: - """Return true if LED switch is on.""" - return bool(self.device.led) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + is_on = self.device.led + self._attr_is_on = is_on + self._attr_icon = "mdi:led-on" if is_on else "mdi:led-off" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): @@ -99,6 +102,7 @@ class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): super().__init__(device, coordinator) # For backwards compat with pyHS100 self._attr_unique_id = legacy_device_id(device) + self._async_update_attrs() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -110,6 +114,17 @@ class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Turn the switch off.""" await self.device.turn_off() + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self.device.is_on + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + class SmartPlugSwitchChild(SmartPlugSwitch): """Representation of an individual plug of a TPLink Smart Plug strip.""" @@ -121,8 +136,8 @@ class SmartPlugSwitchChild(SmartPlugSwitch): plug: SmartDevice, ) -> None: """Initialize the child switch.""" - super().__init__(device, coordinator) self._plug = plug + super().__init__(device, coordinator) self._attr_unique_id = legacy_device_id(plug) self._attr_name = plug.alias @@ -136,7 +151,7 @@ class SmartPlugSwitchChild(SmartPlugSwitch): """Turn the child switch off.""" await self._plug.turn_off() - @property - def is_on(self) -> bool: - """Return true if child switch is on.""" - return bool(self._plug.is_on) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self._plug.is_on From 883711fb35948d83962e5a679b422caf9a58b7ee Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 21 Jan 2024 21:34:44 +0100 Subject: [PATCH 0876/1544] Add icon translations to Withings (#108385) * Add icon translations to Withings * Add icon translations to Withings * Add icon translations to Withings * Add icon translations to Withings * Add icon translations to Withings --- .../components/withings/binary_sensor.py | 1 - homeassistant/components/withings/icons.json | 124 ++++++++++++++++++ homeassistant/components/withings/sensor.py | 23 ---- .../withings/snapshots/test_sensor.ambr | 23 ---- 4 files changed, 124 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/withings/icons.json diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 1317befcf3f..12583ba4758 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -47,7 +47,6 @@ async def async_setup_entry( class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" - _attr_icon = "mdi:bed" _attr_translation_key = "in_bed" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY coordinator: WithingsBedPresenceDataUpdateCoordinator diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json new file mode 100644 index 00000000000..f76761ce953 --- /dev/null +++ b/homeassistant/components/withings/icons.json @@ -0,0 +1,124 @@ +{ + "entity": { + "binary_sensor": { + "in_bed": { + "default": "mdi:bed-outline", + "state": { + "on": "mdi:bed", + "off": "mdi:bed-empty" + } + } + }, + "sensor": { + "bone_mass": { + "default": "mdi:bone" + }, + "heart_pulse": { + "default": "mdi:heart-pulse" + }, + "hydration": { + "default": "mdi:water" + }, + "deep_sleep": { + "default": "mdi:sleep" + }, + "time_to_sleep": { + "default": "mdi:sleep" + }, + "time_to_wakeup": { + "default": "mdi:sleep-off" + }, + "average_heart_rate": { + "default": "mdi:heart-pulse" + }, + "maximum_heart_rate": { + "default": "mdi:heart-pulse" + }, + "minimum_heart_rate": { + "default": "mdi:heart-pulse" + }, + "light_sleep": { + "default": "mdi:sleep" + }, + "rem_sleep": { + "default": "mdi:sleep" + }, + "sleep_score": { + "default": "mdi:medal" + }, + "wakeup_count": { + "default": "mdi:sleep-off" + }, + "wakeup_time": { + "default": "mdi:sleep-off" + }, + "activity_steps_today": { + "default": "mdi:shoe-print" + }, + "activity_distance_today": { + "default": "mdi:map-marker-distance" + }, + "activity_elevation_today": { + "default": "mdi:stairs-up" + }, + "step_goal": { + "default": "mdi:shoe-print" + }, + "sleep_goal": { + "default": "mdi:bed-clock" + }, + "workout_distance": { + "default": "mdi:map-marker-distance" + }, + "workout_type": { + "state": { + "walk": "mdi:walk", + "run": "mdi:run", + "hiking": "mdi:hiking", + "skating": "mdi:skateboarding", + "bicycling": "mdi:bike", + "swimming": "mdi:swim", + "surfing": "mdi:surfing", + "kitesurfing": "mdi:kitesurfing", + "windsurfing": "mdi:kitesurfing", + "tennis": "mdi:tennis", + "table_tennis": "mdi:table-tennis", + "squash": "mdi:racquetball", + "badminton": "mdi:badminton", + "lift_weights": "mdi:weight-lifter", + "basket_ball": "mdi:basketball", + "soccer": "mdi:soccer", + "football": "mdi:football", + "rugby": "mdi:rugby", + "volley_ball": "mdi:volleyball", + "waterpolo": "mdi:water-polo", + "horse_riding": "mdi:horse-human", + "golf": "mdi:golf", + "yoga": "mdi:yoga", + "dancing": "mdi:human-female-dance", + "boxing": "mdi:boxing-glove", + "fencing": "mdi:fencing", + "martial_arts": "mdi:karate", + "skiing": "mdi:ski", + "snowboarding": "mdi:snowboard", + "rowing": "mdi:rowing", + "baseball": "mdi:baseball", + "handball": "mdi:handball", + "hockey": "mdi:hockey-sticks", + "ice_hockey": "mdi:hockey-sticks", + "climbing": "mdi:carabiner", + "ice_skating": "mdi:skate" + } + }, + "workout_elevation": { + "default": "mdi:stairs-up" + }, + "workout_pause_duration": { + "default": "mdi:timer-pause" + }, + "workout_duration": { + "default": "mdi:timer" + } + } + } +} diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index de053d6a894..d882cd8cddd 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -107,7 +107,6 @@ MEASUREMENT_SENSORS: dict[ key="bone_mass_kg", measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", - icon="mdi:bone", native_unit_of_measurement=UnitOfMass.KILOGRAMS, suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, @@ -173,7 +172,6 @@ MEASUREMENT_SENSORS: dict[ measurement_type=MeasurementType.HEART_RATE, translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, ), MeasurementType.SP02: WithingsMeasurementSensorEntityDescription( @@ -189,7 +187,6 @@ MEASUREMENT_SENSORS: dict[ translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, - icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -283,7 +280,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), @@ -292,7 +288,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -302,7 +297,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -312,7 +306,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.average_heart_rate, translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -321,7 +314,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.max_heart_rate, translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -330,7 +322,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.min_heart_rate, translation_key="minimum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, - icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -339,7 +330,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -349,7 +339,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -383,7 +372,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_score, translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, - icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -406,7 +394,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_count, translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, - icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -415,7 +402,6 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -435,7 +421,6 @@ ACTIVITY_SENSORS = [ key="activity_steps_today", value_fn=lambda activity: activity.steps, translation_key="activity_steps_today", - icon="mdi:shoe-print", native_unit_of_measurement="steps", state_class=SensorStateClass.TOTAL, ), @@ -444,7 +429,6 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.distance, translation_key="activity_distance_today", suggested_display_precision=0, - icon="mdi:map-marker-distance", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, @@ -453,7 +437,6 @@ ACTIVITY_SENSORS = [ key="activity_floors_climbed_today", value_fn=lambda activity: activity.elevation, translation_key="activity_elevation_today", - icon="mdi:stairs-up", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL, @@ -532,7 +515,6 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { STEP_GOAL: WithingsGoalsSensorEntityDescription( key="step_goal", value_fn=lambda goals: goals.steps, - icon="mdi:shoe-print", translation_key="step_goal", native_unit_of_measurement="steps", state_class=SensorStateClass.MEASUREMENT, @@ -540,7 +522,6 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { SLEEP_GOAL: WithingsGoalsSensorEntityDescription( key="sleep_goal", value_fn=lambda goals: goals.sleep, - icon="mdi:bed-clock", translation_key="sleep_goal", native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, @@ -592,13 +573,11 @@ WORKOUT_SENSORS = [ device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, suggested_display_precision=0, - icon="mdi:map-marker-distance", ), WithingsWorkoutSensorEntityDescription( key="workout_floors_climbed", value_fn=lambda workout: workout.elevation, translation_key="workout_elevation", - icon="mdi:stairs-up", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, ), @@ -611,7 +590,6 @@ WORKOUT_SENSORS = [ key="workout_pause_duration", value_fn=lambda workout: workout.pause_duration or 0, translation_key="workout_pause_duration", - icon="mdi:timer-pause", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, @@ -622,7 +600,6 @@ WORKOUT_SENSORS = [ workout.end_date - workout.start_date ).total_seconds(), translation_key="workout_duration", - icon="mdi:timer", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 4ca4093e3b8..08d2786fae9 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -34,7 +34,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -79,7 +78,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'weight', 'friendly_name': 'henk Bone mass', - 'icon': 'mdi:bone', 'state_class': , 'unit_of_measurement': , }), @@ -121,7 +119,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Deep sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -151,7 +148,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Distance travelled last workout', - 'icon': 'mdi:map-marker-distance', 'unit_of_measurement': , }), 'context': , @@ -166,7 +162,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Distance travelled today', - 'icon': 'mdi:map-marker-distance', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , 'unit_of_measurement': , @@ -183,7 +178,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Elevation change last workout', - 'icon': 'mdi:stairs-up', 'unit_of_measurement': , }), 'context': , @@ -198,7 +192,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'distance', 'friendly_name': 'henk Elevation change today', - 'icon': 'mdi:stairs-up', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , 'unit_of_measurement': , @@ -273,7 +266,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Heart pulse', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -304,7 +296,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'weight', 'friendly_name': 'henk Hydration', - 'icon': 'mdi:water', 'state_class': , 'unit_of_measurement': , }), @@ -351,7 +342,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Last workout duration', - 'icon': 'mdi:timer', 'unit_of_measurement': , }), 'context': , @@ -442,7 +432,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Light sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -457,7 +446,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -486,7 +474,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum heart rate', - 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), @@ -547,7 +534,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Pause during last workout', - 'icon': 'mdi:timer-pause', 'unit_of_measurement': , }), 'context': , @@ -577,7 +563,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk REM sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -608,7 +593,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Sleep goal', - 'icon': 'mdi:bed-clock', 'state_class': , 'unit_of_measurement': , }), @@ -623,7 +607,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Sleep score', - 'icon': 'mdi:medal', 'state_class': , 'unit_of_measurement': 'points', }), @@ -694,7 +677,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Step goal', - 'icon': 'mdi:shoe-print', 'state_class': , 'unit_of_measurement': 'steps', }), @@ -709,7 +691,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Steps today', - 'icon': 'mdi:shoe-print', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , 'unit_of_measurement': 'steps', @@ -755,7 +736,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Time to sleep', - 'icon': 'mdi:sleep', 'state_class': , 'unit_of_measurement': , }), @@ -771,7 +751,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Time to wakeup', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': , }), @@ -827,7 +806,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Wakeup count', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': 'times', }), @@ -843,7 +821,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Wakeup time', - 'icon': 'mdi:sleep-off', 'state_class': , 'unit_of_measurement': , }), From fbe1f238d4452540fdb20b945e8c6a457d1ccb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Sun, 21 Jan 2024 22:20:07 +0100 Subject: [PATCH 0877/1544] Bump airthings-ble to 0.6.0 (#108612) --- homeassistant/components/airthings_ble/__init__.py | 3 ++- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index 1d62442f14d..3a97813741b 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -40,10 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Airthings device with address {address}" ) + airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) + async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" ble_device = bluetooth.async_ble_device_from_address(hass, address) - airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) try: data = await airthings.update_device(ble_device) # type: ignore[arg-type] diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index cb7114ff8ff..03b42410d66 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.5.6-2"] + "requirements": ["airthings-ble==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd43f8a1339..e5186226dff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -407,7 +407,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.5.6-2 +airthings-ble==0.6.0 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2618222da8..e46d1241e86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -380,7 +380,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.5.6-2 +airthings-ble==0.6.0 # homeassistant.components.airthings airthings-cloud==0.2.0 From 0566ceca0f15bc68e5b441625616e928e113441f Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 21 Jan 2024 21:22:04 +0000 Subject: [PATCH 0878/1544] Tweak evohome code quality (#107596) * initial commit * lint * initial commit --- homeassistant/components/evohome/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index fafa89f4575..ddad635ddcf 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -160,6 +160,7 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: def _handle_exception(err: evo.RequestFailed) -> None: """Return False if the exception can't be ignored.""" + try: raise err @@ -471,12 +472,13 @@ class EvoBroker: async def call_client_api( self, - api_function: Awaitable[dict[str, Any] | None], + client_api: Awaitable[dict[str, Any] | None], update_state: bool = True, ) -> dict[str, Any] | None: """Call a client API and update the broker state if required.""" + try: - result = await api_function + result = await client_api except evo.RequestFailed as err: _handle_exception(err) return None @@ -556,7 +558,6 @@ class EvoBroker: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status) finally: if access_token != self.client.access_token: @@ -657,9 +658,9 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if self._evo_broker.temps.get(self._evo_id) is not None: + if (temp := self._evo_broker.temps.get(self._evo_id)) is not None: # use high-precision temps if available - return self._evo_broker.temps[self._evo_id] + return temp return self._evo_device.temperature @property From e90b42d3d03639d5c1ca66d4d0607bccdbe0171f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 21 Jan 2024 22:40:48 +0100 Subject: [PATCH 0879/1544] Fix FlowHandler show progress (#108586) --- homeassistant/data_entry_flow.py | 7 +++++++ tests/test_data_entry_flow.py | 28 ++++++++++++++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 40d0a4de763..36c68008a8e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -316,6 +316,9 @@ class FlowManager(abc.ABC): result: FlowResult | None = None while not result or result["type"] == FlowResultType.SHOW_PROGRESS_DONE: result = await self._async_configure(flow_id, user_input) + flow = self._progress.get(flow_id) + if flow and flow.deprecated_show_progress: + break return result async def _async_configure( @@ -540,6 +543,7 @@ class FlowHandler: __progress_task: asyncio.Task[Any] | None = None __no_progress_task_reported = False + deprecated_show_progress = False @property def source(self) -> str | None: @@ -710,6 +714,9 @@ class FlowHandler: report_issue, ) + if progress_task is None: + self.deprecated_show_progress = True + flow_result = FlowResult( type=FlowResultType.SHOW_PROGRESS, flow_id=self.flow_id, diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 78833ac7517..d39c8faccef 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -511,29 +511,31 @@ async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) """Test show progress done is not sent to frontend.""" manager.hass = hass async_show_progress_done_called = False + progress_task: asyncio.Task[None] | None = None @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 data = None - progress_task: asyncio.Task[None] | None = None async def async_step_init(self, user_input=None): + nonlocal progress_task + async def long_running_job() -> None: return - if not self.progress_task: - self.progress_task = hass.async_create_task(long_running_job()) - if self.progress_task.done(): + if not progress_task: + progress_task = hass.async_create_task(long_running_job()) + if progress_task.done(): nonlocal async_show_progress_done_called async_show_progress_done_called = True return self.async_show_progress_done(next_step_id="finish") return self.async_show_progress( step_id="init", progress_action="task", - # Set to None to simulate flow manager has not yet called when - # frontend loads - progress_task=None, + # Set to a task which never finishes to simulate flow manager has not + # yet called when frontend loads + progress_task=hass.async_create_task(asyncio.Event().wait()), ) async def async_step_finish(self, user_input=None): @@ -546,7 +548,7 @@ async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) assert len(manager.async_progress_by_handler("test")) == 1 assert manager.async_get(result["flow_id"])["handler"] == "test" - await hass.async_block_till_done() + await progress_task assert not async_show_progress_done_called # Frontend refreshes the flow @@ -628,8 +630,14 @@ async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> Non result = await manager.async_configure( result["flow_id"], {"task_finished": 2, "title": "Hello"} ) - # Note: The SHOW_PROGRESS_DONE is hidden from frontend; FlowManager automatically - # calls the flow again + # Note: The SHOW_PROGRESS_DONE is not hidden from frontend when flows manage + # the progress tasks themselves + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE + + # Frontend refreshes the flow + result = await manager.async_configure( + result["flow_id"], {"task_finished": 2, "title": "Hello"} + ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Hello" From 71e636572fa85d37e384745049eee313c4189d07 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Sun, 21 Jan 2024 15:09:08 -0800 Subject: [PATCH 0880/1544] Send recurrence data when updating a task in todoist (#108269) * Send recurrence data when updating a task in todoist * Update tests/components/todoist/test_todo.py Co-authored-by: Allen Porter * Move logic into _task_api_data. * Add comment about sending potentinally stale data. --------- Co-authored-by: Allen Porter --- homeassistant/components/todoist/todo.py | 13 +++++-- tests/components/todoist/test_todo.py | 46 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 5067e98642e..490e4ad9f1a 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -4,6 +4,8 @@ import asyncio import datetime from typing import Any, cast +from todoist_api_python.models import Task + from homeassistant.components.todo import ( TodoItem, TodoItemStatus, @@ -32,7 +34,7 @@ async def async_setup_entry( ) -def _task_api_data(item: TodoItem) -> dict[str, Any]: +def _task_api_data(item: TodoItem, api_data: Task | None = None) -> dict[str, Any]: """Convert a TodoItem to the set of add or update arguments.""" item_data: dict[str, Any] = { "content": item.summary, @@ -44,6 +46,12 @@ def _task_api_data(item: TodoItem) -> dict[str, Any]: item_data["due_datetime"] = due.isoformat() else: item_data["due_date"] = due.isoformat() + # In order to not lose any recurrence metadata for the task, we need to + # ensure that we send the `due_string` param if the task has it set. + # NOTE: It's ok to send stale data for non-recurring tasks. Any provided + # date/datetime will override this string. + if api_data and api_data.due: + item_data["due_string"] = api_data.due.string else: # Special flag "no date" clears the due date/datetime. # See https://developer.todoist.com/rest/v2/#update-a-task for more. @@ -126,7 +134,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if update_data := _task_api_data(item): + api_data = next((d for d in self.coordinator.data if d.id == uid), None) + if update_data := _task_api_data(item, api_data): await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: # Only update status if changed diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 5aa1e2af9de..a227ec858e4 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -402,8 +402,52 @@ async def test_update_todo_item_status( "status": "needs_action", }, ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + # Create a mock task with a string value in the Due object and verify it + # gets preserved when verifying the kwargs to update below + due=Due(date="2024-01-01", is_recurring=True, string="every day"), + ) + ], + {"due_date": "2024-02-01"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + due=Due(date="2024-02-01", is_recurring=True, string="every day"), + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "6-pack", + "due_date": "2024-02-01", + "due_string": "every day", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + "due": "2024-02-01", + }, + ), + ], + ids=[ + "rename", + "due_date", + "due_datetime", + "description", + "clear_description", + "due_date_with_recurrence", ], - ids=["rename", "due_date", "due_datetime", "description", "clear_description"], ) async def test_update_todo_items( hass: HomeAssistant, From da1d530889aa2159facfad9cbd7472351ff65079 Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Mon, 22 Jan 2024 13:05:10 +1300 Subject: [PATCH 0881/1544] Update August diagnostics.py to redact contentToken (#108626) --- homeassistant/components/august/diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/august/diagnostics.py b/homeassistant/components/august/diagnostics.py index 6c19d57a0c3..57e56795c2d 100644 --- a/homeassistant/components/august/diagnostics.py +++ b/homeassistant/components/august/diagnostics.py @@ -24,6 +24,7 @@ TO_REDACT = { "remoteOperateSecret", "users", "zWaveDSK", + "contentToken", } From dbb5645e63b4cebe591102657b2b4efcfd7567a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:33:31 -1000 Subject: [PATCH 0882/1544] Significantly reduce websocket api connection auth phase latency (#108564) * Significantly reduce websocket api connection auth phase latancy Since the auth phase has exclusive control over the websocket until ActiveConnection is created, we can bypass the queue and send messages right away. This reduces the latancy and reconnect time since we do not have to wait for the background processing of the queue to send the auth ok message. * only start the writer queue after auth is successful --- .../components/websocket_api/auth.py | 42 +++++++++--------- .../components/websocket_api/http.py | 43 +++++++++++-------- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 0a681692c3d..176b561f583 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -1,14 +1,13 @@ """Handle the auth of a connection.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Final from aiohttp.web import Request import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.auth.models import RefreshToken, User from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.const import __version__ from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -41,9 +40,9 @@ AUTH_REQUIRED_MESSAGE = json_bytes( ) -def auth_invalid_message(message: str) -> dict[str, str]: +def auth_invalid_message(message: str) -> bytes: """Return an auth_invalid message.""" - return {"type": TYPE_AUTH_INVALID, "message": message} + return json_bytes({"type": TYPE_AUTH_INVALID, "message": message}) class AuthPhase: @@ -56,13 +55,17 @@ class AuthPhase: send_message: Callable[[bytes | str | dict[str, Any]], None], cancel_ws: CALLBACK_TYPE, request: Request, + send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]], ) -> None: - """Initialize the authentiated connection.""" + """Initialize the authenticated connection.""" self._hass = hass + # send_message will send a message to the client via the queue. self._send_message = send_message self._cancel_ws = cancel_ws self._logger = logger self._request = request + # send_bytes_text will directly send a message to the client. + self._send_bytes_text = send_bytes_text async def async_handle(self, msg: JsonValueType) -> ActiveConnection: """Handle authentication.""" @@ -73,7 +76,7 @@ class AuthPhase: f"Auth message incorrectly formatted: {humanize_error(msg, err)}" ) self._logger.warning(error_msg) - self._send_message(auth_invalid_message(error_msg)) + await self._send_bytes_text(auth_invalid_message(error_msg)) raise Disconnect from err if (access_token := valid_msg.get("access_token")) and ( @@ -81,26 +84,25 @@ class AuthPhase: access_token ) ): - conn = await self._async_finish_auth(refresh_token.user, refresh_token) + conn = ActiveConnection( + self._logger, + self._hass, + self._send_message, + refresh_token.user, + refresh_token, + ) conn.subscriptions[ "auth" ] = self._hass.auth.async_register_revoke_token_callback( refresh_token.id, self._cancel_ws ) - + await self._send_bytes_text(AUTH_OK_MESSAGE) + self._logger.debug("Auth OK") + process_success_login(self._request) return conn - self._send_message(auth_invalid_message("Invalid access token or password")) + await self._send_bytes_text( + auth_invalid_message("Invalid access token or password") + ) await process_wrong_login(self._request) raise Disconnect - - async def _async_finish_auth( - self, user: User, refresh_token: RefreshToken - ) -> ActiveConnection: - """Create an active connection.""" - self._logger.debug("Auth OK") - process_success_login(self._request) - self._send_message(AUTH_OK_MESSAGE) - return ActiveConnection( - self._logger, self._hass, self._send_message, user, refresh_token - ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index d966e4e26ef..416573d493c 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Callable +from collections.abc import Callable, Coroutine import datetime as dt from functools import partial import logging @@ -116,16 +116,14 @@ class WebSocketHandler: return describe_request(request) return "finished connection" - async def _writer(self) -> None: + async def _writer( + self, send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]] + ) -> None: """Write outgoing messages.""" # Variables are set locally to avoid lookups in the loop message_queue = self._message_queue logger = self._logger wsock = self._wsock - writer = wsock._writer # pylint: disable=protected-access - if TYPE_CHECKING: - assert writer is not None - send_str = partial(writer.send, binary=False) loop = self._hass.loop debug = logger.debug is_enabled_for = logger.isEnabledFor @@ -152,7 +150,7 @@ class WebSocketHandler: ): if debug_enabled: debug("%s: Sending %s", self.description, message) - await send_str(message) + await send_bytes_text(message) continue messages: list[bytes] = [message] @@ -166,7 +164,7 @@ class WebSocketHandler: coalesced_messages = b"".join((b"[", b",".join(messages), b"]")) if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) - await send_str(coalesced_messages) + await send_bytes_text(coalesced_messages) except asyncio.CancelledError: debug("%s: Writer cancelled", self.description) raise @@ -186,7 +184,7 @@ class WebSocketHandler: @callback def _send_message(self, message: str | bytes | dict[str, Any]) -> None: - """Send a message to the client. + """Queue sending a message to the client. Closes connection if the client is not reading the messages. @@ -295,21 +293,23 @@ class WebSocketHandler: EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) - # As the webserver is now started before the start - # event we do not want to block for websocket responses - self._writer_task = asyncio.create_task(self._writer()) + writer = wsock._writer # pylint: disable=protected-access + if TYPE_CHECKING: + assert writer is not None - auth = AuthPhase(logger, hass, self._send_message, self._cancel, request) + send_bytes_text = partial(writer.send, binary=False) + auth = AuthPhase( + logger, hass, self._send_message, self._cancel, request, send_bytes_text + ) connection = None disconnect_warn = None try: - self._send_message(AUTH_REQUIRED_MESSAGE) + await send_bytes_text(AUTH_REQUIRED_MESSAGE) # Auth Phase try: - async with asyncio.timeout(10): - msg = await wsock.receive() + msg = await wsock.receive(10) except asyncio.TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" raise Disconnect from err @@ -330,7 +330,13 @@ class WebSocketHandler: if is_enabled_for(logging_debug): debug("%s: Received %s", self.description, auth_msg_data) connection = await auth.async_handle(auth_msg_data) + # As the webserver is now started before the start + # event we do not want to block for websocket responses + # + # We only start the writer queue after the auth phase is completed + # since there is no need to queue messages before the auth phase self._connection = connection + self._writer_task = asyncio.create_task(self._writer(send_bytes_text)) hass.data[DATA_CONNECTIONS] = hass.data.get(DATA_CONNECTIONS, 0) + 1 async_dispatcher_send(hass, SIGNAL_WEBSOCKET_CONNECTED) @@ -370,7 +376,7 @@ class WebSocketHandler: # added a way to set the limit, but there is no way to actually # reach the code to set the limit, so we have to set it directly. # - wsock._writer._limit = 2**20 # type: ignore[union-attr] # pylint: disable=protected-access + writer._limit = 2**20 # pylint: disable=protected-access async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary @@ -441,7 +447,8 @@ class WebSocketHandler: # so we have another finally block to make sure we close the websocket # if the writer gets canceled. try: - await self._writer_task + if self._writer_task: + await self._writer_task finally: try: # Make sure all error messages are written before closing From 573de95f2191fc676602430d1f2183159a29073a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:41:41 -1000 Subject: [PATCH 0883/1544] Speed up run time of admin services by using HassJob (#108623) --- homeassistant/helpers/service.py | 45 ++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index dee896ccba2..781d41a08d7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable, Iterable import dataclasses from enum import Enum -from functools import cache, partial, wraps +from functools import cache, partial import logging from types import ModuleType from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast @@ -965,6 +965,24 @@ async def _handle_entity_call( return result +async def _async_admin_handler( + hass: HomeAssistant, + service_job: HassJob[[None], Callable[[ServiceCall], Awaitable[None] | None]], + call: ServiceCall, +) -> None: + """Run an admin service.""" + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + if not user.is_admin: + raise Unauthorized(context=call.context) + + result = hass.async_run_hass_job(service_job, call) + if result is not None: + await result + + @bind_hass @callback def async_register_admin_service( @@ -975,21 +993,16 @@ def async_register_admin_service( schema: vol.Schema = vol.Schema({}, extra=vol.PREVENT_EXTRA), ) -> None: """Register a service that requires admin access.""" - - @wraps(service_func) - async def admin_handler(call: ServiceCall) -> None: - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - result = hass.async_run_job(service_func, call) - if result is not None: - await result - - hass.services.async_register(domain, service, admin_handler, schema) + hass.services.async_register( + domain, + service, + partial( + _async_admin_handler, + hass, + HassJob(service_func, f"admin service {domain}.{service}"), + ), + schema, + ) @bind_hass From 3d3f4ac29356e93cc6d362da96d78c3cf3358907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:45:45 -1000 Subject: [PATCH 0884/1544] Avoid recreating persistent notification update function when subscribing (#108624) I recently came up with an idea to look for callback functions that get created over and over frequently by adding logging to homeassistant.core.callback when its called to decorate a function. This one happens a lot at runtime. --- .../persistent_notification/__init__.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 9ecb91bdb7f..6d6fb7bfbd6 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime from enum import StrEnum +from functools import partial import logging from typing import Any, Final, TypedDict @@ -213,6 +214,21 @@ def websocket_get_notifications( ) +@callback +def _async_send_notification_update( + connection: websocket_api.ActiveConnection, + msg_id: int, + update_type: UpdateType, + notifications: dict[str, Notification], +) -> None: + """Send persistent_notification update.""" + connection.send_message( + websocket_api.event_message( + msg_id, {"type": update_type, "notifications": notifications} + ) + ) + + @callback @websocket_api.websocket_command( {vol.Required("type"): "persistent_notification/subscribe"} @@ -225,19 +241,9 @@ def websocket_subscribe_notifications( """Return a list of persistent_notifications.""" notifications = _async_get_or_create_notifications(hass) msg_id = msg["id"] - - @callback - def _async_send_notification_update( - update_type: UpdateType, notifications: dict[str, Notification] - ) -> None: - connection.send_message( - websocket_api.event_message( - msg["id"], {"type": update_type, "notifications": notifications} - ) - ) - + notify_func = partial(_async_send_notification_update, connection, msg_id) connection.subscriptions[msg_id] = async_dispatcher_connect( - hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, notify_func ) connection.send_result(msg_id) - _async_send_notification_update(UpdateType.CURRENT, notifications) + notify_func(UpdateType.CURRENT, notifications) From 740209912c5a7086fb702f448d249a23869d91c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:49:06 -1000 Subject: [PATCH 0885/1544] Small performance improvements to handing revoke token callbacks (#108625) - Use a set to avoid linear search for remove - Avoid recreating the unregister function each time --- homeassistant/auth/__init__.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index ac9bbaaf593..0e9a2429fe4 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import OrderedDict from collections.abc import Mapping from datetime import timedelta +from functools import partial import time from typing import Any, cast @@ -157,7 +158,7 @@ class AuthManager: self._providers = providers self._mfa_modules = mfa_modules self.login_flow = AuthManagerFlowManager(hass, self) - self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {} + self._revoke_callbacks: dict[str, set[CALLBACK_TYPE]] = {} @property def auth_providers(self) -> list[AuthProvider]: @@ -475,27 +476,28 @@ class AuthManager: """Delete a refresh token.""" await self._store.async_remove_refresh_token(refresh_token) - callbacks = self._revoke_callbacks.pop(refresh_token.id, []) + callbacks = self._revoke_callbacks.pop(refresh_token.id, ()) for revoke_callback in callbacks: revoke_callback() + @callback + def _async_unregister( + self, callbacks: set[CALLBACK_TYPE], callback_: CALLBACK_TYPE + ) -> None: + """Unregister a callback.""" + callbacks.remove(callback_) + @callback def async_register_revoke_token_callback( self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register a callback to be called when the refresh token id is revoked.""" if refresh_token_id not in self._revoke_callbacks: - self._revoke_callbacks[refresh_token_id] = [] + self._revoke_callbacks[refresh_token_id] = set() callbacks = self._revoke_callbacks[refresh_token_id] - callbacks.append(revoke_callback) - - @callback - def unregister() -> None: - if revoke_callback in callbacks: - callbacks.remove(revoke_callback) - - return unregister + callbacks.add(revoke_callback) + return partial(self._async_unregister, callbacks, revoke_callback) @callback def async_create_access_token( From a3f9fc45e3595e50673edb1710f31550e066df8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:52:47 -1000 Subject: [PATCH 0886/1544] Refactor async_listen_once to remove nonlocal (#108627) --- homeassistant/core.py | 61 +++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 9c7689f483b..fed3e34159a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1153,6 +1153,23 @@ _FilterableJobType = tuple[ ] +@dataclass(slots=True) +class _OneTimeListener: + hass: HomeAssistant + listener: Callable[[Event], Coroutine[Any, Any, None] | None] + remove: CALLBACK_TYPE | None = None + + @callback + def async_call(self, event: Event) -> None: + """Remove listener from event bus and then fire listener.""" + if not self.remove: + # If the listener was already removed, we don't need to do anything + return + self.remove() + self.remove = None + self.hass.async_run_job(self.listener, event) + + class EventBus: """Allow the firing of and listening for events.""" @@ -1344,39 +1361,21 @@ class EventBus: This method must be run in the event loop. """ - filterable_job: _FilterableJobType | None = None - - @callback - def _onetime_listener(event: Event) -> None: - """Remove listener from event bus and then fire listener.""" - nonlocal filterable_job - if hasattr(_onetime_listener, "run"): - return - # Set variable so that we will never run twice. - # Because the event bus loop might have async_fire queued multiple - # times, its possible this listener may already be lined up - # multiple times as well. - # This will make sure the second time it does nothing. - setattr(_onetime_listener, "run", True) - assert filterable_job is not None - self._async_remove_listener(event_type, filterable_job) - self._hass.async_run_job(listener, event) - - functools.update_wrapper( - _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] - ) - - filterable_job = ( - HassJob( - _onetime_listener, - f"onetime listen {event_type} {listener}", - job_type=HassJobType.Callback, + one_time_listener = _OneTimeListener(self._hass, listener) + remove = self._async_listen_filterable_job( + event_type, + ( + HassJob( + one_time_listener.async_call, + f"onetime listen {event_type} {listener}", + job_type=HassJobType.Callback, + ), + None, + False, ), - None, - False, ) - - return self._async_listen_filterable_job(event_type, filterable_job) + one_time_listener.remove = remove + return remove @callback def _async_remove_listener( From 0b3bcca49baa48269f0519de01a9f7aad97aabdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 17:53:45 -1000 Subject: [PATCH 0887/1544] Avoid string decode/encode round trip in websocket_api get_services (#108632) The cache was converting from bytes to str and when we read the cache we converted it back to bytes again --- homeassistant/components/websocket_api/commands.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 32f59bd0c5f..c088acc6e00 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -45,7 +45,7 @@ from homeassistant.helpers.json import ( JSON_DUMP, ExtendedJSONEncoder, find_paths_unserializable_data, - json_dumps, + json_bytes, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import EventType @@ -460,7 +460,7 @@ def _send_handle_entities_init_response( ) -async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: +async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" descriptions = await async_get_all_descriptions(hass) if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: @@ -469,8 +469,8 @@ async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: ] # If the descriptions are the same, return the cached JSON payload if cached_descriptions is descriptions: - return cast(str, cached_json_payload) - json_payload = json_dumps(descriptions) + return cast(bytes, cached_json_payload) + json_payload = json_bytes(descriptions) hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) return json_payload @@ -482,7 +482,7 @@ async def handle_get_services( ) -> None: """Handle get services command.""" payload = await _async_get_all_descriptions_json(hass) - connection.send_message(construct_result_message(msg["id"], payload.encode())) + connection.send_message(construct_result_message(msg["id"], payload)) @callback From e9a78700802857fc7bc38e53c68637e1ba270ce0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 18:09:33 -1000 Subject: [PATCH 0888/1544] Small cleanups to async_get_all_descriptions (#108633) --- homeassistant/helpers/service.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 781d41a08d7..f00e80f43d8 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -581,31 +581,36 @@ async def async_get_all_descriptions( descriptions_cache: dict[ tuple[str, str], dict[str, Any] | None ] = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - services = hass.services.async_services() + + # We don't mutate services here so we avoid calling + # async_services which makes a copy of every services + # dict. + services = hass.services._services # pylint: disable=protected-access # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. - missing = set() - all_services = [] - for domain in services: - for service_name in services[domain]: + domains_with_missing_services: set[str] = set() + all_services: set[tuple[str, str]] = set() + for domain, services_by_domain in services.items(): + for service_name in services_by_domain: cache_key = (domain, service_name) - all_services.append(cache_key) + all_services.add(cache_key) if cache_key not in descriptions_cache: - missing.add(domain) + domains_with_missing_services.add(domain) # If we have a complete cache, check if it is still valid + all_cache: tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] | None if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache if previous_all_services == all_services: - return cast(dict[str, dict[str, Any]], previous_descriptions_cache) + return previous_descriptions_cache # type: ignore[no-any-return] # Files we loaded for missing descriptions loaded: dict[str, JSON_TYPE] = {} - if missing: - ints_or_excs = await async_get_integrations(hass, missing) + if domains_with_missing_services: + ints_or_excs = await async_get_integrations(hass, domains_with_missing_services) integrations: list[Integration] = [] for domain, int_or_exc in ints_or_excs.items(): if type(int_or_exc) is Integration: # noqa: E721 @@ -617,11 +622,11 @@ async def async_get_all_descriptions( contents = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(missing, contents)) + loaded = dict(zip(domains_with_missing_services, contents)) # Load translations for all service domains translations = await translation.async_get_translations( - hass, "en", "services", list(services) + hass, "en", "services", services ) # Build response From 8d4a1f475e3cb963be8b06e8ecea6996be014f8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 18:39:17 -1000 Subject: [PATCH 0889/1544] Bump habluetooth to 2.3.1 (#108628) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluetooth/test_diagnostics.py | 90 ++++--------------- 5 files changed, 22 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c75524c8b3a..1951e3b15ea 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.2.0" + "habluetooth==2.3.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 386000a4ff6..e338e334290 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.2.0 +habluetooth==2.3.1 hass-nabucasa==0.75.1 hassil==1.5.2 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index e5186226dff..4a49a0b5d74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.2.0 +habluetooth==2.3.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46d1241e86..da9440de063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -812,7 +812,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.2.0 +habluetooth==2.3.1 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index debecb0ac80..eae5f6507ac 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -78,23 +78,16 @@ async def test_diagnostics( } }, ): - entry1 = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry1.add_to_hass(hass) - entry2 = MockConfigEntry( domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:02" ) entry2.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry1.entry_id) - await hass.async_block_till_done() assert await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry2) + expected = { "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -180,33 +173,6 @@ async def test_diagnostics( "start_time": ANY, "type": "HaScanner", }, - { - "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], - "last_detection": ANY, - "monotonic_time": ANY, - "name": "hci0 (00:00:00:00:00:01)", - "scanning": True, - "source": "00:00:00:00:00:01", - "start_time": ANY, - "type": "FakeHaScanner", - }, { "adapter": "hci1", "discovered_devices_and_advertisement_data": [ @@ -242,6 +208,12 @@ async def test_diagnostics( }, }, } + diag_scanners = diag["manager"].pop("scanners") + expected_scanners = expected["manager"].pop("scanners") + assert diag == expected + assert sorted(diag_scanners, key=lambda x: x["name"]) == sorted( + expected_scanners, key=lambda x: x["name"] + ) @patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) @@ -448,13 +420,7 @@ async def test_diagnostics_remote_adapter( "homeassistant.components.bluetooth.diagnostics.get_dbus_managed_objects", return_value={}, ): - entry1 = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry1.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry1.entry_id) - await hass.async_block_till_done() + entry1 = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) @@ -467,7 +433,7 @@ async def test_diagnostics_remote_adapter( diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { + expected = { "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -490,7 +456,7 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, @@ -568,33 +534,6 @@ async def test_diagnostics_remote_adapter( "start_time": ANY, "type": "HaScanner", }, - { - "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], - "last_detection": ANY, - "monotonic_time": ANY, - "name": "hci0 (00:00:00:00:00:01)", - "scanning": True, - "source": "00:00:00:00:00:01", - "start_time": ANY, - "type": "FakeHaScanner", - }, { "connectable": True, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, @@ -641,5 +580,12 @@ async def test_diagnostics_remote_adapter( }, } + diag_scanners = diag["manager"].pop("scanners") + expected_scanners = expected["manager"].pop("scanners") + assert diag == expected + assert sorted(diag_scanners, key=lambda x: x["name"]) == sorted( + expected_scanners, key=lambda x: x["name"] + ) + cancel() unsetup() From 4ee6735cbbf13c130ee7cf2ca99274141fafc0fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 19:33:05 -1000 Subject: [PATCH 0890/1544] Small cleanup to zone async_active_zone (#108629) --- homeassistant/components/zone/__init__.py | 62 +++++++++++++---------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index bfc9c2fce09..01ec041e9d8 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,9 +1,10 @@ """Support for the definition of zones.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable import logging from operator import attrgetter +import sys from typing import Any, Self, cast import voluptuous as vol @@ -109,40 +110,49 @@ def async_active_zone( This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones - min_dist = None - closest = None + min_dist: float = sys.maxsize + closest: State | None = None + # This can be called before async_setup by device tracker - zone_entity_ids: list[str] = hass.data.get(ZONE_ENTITY_IDS, []) + zone_entity_ids: Iterable[str] = hass.data.get(ZONE_ENTITY_IDS, ()) + for entity_id in zone_entity_ids: - zone = hass.states.get(entity_id) if ( - not zone + not (zone := hass.states.get(entity_id)) + # Skip unavailable zones or zone.state == STATE_UNAVAILABLE - or zone.attributes.get(ATTR_PASSIVE) + # Skip passive zones + or (zone_attrs := zone.attributes).get(ATTR_PASSIVE) + # Skip zones where we cannot calculate distance + or ( + zone_dist := distance( + latitude, + longitude, + zone_attrs[ATTR_LATITUDE], + zone_attrs[ATTR_LONGITUDE], + ) + ) + is None + # Skip zone that are outside the radius aka the + # lat/long is outside the zone + or not (zone_dist - (radius := zone_attrs[ATTR_RADIUS]) < radius) ): continue - zone_dist = distance( - latitude, - longitude, - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], - ) - - if zone_dist is None: + # If have a closest and its not closer than the closest skip it + if closest and not ( + zone_dist < min_dist + or ( + # If same distance, prefer smaller zone + zone_dist == min_dist and radius < closest.attributes[ATTR_RADIUS] + ) + ): continue - within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] - closer_zone = closest is None or zone_dist < min_dist # type: ignore[unreachable] - smaller_zone = ( - zone_dist == min_dist - and zone.attributes[ATTR_RADIUS] - < cast(State, closest).attributes[ATTR_RADIUS] - ) - - if within_zone and (closer_zone or smaller_zone): - min_dist = zone_dist - closest = zone + # We got here which means it closer than the previous known closest + # or equal distance but this one is smaller. + min_dist = zone_dist + closest = zone return closest From fb62b6f01effdafb6ba4fefd7b716ae4dbe7c37e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 21:35:24 -1000 Subject: [PATCH 0891/1544] Fix unifi test_tracked_clients test (#108638) Fix unifi test_tracked_clients client This test relied on the sensor platform getting set up and creating the device before the device_tracker platform was setup since the device_tracker platform will disable the entity because there is not matching device entry for it via https://github.com/home-assistant/core/blob/4ee6735cbbf13c130ee7cf2ca99274141fafc0fc/homeassistant/components/device_tracker/config_entry.py#L336 There is no guarantee the sensor platform will get set up before the device tracker platform so the test was subject to a race where it would fail if the device tracker platform was setup first --- tests/components/unifi/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index d48ff613902..0cddd505cd4 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -121,6 +121,7 @@ def mock_device_registry(hass): "00:00:00:00:00:03", "00:00:00:00:00:04", "00:00:00:00:00:05", + "00:00:00:00:00:06", "00:00:00:00:01:01", "00:00:00:00:02:02", ) From 94f1f3e40cef03b598fb931f3aebcd589bdf5344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 22 Jan 2024 08:57:48 +0100 Subject: [PATCH 0892/1544] Remove numbering from GitHub progress step to not deal with styling of list entries (#108639) --- homeassistant/components/github/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index be753f7f785..130b404015c 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -9,7 +9,7 @@ } }, "progress": { - "wait_for_device": "1. Open {url} \n2. Paste the following key to authorize the integration: \n```\n{code}\n```\n" + "wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```\n" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", From 2a24af14ff7b80eeecc6bb07d75821a2ddedf739 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 09:03:30 +0100 Subject: [PATCH 0893/1544] Remove obsolete services.yaml and translations from WLED (#108605) --- homeassistant/components/wled/services.yaml | 41 --------------------- homeassistant/components/wled/strings.json | 38 ------------------- 2 files changed, 79 deletions(-) delete mode 100644 homeassistant/components/wled/services.yaml diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml deleted file mode 100644 index 40170fd54e9..00000000000 --- a/homeassistant/components/wled/services.yaml +++ /dev/null @@ -1,41 +0,0 @@ -effect: - target: - entity: - integration: wled - domain: light - fields: - effect: - example: "Rainbow" - selector: - text: - intensity: - selector: - number: - min: 0 - max: 255 - palette: - example: "Tiamat" - selector: - text: - speed: - selector: - number: - min: 0 - max: 255 - reverse: - default: false - selector: - boolean: - -preset: - target: - entity: - integration: wled - domain: light - fields: - preset: - selector: - number: - min: -1 - max: 65535 - mode: box diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 22b1e451a68..9581641f545 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -133,43 +133,5 @@ "name": "Segment {segment} reverse" } } - }, - "services": { - "effect": { - "name": "Set effect", - "description": "Controls the effect settings of WLED.", - "fields": { - "effect": { - "name": "Effect", - "description": "Name or ID of the WLED light effect." - }, - "intensity": { - "name": "Effect intensity", - "description": "Intensity of the effect. Number between 0 and 255." - }, - "palette": { - "name": "Color palette", - "description": "Name or ID of the WLED light palette." - }, - "speed": { - "name": "Effect speed", - "description": "Speed of the effect." - }, - "reverse": { - "name": "Reverse effect", - "description": "Reverse the effect. Either true to reverse or false otherwise." - } - } - }, - "preset": { - "name": "Set preset (deprecated)", - "description": "Sets a preset for the WLED device.", - "fields": { - "preset": { - "name": "Preset ID", - "description": "ID of the WLED preset." - } - } - } } } From 09be3ffc293e5a22c7b86939c67d2384b7fb29ba Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 09:04:37 +0100 Subject: [PATCH 0894/1544] Add icon translations to WLED (#108604) --- homeassistant/components/wled/icons.json | 68 +++++++++++++++++++ homeassistant/components/wled/light.py | 4 +- homeassistant/components/wled/number.py | 1 - homeassistant/components/wled/select.py | 4 -- homeassistant/components/wled/sensor.py | 5 -- homeassistant/components/wled/switch.py | 4 -- .../wled/snapshots/test_number.ambr | 3 +- .../wled/snapshots/test_select.ambr | 12 ++-- .../wled/snapshots/test_switch.ambr | 12 ++-- tests/components/wled/test_light.py | 4 +- tests/components/wled/test_sensor.py | 10 +-- 11 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/wled/icons.json diff --git a/homeassistant/components/wled/icons.json b/homeassistant/components/wled/icons.json new file mode 100644 index 00000000000..65de1d0f985 --- /dev/null +++ b/homeassistant/components/wled/icons.json @@ -0,0 +1,68 @@ +{ + "entity": { + "light": { + "main": { + "default": "mdi:led-strip-variant" + }, + "segment": { + "default": "mdi:led-strip-variant" + } + }, + "number": { + "speed": { + "default": "mdi:speedometer" + } + }, + "select": { + "preset": { + "default": "mdi:playlist-play" + }, + "playlist": { + "default": "mdi:play-speed" + }, + "color_palette": { + "default": "mdi:palette-outline" + }, + "segment_color_palette": { + "default": "mdi:palette-outline" + }, + "live_override": { + "default": "mdi:theater" + } + }, + "sensor": { + "free_heap": { + "default": "mdi:memory" + }, + "wifi_signal": { + "default": "mdi:wifi" + }, + "wifi_channel": { + "default": "mdi:wifi" + }, + "wifi_bssid": { + "default": "mdi:wifi" + }, + "ip": { + "default": "mdi:ip-network" + } + }, + "switch": { + "nightlight": { + "default": "mdi:weather-night" + }, + "sync_send": { + "default": "mdi:upload-network-outline" + }, + "sync_receive": { + "default": "mdi:download-network-outline" + }, + "reverse": { + "default": "mdi:swap-horizontal-bold" + }, + "segment_reverse": { + "default": "mdi:swap-horizontal-bold" + } + } + } +} diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 5ca86978f0f..4327261d4be 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -51,7 +51,6 @@ class WLEDMainLight(WLEDEntity, LightEntity): """Defines a WLED main light.""" _attr_color_mode = ColorMode.BRIGHTNESS - _attr_icon = "mdi:led-strip-variant" _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -103,7 +102,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): """Defines a WLED light based on a segment.""" _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION - _attr_icon = "mdi:led-strip-variant" + _attr_translation_key = "segment" def __init__( self, @@ -121,7 +120,6 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if segment == 0: self._attr_name = None else: - self._attr_translation_key = "segment" self._attr_translation_placeholders = {"segment": str(segment)} self._attr_unique_id = ( diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 5b88165207f..fd734c07fbc 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -50,7 +50,6 @@ NUMBERS = [ WLEDNumberEntityDescription( key=ATTR_SPEED, translation_key="speed", - icon="mdi:speedometer", entity_category=EntityCategory.CONFIG, native_step=1, native_min_value=0, diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 7df43a4250d..36aff0f4536 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -49,7 +49,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): """Defined a WLED Live Override select.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:theater" _attr_translation_key = "live_override" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -73,7 +72,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" - _attr_icon = "mdi:playlist-play" _attr_translation_key = "preset" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -104,7 +102,6 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): class WLEDPlaylistSelect(WLEDEntity, SelectEntity): """Define a WLED Playlist select.""" - _attr_icon = "mdi:play-speed" _attr_translation_key = "playlist" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -138,7 +135,6 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): """Defines a WLED Palette select.""" _attr_entity_category = EntityCategory.CONFIG - _attr_icon = "mdi:palette-outline" _attr_translation_key = "color_palette" _segment: int diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 709edaf424f..a2e052eacd9 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -76,7 +76,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="free_heap", translation_key="free_heap", - icon="mdi:memory", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_SIZE, @@ -87,7 +86,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_signal", translation_key="wifi_signal", - icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -107,7 +105,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_channel", translation_key="wifi_channel", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.channel if device.info.wifi else None, @@ -115,7 +112,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="wifi_bssid", translation_key="wifi_bssid", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, @@ -123,7 +119,6 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="ip", translation_key="ip", - icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ip, ), diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 1fb300bd01d..f42e1cc7f9f 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -53,7 +53,6 @@ async def async_setup_entry( class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): """Defines a WLED nightlight switch.""" - _attr_icon = "mdi:weather-night" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "nightlight" @@ -91,7 +90,6 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync send switch.""" - _attr_icon = "mdi:upload-network-outline" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "sync_send" @@ -124,7 +122,6 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync receive switch.""" - _attr_icon = "mdi:download-network-outline" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "sync_receive" @@ -157,7 +154,6 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): class WLEDReverseSwitch(WLEDEntity, SwitchEntity): """Defines a WLED reverse effect switch.""" - _attr_icon = "mdi:swap-horizontal-bold" _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "reverse" _segment: int diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 5539f1f4503..7c05390a04e 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -87,7 +87,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 speed', - 'icon': 'mdi:speedometer', 'max': 255, 'min': 0, 'mode': , @@ -126,7 +125,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:speedometer', + 'original_icon': None, 'original_name': 'Segment 1 speed', 'platform': 'wled', 'previous_unique_id': None, diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 9c8cb52b4a6..3c96e063738 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -3,7 +3,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Live override', - 'icon': 'mdi:theater', 'options': list([ '0', '1', @@ -44,7 +43,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:theater', + 'original_icon': None, 'original_name': 'Live override', 'platform': 'wled', 'previous_unique_id': None, @@ -90,7 +89,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Segment 1 color palette', - 'icon': 'mdi:palette-outline', 'options': list([ 'Analogous', 'April Night', @@ -225,7 +223,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:palette-outline', + 'original_icon': None, 'original_name': 'Segment 1 color palette', 'platform': 'wled', 'previous_unique_id': None, @@ -271,7 +269,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGBW Light Playlist', - 'icon': 'mdi:play-speed', 'options': list([ 'Playlist 1', 'Playlist 2', @@ -310,7 +307,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:play-speed', + 'original_icon': None, 'original_name': 'Playlist', 'platform': 'wled', 'previous_unique_id': None, @@ -356,7 +353,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGBW Light Preset', - 'icon': 'mdi:playlist-play', 'options': list([ 'Preset 1', 'Preset 2', @@ -395,7 +391,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:playlist-play', + 'original_icon': None, 'original_name': 'Preset', 'platform': 'wled', 'previous_unique_id': None, diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 8031624c75b..1184f1842ac 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -5,7 +5,6 @@ 'duration': 60, 'fade': True, 'friendly_name': 'WLED RGB Light Nightlight', - 'icon': 'mdi:weather-night', 'target_brightness': 0, }), 'context': , @@ -36,7 +35,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-night', + 'original_icon': None, 'original_name': 'Nightlight', 'platform': 'wled', 'previous_unique_id': None, @@ -82,7 +81,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Reverse', - 'icon': 'mdi:swap-horizontal-bold', }), 'context': , 'entity_id': 'switch.wled_rgb_light_reverse', @@ -112,7 +110,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:swap-horizontal-bold', + 'original_icon': None, 'original_name': 'Reverse', 'platform': 'wled', 'previous_unique_id': None, @@ -158,7 +156,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Sync receive', - 'icon': 'mdi:download-network-outline', 'udp_port': 21324, }), 'context': , @@ -189,7 +186,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:download-network-outline', + 'original_icon': None, 'original_name': 'Sync receive', 'platform': 'wled', 'previous_unique_id': None, @@ -235,7 +232,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WLED RGB Light Sync send', - 'icon': 'mdi:upload-network-outline', 'udp_port': 21324, }), 'context': , @@ -266,7 +262,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:upload-network-outline', + 'original_icon': None, 'original_name': 'Sync send', 'platform': 'wled', 'previous_unique_id': None, diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 2594c228eda..fc1d5503c07 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -43,7 +43,7 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Solid" assert state.attributes.get(ATTR_HS_COLOR) == (37.412, 100.0) - assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_ICON) is None assert state.state == STATE_ON assert (entry := entity_registry.async_get("light.wled_rgb_light")) @@ -54,7 +54,7 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Blink" assert state.attributes.get(ATTR_HS_COLOR) == (148.941, 100.0) - assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_ICON) is None assert state.state == STATE_ON assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index d9168d7b697..db68bc2e454 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -61,7 +61,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_free_memory")) - assert state.attributes.get(ATTR_ICON) == "mdi:memory" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.BYTES assert state.state == "14600" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -71,7 +71,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_signal")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "76" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -93,7 +93,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_channel")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "11" @@ -102,7 +102,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_bssid")) - assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "AA:AA:AA:AA:AA:BB" @@ -111,7 +111,7 @@ async def test_sensors( assert entry.entity_category is EntityCategory.DIAGNOSTIC assert (state := hass.states.get("sensor.wled_rgb_light_ip")) - assert state.attributes.get(ATTR_ICON) == "mdi:ip-network" + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "127.0.0.1" From ef7e2cfc08bb4d1f1649c99cf8bb53f4be1aaa54 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 09:06:25 +0100 Subject: [PATCH 0895/1544] Add icon translations to Abode (#108407) --- homeassistant/components/abode/alarm_control_panel.py | 3 --- homeassistant/components/abode/icons.json | 9 +++++++++ homeassistant/components/abode/switch.py | 4 +--- 3 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/abode/icons.json diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index d0137395446..4671b71059d 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -17,8 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice, AbodeSystem from .const import DOMAIN -ICON = "mdi:security" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -33,7 +31,6 @@ async def async_setup_entry( class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """An alarm_control_panel implementation for Abode.""" - _attr_icon = ICON _attr_name = None _attr_code_arm_required = False _attr_supported_features = ( diff --git a/homeassistant/components/abode/icons.json b/homeassistant/components/abode/icons.json new file mode 100644 index 00000000000..89cee031818 --- /dev/null +++ b/homeassistant/components/abode/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "automation": { + "default": "mdi:robot" + } + } + } +} diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 14bdf4e0caf..8443a16ef8f 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -17,8 +17,6 @@ from .const import DOMAIN DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE] -ICON = "mdi:robot" - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -63,7 +61,7 @@ class AbodeSwitch(AbodeDevice, SwitchEntity): class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): """A switch implementation for Abode automations.""" - _attr_icon = ICON + _attr_translation_key = "automation" async def async_added_to_hass(self) -> None: """Set up trigger automation service.""" From fd1c9237a8fb7ae8a55a571149c2bc069d18bc6e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 22 Jan 2024 09:07:06 +0100 Subject: [PATCH 0896/1544] Bump songpal dependency to 0.16.1 (#108637) --- homeassistant/components/songpal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index ce78b8c9f03..d4d33a77d43 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.16"], + "requirements": ["python-songpal==0.16.1"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/requirements_all.txt b/requirements_all.txt index 4a49a0b5d74..64f530e6d9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2259,7 +2259,7 @@ python-roborock==0.39.0 python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16 +python-songpal==0.16.1 # homeassistant.components.tado python-tado==0.17.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da9440de063..d4f5528f362 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1720,7 +1720,7 @@ python-roborock==0.39.0 python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16 +python-songpal==0.16.1 # homeassistant.components.tado python-tado==0.17.4 From 9d380cea213586533f0343873a60badce3ae2102 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 09:08:01 +0100 Subject: [PATCH 0897/1544] Use default icon in Agent DVR (#108405) --- homeassistant/components/agent_dvr/alarm_control_panel.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 1ac26e2eb79..9e5586b21f4 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -18,8 +18,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONNECTION, DOMAIN as AGENT_DOMAIN -ICON = "mdi:security" - CONF_HOME_MODE_NAME = "home" CONF_AWAY_MODE_NAME = "away" CONF_NIGHT_MODE_NAME = "night" @@ -41,7 +39,6 @@ async def async_setup_entry( class AgentBaseStation(AlarmControlPanelEntity): """Representation of an Agent DVR Alarm Control Panel.""" - _attr_icon = ICON _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY From 62fe9144f185e349048567cb6429b805fb87782d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 09:10:26 +0100 Subject: [PATCH 0898/1544] Add icon translations to Adguard (#108406) --- homeassistant/components/adguard/icons.json | 75 +++++++++++++++++++++ homeassistant/components/adguard/sensor.py | 8 --- homeassistant/components/adguard/switch.py | 6 -- 3 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/adguard/icons.json diff --git a/homeassistant/components/adguard/icons.json b/homeassistant/components/adguard/icons.json new file mode 100644 index 00000000000..9c5df8a4a45 --- /dev/null +++ b/homeassistant/components/adguard/icons.json @@ -0,0 +1,75 @@ +{ + "entity": { + "sensor": { + "dns_queries": { + "default": "mdi:magnify" + }, + "dns_queries_blocked": { + "default": "mdi:magnify-close" + }, + "dns_queries_blocked_ratio": { + "default": "mdi:magnify-close" + }, + "parental_control_blocked": { + "default": "mdi:human-male-girl" + }, + "safe_browsing_blocked": { + "default": "mdi:shield-half-full" + }, + "safe_searches_enforced": { + "default": "mdi:shield-search" + }, + "average_processing_speed": { + "default": "mdi:speedometer" + }, + "rules_count": { + "default": "mdi:counter" + } + }, + "switch": { + "protection": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "parental": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "safe_search": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "safe_browsing": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "filtering": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + }, + "query_log": { + "default": "mdi:shield-check", + "state": { + "off": "mdi:shield-off" + } + } + } + }, + "services": { + "add_url": "mdi:link-plus", + "remove_url": "mdi:link-off", + "enable_url": "mdi:link-variant", + "disable_url": "mdi:link-variant-off", + "refresh": "mdi:refresh" + } +} diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index c8ec5023533..e1cec6c4d3b 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -33,56 +33,48 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( AdGuardHomeEntityDescription( key="dns_queries", translation_key="dns_queries", - icon="mdi:magnify", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.dns_queries(), ), AdGuardHomeEntityDescription( key="blocked_filtering", translation_key="dns_queries_blocked", - icon="mdi:magnify-close", native_unit_of_measurement="queries", value_fn=lambda adguard: adguard.stats.blocked_filtering(), ), AdGuardHomeEntityDescription( key="blocked_percentage", translation_key="dns_queries_blocked_ratio", - icon="mdi:magnify-close", native_unit_of_measurement=PERCENTAGE, value_fn=lambda adguard: adguard.stats.blocked_percentage(), ), AdGuardHomeEntityDescription( key="blocked_parental", translation_key="parental_control_blocked", - icon="mdi:human-male-girl", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_parental(), ), AdGuardHomeEntityDescription( key="blocked_safebrowsing", translation_key="safe_browsing_blocked", - icon="mdi:shield-half-full", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safebrowsing(), ), AdGuardHomeEntityDescription( key="enforced_safesearch", translation_key="safe_searches_enforced", - icon="mdi:shield-search", native_unit_of_measurement="requests", value_fn=lambda adguard: adguard.stats.replaced_safesearch(), ), AdGuardHomeEntityDescription( key="average_speed", translation_key="average_processing_speed", - icon="mdi:speedometer", native_unit_of_measurement=UnitOfTime.MILLISECONDS, value_fn=lambda adguard: adguard.stats.avg_processing_time(), ), AdGuardHomeEntityDescription( key="rules_count", translation_key="rules_count", - icon="mdi:counter", native_unit_of_measurement="rules", value_fn=lambda adguard: adguard.filtering.rules_count(allowlist=False), entity_registry_enabled_default=False, diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 4b6fe06cdab..0aa88aa3ffd 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -34,7 +34,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="protection", translation_key="protection", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.protection_enabled, turn_on_fn=lambda adguard: adguard.enable_protection, turn_off_fn=lambda adguard: adguard.disable_protection, @@ -42,7 +41,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="parental", translation_key="parental", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.parental.enabled, turn_on_fn=lambda adguard: adguard.parental.enable, turn_off_fn=lambda adguard: adguard.parental.disable, @@ -50,7 +48,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="safesearch", translation_key="safe_search", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safesearch.enabled, turn_on_fn=lambda adguard: adguard.safesearch.enable, turn_off_fn=lambda adguard: adguard.safesearch.disable, @@ -58,7 +55,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="safebrowsing", translation_key="safe_browsing", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.safebrowsing.enabled, turn_on_fn=lambda adguard: adguard.safebrowsing.enable, turn_off_fn=lambda adguard: adguard.safebrowsing.disable, @@ -66,7 +62,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="filtering", translation_key="filtering", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.filtering.enabled, turn_on_fn=lambda adguard: adguard.filtering.enable, turn_off_fn=lambda adguard: adguard.filtering.disable, @@ -74,7 +69,6 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( AdGuardHomeSwitchEntityDescription( key="querylog", translation_key="query_log", - icon="mdi:shield-check", is_on_fn=lambda adguard: adguard.querylog.enabled, turn_on_fn=lambda adguard: adguard.querylog.enable, turn_off_fn=lambda adguard: adguard.querylog.disable, From 4a34cd25b2130afe254ae0500b552d1c65f63e9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jan 2024 22:29:03 -1000 Subject: [PATCH 0899/1544] Reduce lock contention when all translations are already cached (#108634) --- homeassistant/helpers/translation.py | 34 ++++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index f0b20c945db..e20a290a4e2 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -18,7 +18,6 @@ from homeassistant.util.json import load_json _LOGGER = logging.getLogger(__name__) -TRANSLATION_LOAD_LOCK = "translation_load_lock" TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" @@ -191,13 +190,14 @@ async def _async_get_component_strings( class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "loaded", "cache") + __slots__ = ("hass", "loaded", "cache", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass self.loaded: dict[str, set[str]] = {} self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {} + self.lock = asyncio.Lock() async def async_fetch( self, @@ -206,10 +206,17 @@ class _TranslationCache: components: set[str], ) -> dict[str, str]: """Load resources into the cache.""" - components_to_load = components - self.loaded.setdefault(language, set()) - - if components_to_load: - await self._async_load(language, components_to_load) + loaded = self.loaded.setdefault(language, set()) + if components_to_load := components - loaded: + # Translations are never unloaded so if there are no components to load + # we can skip the lock which reduces contention when multiple different + # translations categories are being fetched at the same time which is + # common from the frontend. + async with self.lock: + # Check components to load again, as another task might have loaded + # them while we were waiting for the lock. + if components_to_load := components - loaded: + await self._async_load(language, components_to_load) result: dict[str, str] = {} category_cache = self.cache.get(language, {}).get(category, {}) @@ -337,11 +344,9 @@ async def async_get_translations( """Return all backend translations. If integration specified, load it for that one. - Otherwise default to loaded intgrations combined with config flow + Otherwise default to loaded integrations combined with config flow integrations if config_flow is true. """ - lock = hass.data.setdefault(TRANSLATION_LOAD_LOCK, asyncio.Lock()) - if integrations is not None: components = set(integrations) elif config_flow: @@ -354,10 +359,9 @@ async def async_get_translations( component for component in hass.config.components if "." not in component } - async with lock: - if TRANSLATION_FLATTEN_CACHE in hass.data: - cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE] - else: - cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass) + if TRANSLATION_FLATTEN_CACHE in hass.data: + cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE] + else: + cache = hass.data[TRANSLATION_FLATTEN_CACHE] = _TranslationCache(hass) - return await cache.async_fetch(language, category, components) + return await cache.async_fetch(language, category, components) From 0d8afc72c2304d21d1286e93ce675019acd4d840 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 22 Jan 2024 10:10:02 +0100 Subject: [PATCH 0900/1544] Update python-slugify to 8.0.1 (#108373) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e338e334290..9b004eb2f85 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ PyJWT==2.8.0 PyNaCl==1.5.0 pyOpenSSL==23.2.0 pyserial==3.5 -python-slugify==4.0.1 +python-slugify==8.0.1 PyTurboJPEG==1.7.1 pyudev==0.23.2 PyYAML==6.0.1 diff --git a/pyproject.toml b/pyproject.toml index 7fcdeac3fb8..99027f29b8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "orjson==3.9.12", "packaging>=23.1", "pip>=21.3.1", - "python-slugify==4.0.1", + "python-slugify==8.0.1", "PyYAML==6.0.1", "requests==2.31.0", "typing-extensions>=4.9.0,<5.0", diff --git a/requirements.txt b/requirements.txt index 948811d4940..cd5a84a506d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ pyOpenSSL==23.2.0 orjson==3.9.12 packaging>=23.1 pip>=21.3.1 -python-slugify==4.0.1 +python-slugify==8.0.1 PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.9.0,<5.0 diff --git a/requirements_test.txt b/requirements_test.txt index 06853ec93a2..99099de41a6 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -45,7 +45,7 @@ types-Pillow==10.1.0.20240106 types-protobuf==4.24.0.20240106 types-psutil==5.9.5.20240106 types-python-dateutil==2.8.19.20240106 -types-python-slugify==0.1.2 +types-python-slugify==8.0.0.3 types-pytz==2023.3.1.1 types-PyYAML==6.0.12.12 types-requests==2.31.0.3 From 881872fdb43d6d5908689a72c24e9e3d7ee807ed Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 22 Jan 2024 13:36:26 +0100 Subject: [PATCH 0901/1544] Add binary_sensor to Ecovacs (#108544) --- homeassistant/components/ecovacs/__init__.py | 1 + .../components/ecovacs/binary_sensor.py | 75 ++++++++++++++ .../components/ecovacs/controller.py | 19 ++++ homeassistant/components/ecovacs/entity.py | 15 +++ homeassistant/components/ecovacs/icons.json | 12 +++ homeassistant/components/ecovacs/strings.json | 5 + tests/components/ecovacs/conftest.py | 97 ++++++++++++++++--- .../fixtures/devices/yna5x1/device.json | 22 +++++ .../ecovacs/snapshots/test_binary_sensor.ambr | 63 ++++++++++++ .../ecovacs/snapshots/test_init.ambr | 29 ++++++ .../components/ecovacs/test_binary_sensor.py | 46 +++++++++ tests/components/ecovacs/test_init.py | 41 ++++++-- tests/components/ecovacs/util.py | 18 ++++ 13 files changed, 420 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/ecovacs/binary_sensor.py create mode 100644 homeassistant/components/ecovacs/icons.json create mode 100644 tests/components/ecovacs/fixtures/devices/yna5x1/device.json create mode 100644 tests/components/ecovacs/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ecovacs/snapshots/test_init.ambr create mode 100644 tests/components/ecovacs/test_binary_sensor.py create mode 100644 tests/components/ecovacs/util.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index e4c8a965695..6f07b61de4a 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -25,6 +25,7 @@ CONFIG_SCHEMA = vol.Schema( ) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py new file mode 100644 index 00000000000..ea22c9de432 --- /dev/null +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary sensor module.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilityEvent +from deebot_client.events.water_info import WaterInfoEvent + +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 .entity import EcovacsDescriptionEntity, EcovacsEntityDescription, EventT + + +@dataclass(kw_only=True, frozen=True) +class EcovacsBinarySensorEntityDescription( + BinarySensorEntityDescription, + EcovacsEntityDescription, + Generic[EventT], +): + """Class describing Deebot binary sensor entity.""" + + value_fn: Callable[[EventT], bool | None] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( + EcovacsBinarySensorEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + value_fn=lambda e: e.mop_attached, + key="mop_attached", + translation_key="mop_attached", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller.register_platform_add_entities( + EcovacsBinarySensor, ENTITY_DESCRIPTIONS, async_add_entities + ) + + +class EcovacsBinarySensor( + EcovacsDescriptionEntity[ + CapabilityEvent[EventT], EcovacsBinarySensorEntityDescription + ], + BinarySensorEntity, +): + """Ecovacs binary sensor.""" + + entity_description: EcovacsBinarySensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_is_on = self.entity_description.value_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 645c5b9bc19..78b05a8a7d1 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -23,7 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .entity import EcovacsDescriptionEntity, EcovacsEntityDescription from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) @@ -86,6 +88,23 @@ class EcovacsController: _LOGGER.debug("Controller initialize complete") + def register_platform_add_entities( + self, + entity_class: type[EcovacsDescriptionEntity], + descriptions: tuple[EcovacsEntityDescription, ...], + async_add_entities: AddEntitiesCallback, + ) -> None: + """Create entities from descriptions and add them.""" + new_entites: list[EcovacsDescriptionEntity] = [] + + for device in self.devices: + for description in descriptions: + if capability := description.capability_fn(device.capabilities): + new_entites.append(entity_class(device, capability, description)) + + if new_entites: + async_add_entities(new_entites) + async def teardown(self) -> None: """Disconnect controller.""" for device in self.devices: diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index caaefef0956..3a2bb03aabb 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -104,3 +104,18 @@ class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): """ for event_type in self._subscribed_events: self._device.events.request_refresh(event_type) + + +class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT, _EntityDescriptionT]): + """Ecovacs entity.""" + + def __init__( + self, + device: Device, + capability: CapabilityT, + entity_description: _EntityDescriptionT, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + self.entity_description = entity_description + super().__init__(device, capability, **kwargs) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json new file mode 100644 index 00000000000..74c27776f64 --- /dev/null +++ b/homeassistant/components/ecovacs/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "mop_attached": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + } + } + } +} diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 2ae12c244a1..6e4c97be360 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -19,6 +19,11 @@ } }, "entity": { + "binary_sensor": { + "mop_attached": { + "name": "Mop attached" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 9ba28857cbe..38ae8ea54ae 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,17 +1,21 @@ """Common fixtures for the Ecovacs tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, Mock, patch -from deebot_client.api_client import ApiClient -from deebot_client.authentication import Authenticator +from deebot_client.const import PATH_API_APPSVR_APP +from deebot_client.device import Device +from deebot_client.exceptions import ApiError from deebot_client.models import Credentials import pytest from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.core import HomeAssistant from .const import VALID_ENTRY_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -34,18 +38,43 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_authenticator() -> Generator[Mock, None, None]: +def device_classes() -> list[str]: + """Device classes, which should be returned by the get_devices api call.""" + return ["yna5x1"] + + +@pytest.fixture +def mock_authenticator(device_classes: list[str]) -> Generator[Mock, None, None]: """Mock the authenticator.""" - mock_authenticator = Mock(spec_set=Authenticator) - mock_authenticator.authenticate.return_value = Credentials("token", "user_id", 0) with patch( "homeassistant.components.ecovacs.controller.Authenticator", - return_value=mock_authenticator, - ), patch( + autospec=True, + ) as mock, patch( "homeassistant.components.ecovacs.config_flow.Authenticator", - return_value=mock_authenticator, + new=mock, ): - yield mock_authenticator + authenticator = mock.return_value + authenticator.authenticate.return_value = Credentials("token", "user_id", 0) + + devices = [] + for device_class in device_classes: + devices.append( + load_json_object_fixture(f"devices/{device_class}/device.json", DOMAIN) + ) + + def post_authenticated( + path: str, + json: dict[str, Any], + *, + query_params: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if path == PATH_API_APPSVR_APP: + return {"code": 0, "devices": devices, "errno": "0"} + raise ApiError("Path not mocked: {path}") + + authenticator.post_authenticated.side_effect = post_authenticated + yield authenticator @pytest.fixture @@ -55,10 +84,46 @@ def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: @pytest.fixture -def mock_api_client(mock_authenticator: Mock) -> Mock: - """Mock the API client.""" +def mock_mqtt_client(mock_authenticator: Mock) -> Mock: + """Mock the MQTT client.""" with patch( - "homeassistant.components.ecovacs.controller.ApiClient", - return_value=Mock(spec_set=ApiClient), - ) as mock_api_client: - yield mock_api_client.return_value + "homeassistant.components.ecovacs.controller.MqttClient", + autospec=True, + ) as mock_mqtt_client: + client = mock_mqtt_client.return_value + client._authenticator = mock_authenticator + client.subscribe.return_value = lambda: None + yield client + + +@pytest.fixture +def mock_device_execute() -> AsyncMock: + """Mock the device execute function.""" + with patch.object( + Device, "_execute_command", return_value=True + ) as mock_device_execute: + yield mock_device_execute + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, + mock_mqtt_client: Mock, + mock_device_execute: AsyncMock, +) -> MockConfigEntry: + """Set up the Ecovacs integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry + + +@pytest.fixture +def controller( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> EcovacsController: + """Get the controller for the config entry.""" + return hass.data[DOMAIN][init_integration.entry_id] diff --git a/tests/components/ecovacs/fixtures/devices/yna5x1/device.json b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json new file mode 100644 index 00000000000..0b2957af93b --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json @@ -0,0 +1,22 @@ +{ + "did": "E1234567890000000001", + "name": "E1234567890000000001", + "class": "yna5xi", + "resource": "upQ6", + "company": "eco-ng", + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-as.ww.ecouser.net" + }, + "deviceName": "DEEBOT OZMO 950 Series", + "icon": "https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1", + "UILogicId": "DX_9G", + "materialNo": "110-1820-0101", + "pid": "5c19a91ca1e6ee000178224a", + "product_category": "DEEBOT", + "model": "DX9G", + "nick": "Ozmo 950", + "homeSort": 9999, + "status": 1, + "otaUpgrade": {} +} diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..0ddf8c00a1f --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-entity_entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ozmo_950_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mop_attached', + 'unique_id': 'E1234567890000000001_mop_attached', + 'unit_of_measurement': None, + }) +# --- +# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-state] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.ozmo_950_mop_attached', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mop attached', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mop_attached', + 'unique_id': 'E1234567890000000001_mop_attached', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr new file mode 100644 index 00000000000..c5d34090204 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_devices_in_dr[E1234567890000000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ecovacs', + 'E1234567890000000001', + ), + }), + 'is_new': False, + 'manufacturer': 'Ecovacs', + 'model': 'DEEBOT OZMO 950 Series', + 'name': 'Ozmo 950', + 'name_by_user': None, + 'serial_number': 'E1234567890000000001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py new file mode 100644 index 00000000000..a912df60c62 --- /dev/null +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -0,0 +1,46 @@ +"""Tests for Ecovacs binary sensors.""" + +from deebot_client.event_bus import EventBus +from deebot_client.events import WaterAmount, WaterInfoEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import STATE_OFF, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import notify_and_wait + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +async def test_mop_attached( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + controller: EcovacsController, + snapshot: SnapshotAssertion, +) -> None: + """Test mop_attached binary sensor.""" + entity_id = "binary_sensor.ozmo_950_mop_attached" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") + assert entity_entry.device_id + + event_bus: EventBus = controller.devices[0].events + await notify_and_wait( + hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) + ) + + assert (state := hass.states.get(state.entity_id)) + assert entity_entry == snapshot(name=f"{entity_id}-state") + + await notify_and_wait( + hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False) + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_OFF diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index e6be4e22233..103ab254650 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -1,13 +1,16 @@ """Test init of ecovacs.""" from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch from deebot_client.exceptions import DeebotError, InvalidAuthenticationError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .const import IMPORT_DATA @@ -15,22 +18,31 @@ from .const import IMPORT_DATA from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_api_client") +@pytest.mark.usefixtures("init_integration") async def test_load_unload_config_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + init_integration: MockConfigEntry, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - + mock_config_entry = init_integration assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN in hass.data 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 DOMAIN not in hass.data + + +@pytest.fixture +def mock_api_client(mock_authenticator: Mock) -> Mock: + """Mock the API client.""" + with patch( + "homeassistant.components.ecovacs.controller.ApiClient", + autospec=True, + ) as mock_api_client: + yield mock_api_client.return_value async def test_config_entry_not_ready( @@ -83,3 +95,18 @@ async def test_async_setup_import( assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected assert mock_setup_entry.call_count == config_entries_expected assert mock_authenticator_authenticate.call_count == config_entries_expected + + +async def test_devices_in_dr( + device_registry: dr.DeviceRegistry, + controller: EcovacsController, + snapshot: SnapshotAssertion, +) -> None: + """Test all devices are in the device registry.""" + for device in controller.devices: + assert ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, device.device_info.did)} + ) + ) + assert device_entry == snapshot(name=device.device_info.did) diff --git a/tests/components/ecovacs/util.py b/tests/components/ecovacs/util.py new file mode 100644 index 00000000000..ba697226ae2 --- /dev/null +++ b/tests/components/ecovacs/util.py @@ -0,0 +1,18 @@ +"""Ecovacs test util.""" + + +import asyncio + +from deebot_client.event_bus import EventBus +from deebot_client.events import Event + +from homeassistant.core import HomeAssistant + + +async def notify_and_wait( + hass: HomeAssistant, event_bus: EventBus, event: Event +) -> None: + """Block till done.""" + event_bus.notify(event) + await asyncio.gather(*event_bus._tasks) + await hass.async_block_till_done() From 516fa64da5c66859afad2978d25523d5afab18f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 Jan 2024 13:43:06 +0100 Subject: [PATCH 0902/1544] Update Pillow to 10.2.0 (#108422) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index e49f525d0c2..73d7d3754ce 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.1.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.2.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 7ac9c5d406f..861e2cf26c2 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.1.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.2.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 5ffeec1ca41..ba9140b4ed8 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index a68741d4c33..a0eb7f3cb5b 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.22.1", "Pillow==10.1.0"] + "requirements": ["matrix-nio==0.22.1", "Pillow==10.2.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 59798e38957..1b05a768b64 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 23bd1d050a1..e3b202a9950 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.1.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.2.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 80b428b908e..6c511e3f44e 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.1.0"] + "requirements": ["Pillow==10.2.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 208e2d31de4..e63864af707 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.1.0", "simplehound==0.3"] + "requirements": ["Pillow==10.2.0", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 39083434e89..b98c4c6e428 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.1.0" + "Pillow==10.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9b004eb2f85..111f5f5b0ab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ mutagen==1.47.0 orjson==3.9.12 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.1.0 +Pillow==10.2.0 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 64f530e6d9e..5fc3b3e83c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -42,7 +42,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.1.0 +Pillow==10.2.0 # homeassistant.components.plex PlexAPI==4.15.7 diff --git a/requirements_test.txt b/requirements_test.txt index 99099de41a6..92a0a6e8ba0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -41,7 +41,7 @@ types-caldav==1.3.0.20240106 types-chardet==0.1.5 types-decorator==5.1.8.20240106 types-paho-mqtt==1.6.0.20240106 -types-Pillow==10.1.0.20240106 +types-pillow==10.2.0.20240111 types-protobuf==4.24.0.20240106 types-psutil==5.9.5.20240106 types-python-dateutil==2.8.19.20240106 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4f5528f362..d3d2d601a6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ HATasmota==0.8.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.1.0 +Pillow==10.2.0 # homeassistant.components.plex PlexAPI==4.15.7 From 8c31e67dbc290185f770fcb01367ac1cbd4da241 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 22 Jan 2024 13:51:17 +0100 Subject: [PATCH 0903/1544] Bump aiovodafone to 0.5.4 (#108592) --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 20ea4db057e..ced871b7616 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.3"] + "requirements": ["aiovodafone==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5fc3b3e83c3..8be1afb0dc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aiounifi==69 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.3 +aiovodafone==0.5.4 # homeassistant.components.waqi aiowaqi==3.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3d2d601a6c..cbc87321c85 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -359,7 +359,7 @@ aiounifi==69 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.3 +aiovodafone==0.5.4 # homeassistant.components.waqi aiowaqi==3.0.1 From ef5d46c79c00f92425c51b84d8f1b093be27f3c4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 22 Jan 2024 14:45:27 +0100 Subject: [PATCH 0904/1544] Convert AreaEntry to dataclass (#108648) * Convert AreaEntry to dataclass * Correct typing of AreaEntry.id * Move responsibility for generating area id to AreaRegistry --- homeassistant/helpers/area_registry.py | 47 +++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 1d785fd0cee..95f889281fc 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -2,11 +2,10 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Container, Iterable, MutableMapping +from collections.abc import Iterable, MutableMapping +import dataclasses from typing import Any, Literal, TypedDict, cast -import attr - from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -29,26 +28,15 @@ class EventAreaRegistryUpdatedData(TypedDict): area_id: str -@attr.s(slots=True, frozen=True) +@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) class AreaEntry: """Area Registry Entry.""" - name: str = attr.ib() - normalized_name: str = attr.ib() - aliases: set[str] = attr.ib( - converter=attr.converters.default_if_none(factory=set) # type: ignore[misc] - ) - id: str | None = attr.ib(default=None) - picture: str | None = attr.ib(default=None) - - def generate_id(self, existing_ids: Container[str]) -> None: - """Initialize ID.""" - suggestion = suggestion_base = slugify(self.name) - tries = 1 - while suggestion in existing_ids: - tries += 1 - suggestion = f"{suggestion_base}_{tries}" - object.__setattr__(self, "id", suggestion) + aliases: set[str] + id: str + name: str + normalized_name: str + picture: str | None class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): @@ -133,10 +121,14 @@ class AreaRegistry: if self.async_get_area_by_name(name): raise ValueError(f"The name {name} ({normalized_name}) is already in use") + area_id = self._generate_area_id(name) area = AreaEntry( - aliases=aliases, name=name, normalized_name=normalized_name, picture=picture + aliases=aliases or set(), + id=area_id, + name=name, + normalized_name=normalized_name, + picture=picture, ) - area.generate_id(self.areas) assert area.id is not None self.areas[area.id] = area self._normalized_name_area_idx[normalized_name] = area.id @@ -221,7 +213,7 @@ class AreaRegistry: if not new_values: return old - new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type] + new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] if normalized_name is not None: self._normalized_name_area_idx[ normalized_name @@ -273,6 +265,15 @@ class AreaRegistry: return data + def _generate_area_id(self, name: str) -> str: + """Generate area ID.""" + suggestion = suggestion_base = slugify(name) + tries = 1 + while suggestion in self.areas: + tries += 1 + suggestion = f"{suggestion_base}_{tries}" + return suggestion + @callback def async_get(hass: HomeAssistant) -> AreaRegistry: From 43daf20be3c5e26dc76c5bea3f059b6cf0f798c7 Mon Sep 17 00:00:00 2001 From: jmwaldrip Date: Mon, 22 Jan 2024 06:27:47 -0800 Subject: [PATCH 0905/1544] Bump asyncsleepiq to 1.5.2 (#108431) Upgrading asyncsleepiq to version 1.5.2 --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index cac696dc5af..db29e5ab586 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.4.2"] + "requirements": ["asyncsleepiq==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8be1afb0dc0..eb268a5b02e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -481,7 +481,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.4.2 +asyncsleepiq==1.5.2 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbc87321c85..9abd5becfe5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -427,7 +427,7 @@ arcam-fmj==1.4.0 async-upnp-client==0.38.1 # homeassistant.components.sleepiq -asyncsleepiq==1.4.2 +asyncsleepiq==1.5.2 # homeassistant.components.aurora auroranoaa==0.0.3 From d0da457a049c0203019676219ffb87fa35bbcda1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 15:52:59 +0100 Subject: [PATCH 0906/1544] Add device to Lutron (#107467) * Add typing to Lutron platforms * Add devices to Lutron * Add devices to Lutron * Fix typing * Fix * Add name * Fix lights * Comment out ESA * Fix domain * Fix domain * Fix * Make generic keypad base class --- homeassistant/components/lutron/__init__.py | 14 ++++- .../components/lutron/binary_sensor.py | 8 --- homeassistant/components/lutron/cover.py | 1 + homeassistant/components/lutron/entity.py | 56 ++++++++++++++++--- homeassistant/components/lutron/light.py | 1 + homeassistant/components/lutron/scene.py | 23 +++----- homeassistant/components/lutron/switch.py | 27 ++++----- 7 files changed, 80 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index d89797eedc7..486b1643f59 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -167,7 +168,7 @@ class LutronData: buttons: list[LutronButton] covers: list[tuple[str, Output]] lights: list[tuple[str, Output]] - scenes: list[tuple[str, str, Button, Led]] + scenes: list[tuple[str, Keypad, Button, Led]] switches: list[tuple[str, Output]] @@ -218,11 +219,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b (led for led in keypad.leds if led.number == button.number), None, ) - entry_data.scenes.append((area.name, keypad.name, button, led)) + entry_data.scenes.append((area.name, keypad, button, led)) entry_data.buttons.append(LutronButton(hass, area.name, keypad, button)) if area.occupancy_group is not None: entry_data.binary_sensors.append((area.name, area.occupancy_group)) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, lutron_client.guid)}, + manufacturer="Lutron", + name="Main repeater", + ) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 8433724d489..3adabfb3c9a 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -58,14 +58,6 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): # Error cases will end up treated as unoccupied. return self._lutron_device.state == OccupancyGroup.State.OCCUPIED - @property - def name(self) -> str: - """Return the name of the device.""" - # The default LutronDevice naming would create 'Kitchen Occ Kitchen', - # but since there can only be one OccupancyGroup per area we go - # with something shorter. - return f"{self._area_name} Occupancy" - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 1941c050aa4..9aace54757f 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -51,6 +51,7 @@ class LutronCover(LutronDevice, CoverEntity): | CoverEntityFeature.SET_POSITION ) _lutron_device: Output + _attr_name = None @property def is_closed(self) -> bool: diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index 423186eceae..4e6d0066a47 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -1,14 +1,19 @@ """Base class for Lutron devices.""" -from pylutron import Lutron, LutronEntity, LutronEvent +from pylutron import Keypad, Lutron, LutronEntity, LutronEvent +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_VIA_DEVICE +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from .const import DOMAIN -class LutronDevice(Entity): - """Representation of a Lutron device entity.""" + +class LutronBaseEntity(Entity): + """Base class for Lutron entities.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, area_name: str, lutron_device: LutronEntity, controller: Lutron @@ -28,11 +33,6 @@ class LutronDevice(Entity): """Run when invoked by pylutron when the device state changes.""" self.schedule_update_ha_state() - @property - def name(self) -> str: - """Return the name of the device.""" - return f"{self._area_name} {self._lutron_device.name}" - @property def unique_id(self) -> str | None: """Return a unique ID.""" @@ -40,3 +40,43 @@ class LutronDevice(Entity): if self._lutron_device.uuid is None: return None return f"{self._controller.guid}_{self._lutron_device.uuid}" + + +class LutronDevice(LutronBaseEntity): + """Representation of a Lutron device entity.""" + + def __init__( + self, area_name: str, lutron_device: LutronEntity, controller: Lutron + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lutron_device.uuid)}, + manufacturer="Lutron", + name=lutron_device.name, + suggested_area=area_name, + via_device=(DOMAIN, controller.guid), + ) + + +class LutronKeypad(LutronBaseEntity): + """Representation of a Lutron Keypad.""" + + def __init__( + self, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + keypad: Keypad, + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, keypad.id)}, + manufacturer="Lutron", + name=keypad.name, + ) + if keypad.type == "MAIN_REPEATER": + self._attr_device_info[ATTR_IDENTIFIERS].add((DOMAIN, controller.guid)) + else: + self._attr_device_info[ATTR_VIA_DEVICE] = (DOMAIN, controller.guid) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index b6860a4e818..fa7a45734fe 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -52,6 +52,7 @@ class LutronLight(LutronDevice, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _lutron_device: Output _prev_brightness: int | None = None + _attr_name = None @property def brightness(self) -> int: diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index ae8f787d290..a4a505c8477 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from pylutron import Button, Led, Lutron +from pylutron import Button, Keypad, Lutron from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, LutronData -from .entity import LutronDevice +from .entity import LutronKeypad async def async_setup_entry( @@ -28,14 +28,14 @@ async def async_setup_entry( async_add_entities( [ - LutronScene(area_name, keypad_name, device, led, entry_data.client) - for area_name, keypad_name, device, led in entry_data.scenes + LutronScene(area_name, keypad, device, entry_data.client) + for area_name, keypad, device, led in entry_data.scenes ], True, ) -class LutronScene(LutronDevice, Scene): +class LutronScene(LutronKeypad, Scene): """Representation of a Lutron Scene.""" _lutron_device: Button @@ -43,21 +43,14 @@ class LutronScene(LutronDevice, Scene): def __init__( self, area_name: str, - keypad_name: str, + keypad: Keypad, lutron_device: Button, - lutron_led: Led, controller: Lutron, ) -> None: """Initialize the scene/button.""" - super().__init__(area_name, lutron_device, controller) - self._keypad_name = keypad_name - self._led = lutron_led + super().__init__(area_name, lutron_device, controller, keypad) + self._attr_name = lutron_device.name def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self._lutron_device.press() - - @property - def name(self) -> str: - """Return the name of the device.""" - return f"{self._area_name} {self._keypad_name}: {self._lutron_device.name}" diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 5cb7dcf53d8..14331fa500d 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pylutron import Button, Led, Lutron, Output +from pylutron import Button, Keypad, Led, Lutron, Output from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, LutronData -from .entity import LutronDevice +from .entity import LutronDevice, LutronKeypad async def async_setup_entry( @@ -33,11 +33,9 @@ async def async_setup_entry( entities.append(LutronSwitch(area_name, device, entry_data.client)) # Add the indicator LEDs for scenes (keypad buttons) - for area_name, keypad_name, scene, led in entry_data.scenes: + for area_name, keypad, scene, led in entry_data.scenes: if led is not None: - entities.append( - LutronLed(area_name, keypad_name, scene, led, entry_data.client) - ) + entities.append(LutronLed(area_name, keypad, scene, led, entry_data.client)) async_add_entities(entities, True) @@ -77,7 +75,7 @@ class LutronSwitch(LutronDevice, SwitchEntity): self._prev_state = self._lutron_device.level > 0 -class LutronLed(LutronDevice, SwitchEntity): +class LutronLed(LutronKeypad, SwitchEntity): """Representation of a Lutron Keypad LED.""" _lutron_device: Led @@ -85,15 +83,15 @@ class LutronLed(LutronDevice, SwitchEntity): def __init__( self, area_name: str, - keypad_name: str, + keypad: Keypad, scene_device: Button, led_device: Led, controller: Lutron, ) -> None: """Initialize the switch.""" - self._keypad_name = keypad_name - self._scene_name = scene_device.name - super().__init__(area_name, led_device, controller) + super().__init__(area_name, led_device, controller, keypad) + self._keypad_name = keypad.name + self._attr_name = scene_device.name def turn_on(self, **kwargs: Any) -> None: """Turn the LED on.""" @@ -108,7 +106,7 @@ class LutronLed(LutronDevice, SwitchEntity): """Return the state attributes.""" return { "keypad": self._keypad_name, - "scene": self._scene_name, + "scene": self._attr_name, "led": self._lutron_device.name, } @@ -117,11 +115,6 @@ class LutronLed(LutronDevice, SwitchEntity): """Return true if device is on.""" return self._lutron_device.last_state - @property - def name(self) -> str: - """Return the name of the LED.""" - return f"{self._area_name} {self._keypad_name}: {self._scene_name} LED" - def update(self) -> None: """Call when forcing a refresh of the device.""" # The following property getter actually triggers an update in Lutron From e086cd9fef0d4bfeb5235a90658ec94d100853ad Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 22 Jan 2024 17:24:15 +0100 Subject: [PATCH 0907/1544] Add cloud tts entity (#108293) * Add cloud tts entity * Test test_login_view_missing_entity * Fix pipeline iteration for migration * Update tests * Make migration more strict * Fix docstring --- homeassistant/components/cloud/__init__.py | 11 +- .../components/cloud/assist_pipeline.py | 55 ++++-- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/prefs.py | 10 +- homeassistant/components/cloud/stt.py | 16 +- homeassistant/components/cloud/tts.py | 103 ++++++++++- tests/components/cloud/__init__.py | 48 +++++ tests/components/cloud/conftest.py | 11 ++ .../components/cloud/test_assist_pipeline.py | 16 ++ tests/components/cloud/test_http_api.py | 16 +- tests/components/cloud/test_stt.py | 70 +------ tests/components/cloud/test_tts.py | 173 +++++++++++++++++- 12 files changed, 428 insertions(+), 102 deletions(-) create mode 100644 tests/components/cloud/test_assist_pipeline.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index cdaae0d6272..888e99e3a34 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -65,7 +65,7 @@ from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD -PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT, Platform.TTS] SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" @@ -288,9 +288,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: loaded = False stt_platform_loaded = asyncio.Event() tts_platform_loaded = asyncio.Event() + stt_tts_entities_added = asyncio.Event() hass.data[DATA_PLATFORMS_SETUP] = { Platform.STT: stt_platform_loaded, Platform.TTS: tts_platform_loaded, + "stt_tts_entities_added": stt_tts_entities_added, } async def _on_start() -> None: @@ -330,6 +332,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: account_link.async_setup(hass) + # Load legacy tts platform for backwards compatibility. hass.async_create_task( async_load_platform( hass, @@ -377,8 +380,10 @@ def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] - stt_platform_loaded.set() + stt_tts_entities_added: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][ + "stt_tts_entities_added" + ] + stt_tts_entities_added.set() return True diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index 31e990cdb81..2c381dd0ac0 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -9,16 +9,23 @@ from homeassistant.components.assist_pipeline import ( ) from homeassistant.components.conversation import HOME_ASSISTANT_AGENT from homeassistant.components.stt import DOMAIN as STT_DOMAIN +from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID +from .const import ( + DATA_PLATFORMS_SETUP, + DOMAIN, + STT_ENTITY_UNIQUE_ID, + TTS_ENTITY_UNIQUE_ID, +) async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: """Create a cloud assist pipeline.""" - # Wait for stt and tts platforms to set up before creating the pipeline. + # Wait for stt and tts platforms to set up and entities to be added + # before creating the pipeline. platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] await asyncio.gather(*(event.wait() for event in platforms_setup.values())) # Make sure the pipeline store is loaded, needed because assist_pipeline @@ -29,8 +36,11 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: new_stt_engine_id = entity_registry.async_get_entity_id( STT_DOMAIN, DOMAIN, STT_ENTITY_UNIQUE_ID ) - if new_stt_engine_id is None: - # If there's no cloud stt entity, we can't create a cloud pipeline. + new_tts_engine_id = entity_registry.async_get_entity_id( + TTS_DOMAIN, DOMAIN, TTS_ENTITY_UNIQUE_ID + ) + if new_stt_engine_id is None or new_tts_engine_id is None: + # If there's no cloud stt or tts entity, we can't create a cloud pipeline. return None def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: @@ -43,7 +53,7 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: if ( pipeline.conversation_engine == HOME_ASSISTANT_AGENT and pipeline.stt_engine in (DOMAIN, new_stt_engine_id) - and pipeline.tts_engine == DOMAIN + and pipeline.tts_engine in (DOMAIN, new_tts_engine_id) ): return pipeline.id return None @@ -52,7 +62,7 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: cloud_pipeline := await async_create_default_pipeline( hass, stt_engine_id=new_stt_engine_id, - tts_engine_id=DOMAIN, + tts_engine_id=new_tts_engine_id, pipeline_name="Home Assistant Cloud", ) ) is None: @@ -61,25 +71,34 @@ async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: return cloud_pipeline.id -async def async_migrate_cloud_pipeline_stt_engine( - hass: HomeAssistant, stt_engine_id: str +async def async_migrate_cloud_pipeline_engine( + hass: HomeAssistant, platform: Platform, engine_id: str ) -> None: - """Migrate the speech-to-text engine in the cloud assist pipeline.""" - # Migrate existing pipelines with cloud stt to use new cloud stt engine id. - # Added in 2024.01.0. Can be removed in 2025.01.0. + """Migrate the pipeline engines in the cloud assist pipeline.""" + # Migrate existing pipelines with cloud stt or tts to use new cloud engine id. + # Added in 2024.02.0. Can be removed in 2025.02.0. + + # We need to make sure that both stt and tts are loaded before this migration. + # Assist pipeline will call default engine when setting up the store. + # Wait for the stt or tts platform loaded event here. + if platform == Platform.STT: + wait_for_platform = Platform.TTS + pipeline_attribute = "stt_engine" + elif platform == Platform.TTS: + wait_for_platform = Platform.STT + pipeline_attribute = "tts_engine" + else: + raise ValueError(f"Invalid platform {platform}") - # We need to make sure that tts is loaded before this migration. - # Assist pipeline will call default engine of tts when setting up the store. - # Wait for the tts platform loaded event here. platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] - await platforms_setup[Platform.TTS].wait() + await platforms_setup[wait_for_platform].wait() # Make sure the pipeline store is loaded, needed because assist_pipeline # is an after dependency of cloud await async_setup_pipeline_store(hass) + kwargs: dict[str, str] = {pipeline_attribute: engine_id} pipelines = async_get_pipelines(hass) for pipeline in pipelines: - if pipeline.stt_engine != DOMAIN: - continue - await async_update_pipeline(hass, pipeline, stt_engine=stt_engine_id) + if getattr(pipeline, pipeline_attribute) == DOMAIN: + await async_update_pipeline(hass, pipeline, **kwargs) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index da012c20bab..97d2345f16b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -73,3 +73,4 @@ MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE: SignalType[Any] = SignalType("cloud_remote_update") STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" +TTS_ENTITY_UNIQUE_ID = "cloud-text-to-speech" diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 4cc02867347..af5f9213e4d 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -104,10 +104,18 @@ class CloudPreferences: @callback def async_listen_updates( self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]] - ) -> None: + ) -> Callable[[], None]: """Listen for updates to the preferences.""" + + @callback + def unsubscribe() -> None: + """Remove the listener.""" + self._listeners.remove(listener) + self._listeners.append(listener) + return unsubscribe + async def async_update( self, *, diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index b652a36fa8a..3368f25f94a 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,6 +1,7 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations +import asyncio from collections.abc import AsyncIterable import logging @@ -19,12 +20,13 @@ from homeassistant.components.stt import ( SpeechToTextEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .assist_pipeline import async_migrate_cloud_pipeline_stt_engine +from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DOMAIN, STT_ENTITY_UNIQUE_ID +from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID _LOGGER = logging.getLogger(__name__) @@ -35,18 +37,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Home Assistant Cloud speech platform via config entry.""" + stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] + stt_platform_loaded.set() cloud: Cloud[CloudClient] = hass.data[DOMAIN] async_add_entities([CloudProviderEntity(cloud)]) class CloudProviderEntity(SpeechToTextEntity): - """NabuCasa speech API provider.""" + """Home Assistant Cloud speech API provider.""" _attr_name = "Home Assistant Cloud" _attr_unique_id = STT_ENTITY_UNIQUE_ID def __init__(self, cloud: Cloud[CloudClient]) -> None: - """Home Assistant NabuCasa Speech to text.""" + """Initialize cloud Speech to text entity.""" self.cloud = cloud @property @@ -81,7 +85,9 @@ class CloudProviderEntity(SpeechToTextEntity): async def async_added_to_hass(self) -> None: """Run when entity is about to be added to hass.""" - await async_migrate_cloud_pipeline_stt_engine(self.hass, self.entity_id) + await async_migrate_cloud_pipeline_engine( + self.hass, platform=Platform.STT, engine_id=self.entity_id + ) async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index f8152243bf5..2626c01e66f 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,6 +1,7 @@ """Support for the cloud for text-to-speech service.""" from __future__ import annotations +import asyncio import logging from typing import Any @@ -12,16 +13,21 @@ from homeassistant.components.tts import ( ATTR_AUDIO_OUTPUT, ATTR_VOICE, CONF_LANG, - PLATFORM_SCHEMA, + PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TextToSpeechEntity, TtsAudioType, Voice, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .assist_pipeline import async_migrate_cloud_pipeline_engine from .client import CloudClient -from .const import DOMAIN +from .const import DATA_PLATFORMS_SETUP, DOMAIN, TTS_ENTITY_UNIQUE_ID from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -48,7 +54,7 @@ def validate_lang(value: dict[str, Any]) -> dict[str, Any]: PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( + TTS_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LANG): str, vol.Optional(ATTR_GENDER): str, @@ -81,8 +87,95 @@ async def async_get_engine( return cloud_provider +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Home Assistant Cloud text-to-speech platform.""" + tts_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.TTS] + tts_platform_loaded.set() + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + async_add_entities([CloudTTSEntity(cloud)]) + + +class CloudTTSEntity(TextToSpeechEntity): + """Home Assistant Cloud text-to-speech entity.""" + + _attr_name = "Home Assistant Cloud" + _attr_unique_id = TTS_ENTITY_UNIQUE_ID + + def __init__(self, cloud: Cloud[CloudClient]) -> None: + """Initialize cloud text-to-speech entity.""" + self.cloud = cloud + self._language, self._gender = cloud.client.prefs.tts_default_voice + + async def _sync_prefs(self, prefs: CloudPreferences) -> None: + """Sync preferences.""" + self._language, self._gender = prefs.tts_default_voice + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._language + + @property + def default_options(self) -> dict[str, Any]: + """Return a dict include default options.""" + return { + ATTR_GENDER: self._gender, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + } + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotion.""" + return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + await async_migrate_cloud_pipeline_engine( + self.hass, platform=Platform.TTS, engine_id=self.entity_id + ) + self.async_on_remove( + self.cloud.client.prefs.async_listen_updates(self._sync_prefs) + ) + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + if not (voices := TTS_VOICES.get(language)): + return None + return [Voice(voice, voice) for voice in voices] + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS from Home Assistant Cloud.""" + # Process TTS + try: + data = await self.cloud.voice.process_tts( + text=message, + language=language, + gender=options.get(ATTR_GENDER), + voice=options.get(ATTR_VOICE), + output=options[ATTR_AUDIO_OUTPUT], + ) + except VoiceError as err: + _LOGGER.error("Voice error: %s", err) + return (None, None) + + return (str(options[ATTR_AUDIO_OUTPUT].value), data) + + class CloudProvider(Provider): - """NabuCasa Cloud speech API provider.""" + """Home Assistant Cloud speech API provider.""" def __init__( self, cloud: Cloud[CloudClient], language: str | None, gender: str | None @@ -136,7 +229,7 @@ class CloudProvider(Provider): async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: - """Load TTS from NabuCasa Cloud.""" + """Load TTS from Home Assistant Cloud.""" # Process TTS try: data = await self.cloud.voice.process_tts( diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 22b84f032f6..e6e793ed106 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -7,6 +7,54 @@ from homeassistant.components import cloud from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", +} + async def mock_cloud(hass, config=None): """Mock cloud.""" diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 1e1877ae13c..7421914d3d4 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -15,11 +15,22 @@ import jwt import pytest from homeassistant.components.cloud import CloudClient, const, prefs +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(autouse=True) +async def load_homeassistant(hass: HomeAssistant) -> None: + """Load the homeassistant integration. + + This is needed for the cloud integration to work. + """ + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture(name="cloud") async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: """Mock the cloud object. diff --git a/tests/components/cloud/test_assist_pipeline.py b/tests/components/cloud/test_assist_pipeline.py new file mode 100644 index 00000000000..7f1411dab45 --- /dev/null +++ b/tests/components/cloud/test_assist_pipeline.py @@ -0,0 +1,16 @@ +"""Test the cloud assist pipeline.""" +import pytest + +from homeassistant.components.cloud.assist_pipeline import ( + async_migrate_cloud_pipeline_engine, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def test_migrate_pipeline_invalid_platform(hass: HomeAssistant) -> None: + """Test migrate pipeline with invalid platform.""" + with pytest.raises(ValueError): + await async_migrate_cloud_pipeline_engine( + hass, Platform.BINARY_SENSOR, "test-engine-id" + ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 409d86d6e37..4602c054392 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -147,15 +147,19 @@ async def test_google_actions_sync_fails( assert mock_request_sync.call_count == 1 -async def test_login_view_missing_stt_entity( +@pytest.mark.parametrize( + "entity_id", ["stt.home_assistant_cloud", "tts.home_assistant_cloud"] +) +async def test_login_view_missing_entity( hass: HomeAssistant, setup_cloud: None, entity_registry: er.EntityRegistry, hass_client: ClientSessionGenerator, + entity_id: str, ) -> None: - """Test logging in when the cloud stt entity is missing.""" - # Make sure that the cloud stt entity does not exist. - entity_registry.async_remove("stt.home_assistant_cloud") + """Test logging in when a cloud assist pipeline needed entity is missing.""" + # Make sure that the cloud entity does not exist. + entity_registry.async_remove(entity_id) await hass.async_block_till_done() cloud_client = await hass_client() @@ -243,7 +247,7 @@ async def test_login_view_create_pipeline( create_pipeline_mock.assert_awaited_once_with( hass, stt_engine_id="stt.home_assistant_cloud", - tts_engine_id="cloud", + tts_engine_id="tts.home_assistant_cloud", pipeline_name="Home Assistant Cloud", ) @@ -282,7 +286,7 @@ async def test_login_view_create_pipeline_fail( create_pipeline_mock.assert_awaited_once_with( hass, stt_engine_id="stt.home_assistant_cloud", - tts_engine_id="cloud", + tts_engine_id="tts.home_assistant_cloud", pipeline_name="Home Assistant Cloud", ) diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py index 666d8ae7d65..305780e33e1 100644 --- a/tests/components/cloud/test_stt.py +++ b/tests/components/cloud/test_stt.py @@ -14,62 +14,10 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import PIPELINE_DATA + from tests.typing import ClientSessionGenerator -PIPELINE_DATA = { - "items": [ - { - "conversation_engine": "conversation_engine_1", - "conversation_language": "language_1", - "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", - "language": "language_1", - "name": "Home Assistant Cloud", - "stt_engine": "cloud", - "stt_language": "language_1", - "tts_engine": "cloud", - "tts_language": "language_1", - "tts_voice": "Arnold Schwarzenegger", - "wake_word_entity": None, - "wake_word_id": None, - }, - { - "conversation_engine": "conversation_engine_2", - "conversation_language": "language_2", - "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", - "language": "language_2", - "name": "name_2", - "stt_engine": "stt_engine_2", - "stt_language": "language_2", - "tts_engine": "tts_engine_2", - "tts_language": "language_2", - "tts_voice": "The Voice", - "wake_word_entity": None, - "wake_word_id": None, - }, - { - "conversation_engine": "conversation_engine_3", - "conversation_language": "language_3", - "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", - "language": "language_3", - "name": "name_3", - "stt_engine": None, - "stt_language": None, - "tts_engine": None, - "tts_language": None, - "tts_voice": None, - "wake_word_entity": None, - "wake_word_id": None, - }, - ], - "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", -} - - -@pytest.fixture(autouse=True) -async def load_homeassistant(hass: HomeAssistant) -> None: - """Load the homeassistant integration.""" - assert await async_setup_component(hass, "homeassistant", {}) - @pytest.fixture(autouse=True) async def delay_save_fixture() -> AsyncGenerator[None, None]: @@ -143,6 +91,7 @@ async def test_migrating_pipelines( hass_storage: dict[str, Any], ) -> None: """Test migrating pipelines when cloud stt entity is added.""" + entity_id = "stt.home_assistant_cloud" cloud.voice.process_stt = AsyncMock( return_value=STTResponse(True, "Turn the Kitchen Lights on") ) @@ -157,18 +106,18 @@ async def test_migrating_pipelines( assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) await hass.async_block_till_done() - on_start_callback = cloud.register_on_start.call_args[0][0] - await on_start_callback() + await cloud.login("test-user", "test-pass") await hass.async_block_till_done() - state = hass.states.get("stt.home_assistant_cloud") + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN - # The stt engine should be updated to the new cloud stt engine id. + # The stt/tts engines should have been updated to the new cloud engine ids. + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] == entity_id assert ( - hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] - == "stt.home_assistant_cloud" + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] + == "tts.home_assistant_cloud" ) # The other items should stay the same. @@ -189,7 +138,6 @@ async def test_migrating_pipelines( hass_storage[STORAGE_KEY]["data"]["items"][0]["name"] == "Home Assistant Cloud" ) assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_language"] == "language_1" - assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == "cloud" assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_language"] == "language_1" assert ( hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_voice"] diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 4069edcb744..b75d2361070 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,23 +1,36 @@ """Tests for cloud tts.""" -from collections.abc import Callable, Coroutine +from collections.abc import AsyncGenerator, Callable, Coroutine +from copy import deepcopy from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError import pytest import voluptuous as vol +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud import DOMAIN, const, tts from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.components.tts.helper import get_engine_instance from homeassistant.config import async_process_ha_core_config +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component +from . import PIPELINE_DATA + from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield + + @pytest.fixture(autouse=True) async def internal_url_mock(hass: HomeAssistant) -> None: """Mock internal URL of the instance.""" @@ -70,6 +83,10 @@ def test_schema() -> None: "gender": "female", }, ), + ( + "tts.home_assistant_cloud", + None, + ), ], ) async def test_prefs_default_voice( @@ -104,9 +121,17 @@ async def test_prefs_default_voice( assert engine.default_options == {"gender": "male", "audio_output": "mp3"} +@pytest.mark.parametrize( + "engine_id", + [ + DOMAIN, + "tts.home_assistant_cloud", + ], +) async def test_provider_properties( hass: HomeAssistant, cloud: MagicMock, + engine_id: str, ) -> None: """Test cloud provider.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -115,7 +140,7 @@ async def test_provider_properties( on_start_callback = cloud.register_on_start.call_args[0][0] await on_start_callback() - engine = get_engine_instance(hass, DOMAIN) + engine = get_engine_instance(hass, engine_id) assert engine is not None assert engine.supported_options == ["gender", "voice", "audio_output"] @@ -132,6 +157,7 @@ async def test_provider_properties( [ ({"platform": DOMAIN}, DOMAIN), ({"engine_id": DOMAIN}, DOMAIN), + ({"engine_id": "tts.home_assistant_cloud"}, "tts.home_assistant_cloud"), ], ) @pytest.mark.parametrize( @@ -241,3 +267,144 @@ async def test_get_tts_audio_logged_out( assert mock_process_tts.call_args.kwargs["language"] == "en-US" assert mock_process_tts.call_args.kwargs["gender"] == "female" assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +@pytest.mark.parametrize( + ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + [ + (b"", None), + (None, VoiceError("Boom!")), + ], +) +async def test_tts_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: EntityRegistry, + cloud: MagicMock, + mock_process_tts_return_value: bytes | None, + mock_process_tts_side_effect: Exception | None, +) -> None: + """Test text-to-speech entity.""" + mock_process_tts = AsyncMock( + return_value=mock_process_tts_return_value, + side_effect=mock_process_tts_side_effect, + ) + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + client = await hass_client() + entity_id = "tts.home_assistant_cloud" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + url = "/api/tts_get_url" + data = { + "engine_id": entity_id, + "message": "There is someone at the door.", + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{entity_id}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{entity_id}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + state = hass.states.get(entity_id) + assert state + assert state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + # Test removing the entity + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is None + + +async def test_migrating_pipelines( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test migrating pipelines when cloud tts entity is added.""" + entity_id = "tts.home_assistant_cloud" + mock_process_tts = AsyncMock( + return_value=b"", + ) + cloud.voice.process_tts = mock_process_tts + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": deepcopy(PIPELINE_DATA), + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + # The stt/tts engines should have been updated to the new cloud engine ids. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] + == "stt.home_assistant_cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == entity_id + + # The other items should stay the same. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_engine"] + == "conversation_engine_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_language"] + == "language_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["id"] + == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["name"] == "Home Assistant Cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_language"] == "language_1" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_voice"] + == "Arnold Schwarzenegger" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_entity"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_id"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][1] == PIPELINE_DATA["items"][1] + assert hass_storage[STORAGE_KEY]["data"]["items"][2] == PIPELINE_DATA["items"][2] From 988d72b8b61c49250c81a3067b7904e4d55ecb94 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 17:40:20 +0100 Subject: [PATCH 0908/1544] Add helper function to update and reload config entry to config flow (#108034) * Add helper function to update and reload config entry to config flow * Use async_create_task * Remove await * Reload only when update & add task name * Rename function --- homeassistant/config_entries.py | 23 ++++++++++++++++++ tests/test_config_entries.py | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 819b813832d..79c36658417 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1937,6 +1937,29 @@ class ConfigFlow(data_entry_flow.FlowHandler): return result + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + reason: str = "reauth_successful", + ) -> data_entry_flow.FlowResult: + """Update config entry, reload config entry and finish config flow.""" + result = self.hass.config_entries.async_update_entry( + entry=entry, + title=title, + data=data, + options=options, + ) + if result: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + return self.async_abort(reason=reason) + class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2a56e34b981..14224b95fc9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4144,3 +4144,46 @@ def test_raise_trying_to_add_same_config_entry_twice( entry.add_to_hass(hass) entry.add_to_hass(hass) assert f"An entry with the id {entry.entry_id} already exists" in caplog.text + + +async def test_update_entry_and_reload( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test updating an entry and reloading.""" + entry = MockConfigEntry( + domain="comp", + title="Test", + data={"vendor": "data"}, + options={"vendor": "options"}, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "comp.config_flow", None) + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_reauth(self, data): + """Mock Reauth.""" + return self.async_update_reload_and_abort( + entry=entry, + title="Updated Title", + data={"vendor": "data2"}, + options={"vendor": "options2"}, + ) + + with patch.dict(config_entries.HANDLERS, {"comp": MockFlowHandler}): + task = await manager.flow.async_init("comp", context={"source": "reauth"}) + await hass.async_block_till_done() + + assert entry.title == "Updated Title" + assert entry.data == {"vendor": "data2"} + assert entry.options == {"vendor": "options2"} + assert entry.state == config_entries.ConfigEntryState.LOADED + assert task["type"] == FlowResultType.ABORT + assert task["reason"] == "reauth_successful" From 4d85f78b32d4f30d1639541c61a9fb7de9466bd6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 18:20:20 +0100 Subject: [PATCH 0909/1544] Cleanup Discovergy config flow (#108381) * Cleanup Discovergy config flow * Make use of the helper function --- .../components/discovergy/config_flow.py | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 38a250a381d..d0e0c272d24 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -15,26 +15,37 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def make_schema(email: str = "", password: str = "") -> vol.Schema: - """Create schema for config flow.""" - return vol.Schema( - { - vol.Required( - CONF_EMAIL, - default=email, - ): str, - vol.Required( - CONF_PASSWORD, - default=password, - ): str, - } - ) +CONFIG_SCHEMA = vol.Schema( + { + vol.Required( + CONF_EMAIL, + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required( + CONF_PASSWORD, + ): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -42,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - existing_entry: ConfigEntry | None = None + _existing_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -51,15 +62,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="user", - data_schema=make_schema(), + data_schema=CONFIG_SCHEMA, ) return await self._validate_and_save(user_input) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle the initial step.""" - self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) - + self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) return await self._validate_and_save(entry_data, step_id="reauth") async def _validate_and_save( @@ -84,18 +94,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: - if self.existing_entry: - self.hass.config_entries.async_update_entry( - self.existing_entry, + if self._existing_entry: + return self.async_update_reload_and_abort( + entry=self._existing_entry, data={ CONF_EMAIL: user_input[CONF_EMAIL], CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) - await self.hass.config_entries.async_reload( - self.existing_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") # set unique id to title which is the account email await self.async_set_unique_id(user_input[CONF_EMAIL].lower()) @@ -107,6 +113,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id=step_id, - data_schema=make_schema(), + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, + self._existing_entry.data if self._existing_entry else user_input, + ), errors=errors, ) From 31ef034c3f91c7d994b91a863cb1ac9daeae8296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= Date: Mon, 22 Jan 2024 18:22:08 +0100 Subject: [PATCH 0910/1544] Update iOS configuration adding Action toggles to show in CarPlay and Watch (#108355) --- homeassistant/components/ios/__init__.py | 4 ++++ homeassistant/components/ios/const.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 3ba29bf154b..291f08425fa 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -26,6 +26,8 @@ from .const import ( CONF_ACTION_LABEL_COLOR, CONF_ACTION_LABEL_TEXT, CONF_ACTION_NAME, + CONF_ACTION_SHOW_IN_CARPLAY, + CONF_ACTION_SHOW_IN_WATCH, CONF_ACTIONS, DOMAIN, ) @@ -147,6 +149,8 @@ ACTION_SCHEMA = vol.Schema( vol.Optional(CONF_ACTION_ICON_ICON): cv.string, vol.Optional(CONF_ACTION_ICON_COLOR): cv.string, }, + vol.Optional(CONF_ACTION_SHOW_IN_CARPLAY): cv.boolean, + vol.Optional(CONF_ACTION_SHOW_IN_WATCH): cv.boolean, }, ) diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py index 3e6b2155add..41da1954b44 100644 --- a/homeassistant/components/ios/const.py +++ b/homeassistant/components/ios/const.py @@ -11,3 +11,5 @@ CONF_ACTION_ICON = "icon" CONF_ACTION_ICON_COLOR = "color" CONF_ACTION_ICON_ICON = "icon" CONF_ACTIONS = "actions" +CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay" +CONF_ACTION_SHOW_IN_WATCH = "show_in_watch" From 80207835d7e98fc7e48f8bcc4e906e564c63bd13 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 22 Jan 2024 20:09:48 +0100 Subject: [PATCH 0911/1544] Move core fundamental components into bootstrap (#105560) Co-authored-by: Erik Co-authored-by: Martin Hjelmare --- homeassistant/bootstrap.py | 68 ++++++++++++++++--- .../components/default_config/__init__.py | 5 -- .../components/default_config/manifest.json | 23 +------ tests/components/default_config/test_init.py | 6 ++ tests/test_bootstrap.py | 18 +++-- 5 files changed, 80 insertions(+), 40 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index bca74a684b2..cc3d87319d0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -39,7 +39,6 @@ from .helpers import ( from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType from .setup import ( - DATA_SETUP, DATA_SETUP_STARTED, DATA_SETUP_TIME, async_notify_setup_error, @@ -106,6 +105,52 @@ STAGE_1_INTEGRATIONS = { # Ensure supervisor is available "hassio", } +DEFAULT_INTEGRATIONS = { + # These integrations are set up unless recovery mode is activated. + # + # Integrations providing core functionality: + "application_credentials", + "frontend", + "hardware", + "logger", + "network", + "system_health", + # + # Key-feature: + "automation", + "person", + "scene", + "script", + "tag", + "zone", + # + # Built-in helpers: + "counter", + "input_boolean", + "input_button", + "input_datetime", + "input_number", + "input_select", + "input_text", + "schedule", + "timer", +} +DEFAULT_INTEGRATIONS_RECOVERY_MODE = { + # These integrations are set up if recovery mode is activated. + "frontend", +} +DEFAULT_INTEGRATIONS_SUPERVISOR = { + # These integrations are set up if using the Supervisor + "hassio", +} +DEFAULT_INTEGRATIONS_NON_SUPERVISOR = { + # These integrations are set up if not using the Supervisor + "backup", +} +CRITICAL_INTEGRATIONS = { + # Recovery mode is activated if these integrations fail to set up + "frontend", +} async def async_setup_hass( @@ -165,11 +210,11 @@ async def async_setup_hass( _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") recovery_mode = True - elif ( - "frontend" in hass.data.get(DATA_SETUP, {}) - and "frontend" not in hass.config.components - ): - _LOGGER.warning("Detected that frontend did not load. Activating recovery mode") + elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): + _LOGGER.warning( + "Detected that %s did not load. Activating recovery mode", + ",".join(CRITICAL_INTEGRATIONS), + ) # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken with contextlib.suppress(asyncio.TimeoutError): @@ -478,13 +523,18 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN } - # Add config entry domains + # Add config entry and default domains if not hass.config.recovery_mode: + domains.update(DEFAULT_INTEGRATIONS) domains.update(hass.config_entries.async_domains()) + else: + domains.update(DEFAULT_INTEGRATIONS_RECOVERY_MODE) - # Make sure the Hass.io component is loaded + # Add domains depending on if the Supervisor is used or not if "SUPERVISOR" in os.environ: - domains.add("hassio") + domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR) + else: + domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR) return domains diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 25a9ca311e8..2221bbbef61 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,9 +1,7 @@ """Component providing default configuration for new users.""" -from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component DOMAIN = "default_config" @@ -12,7 +10,4 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" - if not is_hassio(hass): - await async_setup_component(hass, "backup", config) - return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 684013a5633..cbadb704a42 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -3,46 +3,25 @@ "name": "Default Config", "codeowners": ["@home-assistant/core"], "dependencies": [ - "application_credentials", "assist_pipeline", - "automation", "bluetooth", "cloud", "conversation", - "counter", "dhcp", "energy", - "frontend", - "hardware", "history", "homeassistant_alerts", - "input_boolean", - "input_button", - "input_datetime", - "input_number", - "input_select", - "input_text", "logbook", - "logger", "map", "media_source", "mobile_app", "my", - "network", - "person", - "scene", - "schedule", - "script", "ssdp", "stream", "sun", - "system_health", - "tag", - "timer", "usb", "webhook", - "zeroconf", - "zone" + "zeroconf" ], "documentation": "https://www.home-assistant.io/integrations/default_config", "integration_type": "system", diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index f3907aac548..20029fe3cdc 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from homeassistant import bootstrap from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -34,4 +35,9 @@ async def test_setup( ) -> None: """Test setup.""" recorder_helper.async_initialize_recorder(hass) + # default_config needs the homeassistant integration, assert it will be + # automatically setup by bootstrap and set it up manually for this test + assert "homeassistant" in bootstrap.CORE_INTEGRATIONS + assert await async_setup_component(hass, "homeassistant", {"foo": "bar"}) + assert await async_setup_component(hass, "default_config", {"foo": "bar"}) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4c350168d4e..b640d59df44 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -87,12 +87,21 @@ async def test_async_enable_logging( async def test_load_hassio(hass: HomeAssistant) -> None: - """Test that we load Hass.io component.""" + """Test that we load the hassio integration when using Supervisor.""" with patch.dict(os.environ, {}, clear=True): - assert bootstrap._get_domains(hass, {}) == set() + assert "hassio" not in bootstrap._get_domains(hass, {}) with patch.dict(os.environ, {"SUPERVISOR": "1"}): - assert bootstrap._get_domains(hass, {}) == {"hassio"} + assert "hassio" in bootstrap._get_domains(hass, {}) + + +async def test_load_backup(hass: HomeAssistant) -> None: + """Test that we load the backup integration when not using Supervisor.""" + with patch.dict(os.environ, {}, clear=True): + assert "backup" in bootstrap._get_domains(hass, {}) + + with patch.dict(os.environ, {"SUPERVISOR": "1"}): + assert "backup" not in bootstrap._get_domains(hass, {}) @pytest.mark.parametrize("load_registries", [False]) @@ -784,6 +793,7 @@ async def test_setup_recovery_mode_if_no_frontend( @pytest.mark.parametrize("load_registries", [False]) +@patch("homeassistant.bootstrap.DEFAULT_INTEGRATIONS", set()) async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( hass: HomeAssistant, ) -> None: @@ -836,7 +846,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( assert integrations[0] != {} assert "an_after_dep" in integrations[0] - assert integrations[-3] != {} + assert integrations[-2] != {} assert integrations[-1] == {} assert "normal_integration" in hass.config.components From 3d1751bdfa85e979ab157dcab721bbc965c1c440 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 22 Jan 2024 20:26:46 +0100 Subject: [PATCH 0912/1544] Prevent runtime issue during entity registration in coordinator of AVM Fritz!Tools (#108667) prevent dictionary changed size during iteration --- homeassistant/components/fritz/common.py | 6 ++++-- tests/components/fritz/test_update.py | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index bad73d91320..55bf7279ede 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -315,12 +315,14 @@ class FritzBoxTools( } try: await self.async_scan_devices() - for key, update_fn in self._entity_update_functions.items(): + for key in list(self._entity_update_functions): _LOGGER.debug("update entity %s", key) entity_data["entity_states"][ key ] = await self.hass.async_add_executor_job( - update_fn, self.fritz_status, self.data["entity_states"].get(key) + self._entity_update_functions[key], + self.fritz_status, + self.data["entity_states"].get(key), ) if self.has_call_deflections: entity_data[ diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index 5cb9d4d3d69..bc677e28ebe 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -2,8 +2,6 @@ from unittest.mock import patch -import pytest - from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -49,7 +47,6 @@ async def test_update_entities_initialized( assert len(updates) == 1 -@pytest.mark.xfail(reason="Flaky test") async def test_update_available( hass: HomeAssistant, hass_client: ClientSessionGenerator, From e47ed1698045704970c265d058d9ff0b62d4492c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 20:59:24 +0100 Subject: [PATCH 0913/1544] Use snapshot testing in Airly sensor (#108608) * Use snapshot testing in Airly sensor * Apply suggestions from code review Co-authored-by: Robert Resch * Fix tests --------- Co-authored-by: Robert Resch --- .../airly/snapshots/test_sensor.ambr | 584 ++++++++++++++++++ tests/components/airly/test_sensor.py | 191 +----- 2 files changed, 596 insertions(+), 179 deletions(-) create mode 100644 tests/components/airly/snapshots/test_sensor.ambr diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..eac02cbbc1e --- /dev/null +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -0,0 +1,584 @@ +# serializer version: 1 +# name: test_sensor[sensor.home_carbon_monoxide-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.home_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'co', + 'unique_id': '123-456-co', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'friendly_name': 'Home Carbon monoxide', + 'limit': 4000, + 'percent': 4, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_carbon_monoxide', + 'last_changed': , + 'last_updated': , + 'state': '162.49', + }) +# --- +# name: test_sensor[sensor.home_common_air_quality_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_common_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-filter', + 'original_name': 'Common air quality index', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'caqi', + 'unique_id': '123-456-caqi', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_sensor[sensor.home_common_air_quality_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'advice': 'Catch your breath!', + 'attribution': 'Data provided by Airly', + 'description': 'Great air here today!', + 'friendly_name': 'Home Common air quality index', + 'icon': 'mdi:air-filter', + 'level': 'very low', + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.home_common_air_quality_index', + 'last_changed': , + 'last_updated': , + 'state': '7.29', + }) +# --- +# name: test_sensor[sensor.home_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'humidity', + 'friendly_name': 'Home Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_humidity', + 'last_changed': , + 'last_updated': , + 'state': '68.35', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_dioxide-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.home_nitrogen_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-no2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'nitrogen_dioxide', + 'friendly_name': 'Home Nitrogen dioxide', + 'limit': 25, + 'percent': 64, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '16.04', + }) +# --- +# name: test_sensor[sensor.home_ozone-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.home_ozone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-o3', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'ozone', + 'friendly_name': 'Home Ozone', + 'limit': 100, + 'percent': 42, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ozone', + 'last_changed': , + 'last_updated': , + 'state': '41.52', + }) +# --- +# name: test_sensor[sensor.home_pm1-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.home_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm1', + 'friendly_name': 'Home PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm1', + 'last_changed': , + 'last_updated': , + 'state': '2.83', + }) +# --- +# name: test_sensor[sensor.home_pm10-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.home_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm10', + 'friendly_name': 'Home PM10', + 'limit': 45, + 'percent': 14, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm10', + 'last_changed': , + 'last_updated': , + 'state': '6.06', + }) +# --- +# name: test_sensor[sensor.home_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pm25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pm25', + 'friendly_name': 'Home PM2.5', + 'limit': 15, + 'percent': 29, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '4.37', + }) +# --- +# name: test_sensor[sensor.home_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'pressure', + 'friendly_name': 'Home Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1019.86', + }) +# --- +# name: test_sensor[sensor.home_sulphur_dioxide-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.home_sulphur_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sulphur dioxide', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-so2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'sulphur_dioxide', + 'friendly_name': 'Home Sulphur dioxide', + 'limit': 40, + 'percent': 35, + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_sulphur_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '13.97', + }) +# --- +# name: test_sensor[sensor.home_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.home_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-456-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Airly', + 'device_class': 'temperature', + 'friendly_name': 'Home Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_temperature', + 'last_changed': , + 'last_updated': , + 'state': '14.37', + }) +# --- diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 35d7eb86c04..2a2bf9fb923 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -1,27 +1,12 @@ """Test sensor of Airly integration.""" from datetime import timedelta from http import HTTPStatus +from unittest.mock import patch from airly.exceptions import AirlyError +from syrupy import SnapshotAssertion -from homeassistant.components.airly.sensor import ATTRIBUTION -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - PERCENTAGE, - STATE_UNAVAILABLE, - UnitOfPressure, - UnitOfTemperature, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -37,171 +22,19 @@ async def test_sensor( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test states of the sensor.""" - await init_integration(hass, aioclient_mock) + with patch("homeassistant.components.airly.PLATFORMS", [Platform.SENSOR]): + entry = await init_integration(hass, aioclient_mock) - state = hass.states.get("sensor.home_common_air_quality_index") - assert state - assert state.state == "7.29" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - entry = entity_registry.async_get("sensor.home_common_air_quality_index") - assert entry - assert entry.unique_id == "123-456-caqi" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_humidity") - assert state - assert state.state == "68.35" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_humidity") - assert entry - assert entry.unique_id == "123-456-humidity" - assert entry.options["sensor"] == {"suggested_display_precision": 1} - - state = hass.states.get("sensor.home_pm1") - assert state - assert state.state == "2.83" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm1") - assert entry - assert entry.unique_id == "123-456-pm1" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4.37" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm2_5") - assert entry - assert entry.unique_id == "123-456-pm25" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pm10") - assert state - assert state.state == "6.06" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pm10") - assert entry - assert entry.unique_id == "123-456-pm10" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_carbon_monoxide") - assert state - assert state.state == "162.49" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - entry = entity_registry.async_get("sensor.home_carbon_monoxide") - assert entry - assert entry.unique_id == "123-456-co" - - state = hass.states.get("sensor.home_nitrogen_dioxide") - assert state - assert state.state == "16.04" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_nitrogen_dioxide") - assert entry - assert entry.unique_id == "123-456-no2" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_ozone") - assert state - assert state.state == "41.52" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_ozone") - assert entry - assert entry.unique_id == "123-456-o3" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_sulphur_dioxide") - assert state - assert state.state == "13.97" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_sulphur_dioxide") - assert entry - assert entry.unique_id == "123-456-so2" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_pressure") - assert state - assert state.state == "1019.86" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.HPA - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_pressure") - assert entry - assert entry.unique_id == "123-456-pressure" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_temperature") - assert state - assert state.state == "14.37" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_temperature") - assert entry - assert entry.unique_id == "123-456-temperature" - assert entry.options["sensor"] == {"suggested_display_precision": 1} + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_availability( From 21009bef0202d5526db232e23d27a47d1f4c440e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 22 Jan 2024 21:17:04 +0100 Subject: [PATCH 0914/1544] Add icon translations to Airly (#108404) * Add icon translations to Airly * Fix test * Fix tests --- homeassistant/components/airly/icons.json | 9 +++++++++ homeassistant/components/airly/sensor.py | 1 - tests/components/airly/snapshots/test_sensor.ambr | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/airly/icons.json diff --git a/homeassistant/components/airly/icons.json b/homeassistant/components/airly/icons.json new file mode 100644 index 00000000000..646953014d0 --- /dev/null +++ b/homeassistant/components/airly/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "caqi": { + "default": "mdi:air-filter" + } + } + } +} diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 6105b277088..f91a242b8d5 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -66,7 +66,6 @@ class AirlySensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, - icon="mdi:air-filter", translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index eac02cbbc1e..36ae402f4f4 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -76,7 +76,7 @@ }), }), 'original_device_class': None, - 'original_icon': 'mdi:air-filter', + 'original_icon': None, 'original_name': 'Common air quality index', 'platform': 'airly', 'previous_unique_id': None, @@ -93,7 +93,6 @@ 'attribution': 'Data provided by Airly', 'description': 'Great air here today!', 'friendly_name': 'Home Common air quality index', - 'icon': 'mdi:air-filter', 'level': 'very low', 'unit_of_measurement': 'CAQI', }), From d75dd0973f38c0d4d10644c06583fbcdf5fd02b2 Mon Sep 17 00:00:00 2001 From: jmwaldrip Date: Mon, 22 Jan 2024 12:22:54 -0800 Subject: [PATCH 0915/1544] Fix SleepIQ setting FootWarmer timer (#108433) * Fixing foot warmer timer bug * Fixing bug where temperature wasnt assigned to number entity causing tests to fail --- homeassistant/components/sleepiq/number.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 520e11bb331..4f90ef7dbdc 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -5,7 +5,13 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast -from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQFootWarmer, SleepIQSleeper +from asyncsleepiq import ( + FootWarmingTemps, + SleepIQActuator, + SleepIQBed, + SleepIQFootWarmer, + SleepIQSleeper, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -79,6 +85,10 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: async def _async_set_foot_warmer_time( foot_warmer: SleepIQFootWarmer, time: int ) -> None: + temperature = FootWarmingTemps(foot_warmer.temperature) + if temperature != FootWarmingTemps.OFF: + await foot_warmer.turn_on(temperature, time) + foot_warmer.timer = time From e1fd5e83a7db146fcb6a1210967d2d98c86b7eb5 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 23 Jan 2024 06:45:08 +1000 Subject: [PATCH 0916/1544] Add time to charge sensor to Tessie (#108342) * Add time to charge and type checking * Revert drive_state_shift_state change * Use original name * Use function instead of lambda * Update homeassistant/components/tessie/sensor.py Co-authored-by: Jan-Philipp Benecke * Fix callback * Avoid having to test None * Go back to if * Use minutes instead of hours --------- Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/tessie/sensor.py | 22 ++++++++++++++++--- homeassistant/components/tessie/strings.json | 3 +++ .../components/tessie/fixtures/vehicles.json | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7ed1b0416e3..07f54ebde5b 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,20 +25,29 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity +@callback +def hours_to_datetime(value: StateType) -> datetime | None: + """Convert relative hours into absolute datetime.""" + if isinstance(value, (int, float)) and value > 0: + return dt_util.now() + timedelta(minutes=value) + return None + + @dataclass(frozen=True, kw_only=True) class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" - value_fn: Callable[[StateType], StateType] = lambda x: x + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( @@ -81,6 +91,12 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.SPEED, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieSensorEntityDescription( + key="charge_state_minutes_to_full_charge", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=hours_to_datetime, + ), TessieSensorEntityDescription( key="charge_state_battery_range", state_class=SensorStateClass.MEASUREMENT, @@ -243,6 +259,6 @@ class TessieSensorEntity(TessieEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.get()) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index d8ccf47bb73..1f0a42bb781 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -88,6 +88,9 @@ "charge_state_battery_range": { "name": "Battery range" }, + "charge_state_minutes_to_full_charge": { + "name": "Time to full charge" + }, "drive_state_speed": { "name": "Speed" }, diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json index c1b0851eee6..359e23f9cdd 100644 --- a/tests/components/tessie/fixtures/vehicles.json +++ b/tests/components/tessie/fixtures/vehicles.json @@ -62,7 +62,7 @@ "fast_charger_type": "ACSingleWireCAN", "ideal_battery_range": 263.68, "max_range_charge_counter": 0, - "minutes_to_full_charge": 30, + "minutes_to_full_charge": 0, "not_enough_power_to_heat": null, "off_peak_charging_enabled": false, "off_peak_charging_times": "all_week", @@ -77,7 +77,7 @@ "scheduled_departure_time": 1694899800, "scheduled_departure_time_minutes": 450, "supercharger_session_trip_planner": false, - "time_to_full_charge": 0.5, + "time_to_full_charge": 0, "timestamp": 1701139037461, "trip_charging": false, "usable_battery_level": 75, From 47601144087b468d9a106b0dffbcd06b108b3ab3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 21:47:28 +0100 Subject: [PATCH 0917/1544] Fix flaky sensibo test (#108669) --- tests/components/sensibo/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/sensibo/conftest.py b/tests/components/sensibo/conftest.py index b2798224b14..d455e1bb1f7 100644 --- a/tests/components/sensibo/conftest.py +++ b/tests/components/sensibo/conftest.py @@ -62,7 +62,7 @@ async def get_data_from_library( return output -@pytest.fixture(name="load_json", scope="session") +@pytest.fixture(name="load_json") def load_json_from_fixture() -> SensiboData: """Load fixture with json data and return.""" From 981193047085533e77746a16274f200b9266e0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:11:38 -1000 Subject: [PATCH 0918/1544] Use new config entry update/abort helper in esphome (#108672) --- homeassistant/components/esphome/config_flow.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 898fb55a3ac..9962b9144ea 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -275,16 +275,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, } if self._reauth_entry: - entry = self._reauth_entry - self.hass.config_entries.async_update_entry( - entry, data=self._reauth_entry.data | config_data + return self.async_update_reload_and_abort( + self._reauth_entry, data=self._reauth_entry.data | config_data ) - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") assert self._name is not None return self.async_create_entry( From 3b6c85b904de3003e73031fa6c03342cbf7ceed9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:12:18 -1000 Subject: [PATCH 0919/1544] Use new config entry update/abort helper in august (#108673) Use new config entry update/abort helper in august uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/august/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index f22b16008d3..8aaf1b1a05b 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -271,6 +271,4 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not existing_entry: return self.async_create_entry(title=info["title"], data=info["data"]) - self.hass.config_entries.async_update_entry(existing_entry, data=info["data"]) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(existing_entry, data=info["data"]) From e7be9cb447ea74c199703093d3cf61860ddf42d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:12:56 -1000 Subject: [PATCH 0920/1544] Use new config entry update/abort helper in powerwall (#108674) Use new config entry update/abort helper in powerwall uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/powerwall/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index a00d1eaa041..e86949e2227 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -258,11 +258,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): {CONF_IP_ADDRESS: entry_data[CONF_IP_ADDRESS], **user_input} ) if not errors: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.reauth_entry, data={**entry_data, **user_input} ) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", From d7273d66ab583db5b02000be2cbb578ebaabf413 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:14:00 -1000 Subject: [PATCH 0921/1544] Use new config entry update/abort helper in bthome (#108676) Use new config entry update/abort helper in bthome uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/bthome/config_flow.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 26bf1186a1d..41440cb435f 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -181,15 +181,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): if entry_id := self.context.get("entry_id"): entry = self.hass.config_entries.async_get_entry(entry_id) assert entry is not None - - self.hass.config_entries.async_update_entry(entry, data=data) - - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=data) return self.async_create_entry( title=self.context["title_placeholders"]["name"], From bd7eb01546779cecc40df59b0dfea710fbb178c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:16:27 -1000 Subject: [PATCH 0922/1544] Use new config entry update/abort helper in xiaomi_ble (#108677) Use new config entry update/abort helper in xiaomi_ble uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/xiaomi_ble/config_flow.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index bca76bfd0a5..a0c03581eee 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -289,15 +289,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): if entry_id := self.context.get("entry_id"): entry = self.hass.config_entries.async_get_entry(entry_id) assert entry is not None - - self.hass.config_entries.async_update_entry(entry, data=data) - - # Reload the config entry to notify of updated config - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=data) return self.async_create_entry( title=self.context["title_placeholders"]["name"], From 4378a171b25cae34e48434afcab386e704c79286 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:25:48 -1000 Subject: [PATCH 0923/1544] Use new config entry update/abort helper in yalexs_ble (#108675) Use new config entry update/abort helper in yalexs_ble uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/yalexs_ble/config_flow.py | 4 +--- tests/components/yalexs_ble/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 3a6d91c4f55..578519107cd 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -207,11 +207,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_SLOT], ) ): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( reauth_entry, data={**reauth_entry.data, **user_input} ) - await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_validate", diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 593b9a7a9d0..a8ce68a3209 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -958,7 +958,7 @@ async def test_reauth(hass: HomeAssistant) -> None: result2["flow_id"], { CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", - CONF_SLOT: 66, + CONF_SLOT: 67, }, ) await hass.async_block_till_done() From 07926660bc00c0e7e37440edbdb4d8fc9376a95a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:33:49 -1000 Subject: [PATCH 0924/1544] Use new config entry update/abort helper in isy994 (#108678) Use new config entry update/abort helper in isy994 uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/isy994/config_flow.py | 7 +++---- tests/components/isy994/test_config_flow.py | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 9f16b4a0d0c..2cdfd1df16d 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -276,10 +276,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" else: - cfg_entries = self.hass.config_entries - cfg_entries.async_update_entry(existing_entry, data=new_data) - await cfg_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort( + self._existing_entry, data=new_data + ) self.context["title_placeholders"] = { CONF_NAME: existing_entry.title, diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index fe344332f38..4a5bfb007f0 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -676,6 +676,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert mock_setup_entry.called assert result4["type"] == "abort" From 4bf4bc7e9b2b27870ec9cca2c61f9324c49fe626 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:39:57 -1000 Subject: [PATCH 0925/1544] Use new config entry update/abort helper in synology_dsm (#108682) --- homeassistant/components/synology_dsm/config_flow.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index ef2fc3dc128..f49eb7feed1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -220,13 +220,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] if existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, data=config_data + reason = ( + "reauth_successful" if self.reauth_conf else "reconfigure_successful" + ) + return self.async_update_reload_and_abort( + existing_entry, data=config_data, reason=reason ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - if self.reauth_conf: - return self.async_abort(reason="reauth_successful") - return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=friendly_name or host, data=config_data) From d825c853519f76672977cbe51893ea5b12d2584f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:43:12 -1000 Subject: [PATCH 0926/1544] Use new config entry update/abort helper in enphase_envoy (#108679) Use new config entry update/abort helper in enphase_envoy uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/enphase_envoy/config_flow.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 939359f7fbf..198fbd833b0 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -164,16 +164,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): name = self._async_envoy_name() if self._reauth_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data=self._reauth_entry.data | user_input, ) - self.hass.async_create_task( - self.hass.config_entries.async_reload( - self._reauth_entry.entry_id - ) - ) - return self.async_abort(reason="reauth_successful") if not self.unique_id: await self.async_set_unique_id(envoy.serial_number) From faf52aa2edf6c0d7b1174176e17afb9fd7d88194 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:59:17 -1000 Subject: [PATCH 0927/1544] Use new config entry update/abort helper in shelly (#108684) Use new config entry update/abort helper in shelly uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/shelly/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 59ae6eed196..70268fd23c4 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -330,11 +330,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.entry, data={**self.entry.data, **user_input} ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") if get_device_entry_gen(self.entry) in BLOCK_GENERATIONS: schema = { From 17202e21f304f9d7152af437ef797227ee498e73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 11:59:50 -1000 Subject: [PATCH 0928/1544] Use new config entry update/abort helper in samsungtv (#108683) Use new config entry update/abort helper in samsungtv uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/samsungtv/config_flow.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index f20a79cc9e6..2e6f64f08e1 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -528,11 +528,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if result == RESULT_SUCCESS: new_data = dict(self._reauth_entry.data) new_data[CONF_TOKEN] = bridge.token - self.hass.config_entries.async_update_entry( - self._reauth_entry, data=new_data + return self.async_update_reload_and_abort( + self._reauth_entry, + data=new_data, ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") if result not in (RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT): return self.async_abort(reason=result) @@ -569,7 +568,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and (token := await self._authenticator.try_pin(pin)) and (session_id := await self._authenticator.get_session_id_and_close()) ): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data={ **self._reauth_entry.data, @@ -577,8 +576,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_SESSION_ID: session_id, }, ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") errors = {"base": RESULT_INVALID_PIN} From 9ee883236748edb2eabe95002e6af817436d0aba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 12:01:08 -1000 Subject: [PATCH 0929/1544] Use new config entry update/abort helper in onvif (#108680) Use new config entry update/abort helper in onvif uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/onvif/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index e0342c5f0d4..9688a78bf3f 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -146,11 +146,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): configure_unique_id=False ) if not errors: - hass = self.hass - entry_id = entry.entry_id - hass.config_entries.async_update_entry(entry, data=self.onvif_config) - hass.async_create_task(hass.config_entries.async_reload(entry_id)) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(entry, data=self.onvif_config) username = (user_input or {}).get(CONF_USERNAME) or entry.data[CONF_USERNAME] return self.async_show_form( From 0b79504cf033db88120e5f2ea60d700d742a57f0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 22 Jan 2024 23:01:55 +0100 Subject: [PATCH 0930/1544] Extend config entry update/abort helper to also update unique id (#108681) * Extend config entry update/abort helper to also update unique id * Move kwarg to end * Make additionals kwargs only --- homeassistant/config_entries.py | 3 +++ tests/test_config_entries.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 79c36658417..7eee83953a7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1941,6 +1941,8 @@ class ConfigFlow(data_entry_flow.FlowHandler): def async_update_reload_and_abort( self, entry: ConfigEntry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, @@ -1949,6 +1951,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): """Update config entry, reload config entry and finish config flow.""" result = self.hass.config_entries.async_update_entry( entry=entry, + unique_id=unique_id, title=title, data=data, options=options, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 14224b95fc9..db382ac35f4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4152,6 +4152,7 @@ async def test_update_entry_and_reload( """Test updating an entry and reloading.""" entry = MockConfigEntry( domain="comp", + unique_id="1234", title="Test", data={"vendor": "data"}, options={"vendor": "options"}, @@ -4172,6 +4173,7 @@ async def test_update_entry_and_reload( """Mock Reauth.""" return self.async_update_reload_and_abort( entry=entry, + unique_id="5678", title="Updated Title", data={"vendor": "data2"}, options={"vendor": "options2"}, @@ -4182,6 +4184,7 @@ async def test_update_entry_and_reload( await hass.async_block_till_done() assert entry.title == "Updated Title" + assert entry.unique_id == "5678" assert entry.data == {"vendor": "data2"} assert entry.options == {"vendor": "options2"} assert entry.state == config_entries.ConfigEntryState.LOADED From def42c6da0f09b0a6fbd33649606b0d746725072 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 12:19:55 -1000 Subject: [PATCH 0931/1544] Use new config entry update/abort helper in enphase-envoy (part 2) (#108689) Use new config entry update/abort helper in enphase_envoy uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/enphase_envoy/config_flow.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 198fbd833b0..5921de15bde 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -103,13 +103,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and entry.data[CONF_HOST] == self.ip_address ): title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY - self.hass.config_entries.async_update_entry( - entry, title=title, unique_id=serial + return self.async_update_reload_and_abort( + entry, title=title, unique_id=serial, reason="already_configured" ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - return self.async_abort(reason="already_configured") return await self.async_step_user() From 426fce93aa67c5b1e79a63c81da0d32ee53106ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 13:24:03 -1000 Subject: [PATCH 0932/1544] Use new config entry update/abort helper in apple_tv (#108688) --- homeassistant/components/apple_tv/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 251d1e377d3..11d408ee2ca 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -546,13 +546,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # If an existing config entry is updated, then this was a re-auth if existing_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( existing_entry, data=data, unique_id=self.unique_id ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(existing_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.atv.name, data=data) From 12b41c35eccf9304577312d380b9fca52b03f995 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 13:24:15 -1000 Subject: [PATCH 0933/1544] Use new config entry update/abort helper in sense (#108691) --- homeassistant/components/sense/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index d7f7588beb2..86b68db1e32 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -69,11 +69,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=self._auth_data[CONF_EMAIL], data=self._auth_data ) - self.hass.config_entries.async_update_entry( - existing_entry, data=self._auth_data - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(existing_entry, data=self._auth_data) async def validate_input_and_create_entry(self, user_input, errors): """Validate the input and create the entry from the data.""" From f6bc5c98b3b13bc7aa9eccc3408e9f1f067a252f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 13:30:21 -1000 Subject: [PATCH 0934/1544] Handle tplink credential change at run time (#108692) --- .../components/tplink/coordinator.py | 5 ++- tests/components/tplink/test_init.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 582c49638e7..798580ef3c2 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -4,9 +4,10 @@ from __future__ import annotations from datetime import timedelta import logging -from kasa import SmartDevice, SmartDeviceException +from kasa import AuthenticationException, SmartDevice, SmartDeviceException from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -42,5 +43,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): """Fetch all device and sensor data from api.""" try: await self.device.update(update_children=False) + except AuthenticationException as ex: + raise ConfigEntryAuthFailed from ex except SmartDeviceException as ex: raise UpdateFailed(ex) from ex diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index e6297cf6553..7bee7823013 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -5,6 +5,7 @@ import copy from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch +from kasa.exceptions import AuthenticationException import pytest from homeassistant import setup @@ -17,6 +18,8 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, + STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -29,6 +32,7 @@ from . import ( IP_ADDRESS, MAC_ADDRESS, _mocked_dimmer, + _mocked_plug, _patch_connect, _patch_discovery, _patch_single_discovery, @@ -256,3 +260,32 @@ async def test_config_entry_errors( any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) == reauth_flows ) + + +async def test_plug_auth_fails(hass: HomeAssistant) -> None: + """Test a smart plug auth failure.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) + config_entry.add_to_hass(hass) + plug = _mocked_plug() + with _patch_discovery(device=plug), _patch_connect(device=plug): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.my_plug" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + plug.update = AsyncMock(side_effect=AuthenticationException) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + assert ( + len( + hass.config_entries.flow.async_progress_by_handler( + DOMAIN, match_context={"source": SOURCE_REAUTH} + ) + ) + == 1 + ) From 7c86ab14c3b9e2998e843af9c9677ab9e5ccc558 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 14:21:17 -1000 Subject: [PATCH 0935/1544] Refactor service enumeration methods to better match existing use cases (#108671) --- homeassistant/auth/mfa_modules/notify.py | 2 +- .../google_assistant_sdk/__init__.py | 2 +- .../components/google_mail/__init__.py | 2 +- .../components/google_sheets/__init__.py | 2 +- .../components/homeassistant/__init__.py | 2 +- .../components/homematicip_cloud/services.py | 2 +- homeassistant/components/isy994/services.py | 4 +-- .../components/rest_command/__init__.py | 2 +- homeassistant/core.py | 26 +++++++++++++++++++ homeassistant/helpers/service.py | 2 +- tests/components/homeassistant/test_init.py | 2 +- tests/test_core.py | 26 ++++++++++++++++++- 12 files changed, 62 insertions(+), 12 deletions(-) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 57989849367..4c5b3a2380b 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -152,7 +152,7 @@ class NotifyAuthModule(MultiFactorAuthModule): """Return list of notify services.""" unordered_services = set() - for service in self.hass.services.async_services().get("notify", {}): + for service in self.hass.services.async_services_for_domain("notify"): if service not in self._exclude: unordered_services.add(service) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 24b71dd0180..f77931b8d89 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -97,7 +97,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) conversation.async_unset_agent(hass, entry) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 96639e4a547..311af064fcd 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -64,7 +64,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) return unload_ok diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 590c7bd0c90..ba2a0884e22 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -81,7 +81,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.state == ConfigEntryState.LOADED ] if len(loaded_entries) == 1: - for service_name in hass.services.async_services()[DOMAIN]: + for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) return True diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 0a5649ba26b..02a86150ff0 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -344,7 +344,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no f"configuration is not valid: {errors}" ) - services = hass.services.async_services() + services = hass.services.async_services_internal() tasks = [ hass.services.async_call( domain, SERVICE_RELOAD, context=call.context, blocking=True diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 09457ce0792..38ce6de7caf 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -110,7 +110,7 @@ SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( async def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" - if hass.services.async_services().get(HMIPC_DOMAIN): + if hass.services.async_services_for_domain(HMIPC_DOMAIN): return @verify_domain_control(hass, HMIPC_DOMAIN) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index fec6c141915..a6adfcfb917 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -132,7 +132,7 @@ def async_get_entities(hass: HomeAssistant) -> dict[str, Entity]: @callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Create and register services for the ISY integration.""" - existing_services = hass.services.async_services().get(DOMAIN) + existing_services = hass.services.async_services_for_domain(DOMAIN) if existing_services and SERVICE_SEND_PROGRAM_COMMAND in existing_services: # Integration-level services have already been added. Return. return @@ -234,7 +234,7 @@ def async_unload_services(hass: HomeAssistant) -> None: # There is still another config entry for this domain, don't remove services. return - existing_services = hass.services.async_services().get(DOMAIN) + existing_services = hass.services.async_services_for_domain(DOMAIN) if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 0c055fe0000..c99df16170b 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -79,7 +79,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if conf is None: return - existing = hass.services.async_services().get(DOMAIN, {}) + existing = hass.services.async_services_for_domain(DOMAIN) for existing_service in existing: if existing_service == SERVICE_RELOAD: continue diff --git a/homeassistant/core.py b/homeassistant/core.py index fed3e34159a..4c59e88e840 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2019,10 +2019,36 @@ class ServiceRegistry: def async_services(self) -> dict[str, dict[str, Service]]: """Return dictionary with per domain a list of available services. + This method makes a copy of the registry. This function is expensive, + and should only be used if has_service is not sufficient. + This method must be run in the event loop. """ return {domain: service.copy() for domain, service in self._services.items()} + @callback + def async_services_for_domain(self, domain: str) -> dict[str, Service]: + """Return dictionary with per domain a list of available services. + + This method makes a copy of the registry for the domain. + + This method must be run in the event loop. + """ + return self._services.get(domain, {}).copy() + + @callback + def async_services_internal(self) -> dict[str, dict[str, Service]]: + """Return dictionary with per domain a list of available services. + + This method DOES NOT make a copy of the services like async_services does. + It is only expected to be called from the Home Assistant internals + as a performance optimization when the caller is not going to modify the + returned data. + + This method must be run in the event loop. + """ + return self._services + def has_service(self, domain: str, service: str) -> bool: """Test if specified service exists. diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f00e80f43d8..5a9786eb0fa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -585,7 +585,7 @@ async def async_get_all_descriptions( # We don't mutate services here so we avoid calling # async_services which makes a copy of every services # dict. - services = hass.services._services # pylint: disable=protected-access + services = hass.services.async_services_internal() # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 22b380a3249..be29f3a3032 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -256,7 +256,7 @@ async def test_turn_on_skips_domains_without_service( "turn_on", {"entity_id": ["light.test", "sensor.bla", "binary_sensor.blub", "light.bla"]}, ) - service = hass.services._services["homeassistant"]["turn_on"] + service = hass.services.async_services_for_domain("homeassistant")["turn_on"] with patch( "homeassistant.core.ServiceRegistry.async_call", diff --git a/tests/test_core.py b/tests/test_core.py index 01eb4c517b1..3fef994b8e8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1269,7 +1269,7 @@ def test_service_call_repr() -> None: ) -async def test_serviceregistry_has_service(hass: HomeAssistant) -> None: +async def test_service_registry_has_service(hass: HomeAssistant) -> None: """Test has_service method.""" hass.services.async_register("test_domain", "test_service", lambda call: None) assert len(hass.services.async_services()) == 1 @@ -1278,6 +1278,30 @@ async def test_serviceregistry_has_service(hass: HomeAssistant) -> None: assert not hass.services.has_service("non_existing", "test_service") +async def test_service_registry_service_enumeration(hass: HomeAssistant) -> None: + """Test enumerating services methods.""" + hass.services.async_register("test_domain", "test_service", lambda call: None) + services1 = hass.services.async_services() + services2 = hass.services.async_services() + assert len(services1) == 1 + assert services1 == services2 + assert services1 is not services2 # should be a copy + + services1 = hass.services.async_services_internal() + services2 = hass.services.async_services_internal() + assert len(services1) == 1 + assert services1 == services2 + assert services1 is services2 # should be the same object + + assert hass.services.async_services_for_domain("unknown") == {} + + services1 = hass.services.async_services_for_domain("test_domain") + services2 = hass.services.async_services_for_domain("test_domain") + assert len(services1) == 1 + assert services1 == services2 + assert services1 is not services2 # should be a copy + + async def test_serviceregistry_call_with_blocking_done_in_time( hass: HomeAssistant, ) -> None: From 6fb86f179a34252b3ff027b395951130e9cc5a9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 19:09:33 -1000 Subject: [PATCH 0936/1544] Use new config entry update/abort helper in bond (#108690) Use new config entry update/abort helper in bond uses the new helper from https://github.com/home-assistant/core/pull/108034 --- homeassistant/components/bond/config_flow.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index da8e6781bfa..26b485127f2 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -105,7 +105,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) - hass = self.hass for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue @@ -114,13 +113,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): token := await async_get_token(self.hass, host) ): updates[CONF_ACCESS_TOKEN] = token - new_data = {**entry.data, **updates} - changed = new_data != dict(entry.data) - if changed: - hass.config_entries.async_update_entry(entry, data=new_data) - entry_id = entry.entry_id - hass.async_create_task(hass.config_entries.async_reload(entry_id)) - raise AbortFlow("already_configured") + return self.async_update_reload_and_abort( + entry, data={**entry.data, **updates}, reason="already_configured" + ) self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} await self._async_try_automatic_configure() From 4358c24edda3f567e18ea88d7aceb3434e3b43bb Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Tue, 23 Jan 2024 00:32:42 -0500 Subject: [PATCH 0937/1544] Add zeroconf to TechnoVE integration (#108340) * Add zeroconf to TechnoVE integration * Update homeassistant/components/technove/config_flow.py Co-authored-by: Teemu R. * Update zeroconf test to test if update is called. When a station is already configured and it is re-discovered through zeroconf, make sure we don't call its API for nothing. --- .../components/technove/config_flow.py | 51 +++++- .../components/technove/manifest.json | 3 +- .../components/technove/strings.json | 4 + homeassistant/generated/zeroconf.py | 5 + tests/components/technove/conftest.py | 10 ++ tests/components/technove/test_config_flow.py | 168 +++++++++++++++++- 6 files changed, 236 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index a08d3030018..d85fd0ad152 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -5,8 +5,9 @@ from typing import Any from technove import Station as TechnoVEStation, TechnoVE, TechnoVEConnectionError import voluptuous as vol +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,6 +17,10 @@ from .const import DOMAIN class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for TechnoVE.""" + VERSION = 1 + discovered_host: str + discovered_station: TechnoVEStation + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -44,6 +49,50 @@ class TechnoVEConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + # Abort quick if the device with provided mac is already configured + if mac := discovery_info.properties.get(CONF_MAC): + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.host} + ) + + self.discovered_host = discovery_info.host + try: + self.discovered_station = await self._async_get_station(discovery_info.host) + except TechnoVEConnectionError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(self.discovered_station.info.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.context.update( + { + "title_placeholders": {"name": self.discovered_station.info.name}, + } + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry( + title=self.discovered_station.info.name, + data={ + CONF_HOST: self.discovered_host, + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.discovered_station.info.name}, + ) + async def _async_get_station(self, host: str) -> TechnoVEStation: """Get information from a TechnoVE station.""" api = TechnoVE(host, session=async_get_clientsession(self.hass)) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index c5177d047f9..50b1c1394e7 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.1.1"] + "requirements": ["python-technove==1.1.1"], + "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 98813fd3cc8..39a86ad29f8 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -10,6 +10,10 @@ "data_description": { "host": "Hostname or IP address of your TechnoVE station." } + }, + "zeroconf_confirm": { + "description": "Do you want to add the TechnoVE Station named `{name}` to Home Assistant?", + "title": "Discovered TechnoVE station" } }, "error": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 21d44317161..58728cd19d3 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -705,6 +705,11 @@ ZEROCONF = { "domain": "system_bridge", }, ], + "_technove-stations._tcp.local.": [ + { + "domain": "technove", + }, + ], "_touch-able._tcp.local.": [ { "domain": "apple_tv", diff --git a/tests/components/technove/conftest.py b/tests/components/technove/conftest.py index 03ee9fd9663..b3921f865dc 100644 --- a/tests/components/technove/conftest.py +++ b/tests/components/technove/conftest.py @@ -31,6 +31,16 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup +@pytest.fixture +def mock_onboarding() -> Generator[MagicMock, None, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def device_fixture() -> TechnoVEStation: """Return the device fixture for a specific device.""" diff --git a/tests/components/technove/test_config_flow.py b/tests/components/technove/test_config_flow.py index 7a631580ff4..72b9b358c89 100644 --- a/tests/components/technove/test_config_flow.py +++ b/tests/components/technove/test_config_flow.py @@ -1,13 +1,15 @@ """Tests for the TechnoVE config flow.""" -from unittest.mock import MagicMock +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock import pytest from technove import TechnoVEConnectionError +from homeassistant.components import zeroconf from homeassistant.components.technove.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -102,3 +104,163 @@ async def test_full_user_flow_with_error( assert result["data"][CONF_HOST] == "192.168.1.123" assert "result" in result assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_technove") +async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + assert result.get("description_placeholders") == {CONF_NAME: "TechnoVE Station"} + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2.get("title") == "TechnoVE Station" + assert result2.get("type") == FlowResultType.CREATE_ENTRY + + assert "data" in result2 + assert result2["data"][CONF_HOST] == "192.168.1.123" + assert "result" in result2 + assert result2["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test we create a config entry when discovered during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + assert result.get("title") == "TechnoVE Station" + assert result.get("type") == FlowResultType.CREATE_ENTRY + + assert result.get("data") == {CONF_HOST: "192.168.1.123"} + assert "result" in result + assert result["result"].unique_id == "AA:AA:AA:AA:AA:BB" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, mock_technove: MagicMock +) -> None: + """Test we abort zeroconf flow on TechnoVE connection error.""" + mock_technove.update.side_effect = TechnoVEConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +@pytest.mark.usefixtures("mock_technove") +async def test_user_station_exists_abort( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_without_mac_station_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={}, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.usefixtures("mock_technove") +async def test_zeroconf_with_mac_station_exists_abort( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_technove: MagicMock +) -> None: + """Test we abort zeroconf flow if TechnoVE station already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "AA:AA:AA:AA:AA:BB"}, + type="mock_type", + ), + ) + + mock_technove.update.assert_not_called() + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" From 60149e9b9e02ca8bdea78bb5cddce2c352f972ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 20:21:44 -1000 Subject: [PATCH 0938/1544] Add OUI 5C628B to tplink (#108699) Seen on 530E(US) --- homeassistant/components/tplink/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 5791e429d71..587b46bd96d 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -176,6 +176,10 @@ "hostname": "l5*", "macaddress": "5CE931*" }, + { + "hostname": "l5*", + "macaddress": "5C628B*" + }, { "hostname": "p1*", "macaddress": "482254*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index a63c814d598..dc428f639a7 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -813,6 +813,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "l5*", "macaddress": "5CE931*", }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "5C628B*", + }, { "domain": "tplink", "hostname": "p1*", From 904032e9448ac07866a4979fc7d6edcceb7631cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 20:46:47 -1000 Subject: [PATCH 0939/1544] Bump habluetooth to 2.4.0 (#108695) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v2.3.1...v2.4.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1951e3b15ea..a0a61c14e8a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.3.1" + "habluetooth==2.4.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 111f5f5b0ab..4b84c9a81c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.3.1 +habluetooth==2.4.0 hass-nabucasa==0.75.1 hassil==1.5.2 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index eb268a5b02e..a8693c59d7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.3.1 +habluetooth==2.4.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9abd5becfe5..ce584dce903 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -812,7 +812,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.3.1 +habluetooth==2.4.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 From 2eea658fd8c1b7d1bcac3d7c65299e7b1a11f4a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 20:51:33 -1000 Subject: [PATCH 0940/1544] Convert getting and removing access tokens to normal functions (#108670) --- homeassistant/auth/__init__.py | 26 +++++----- homeassistant/auth/auth_store.py | 13 +++-- homeassistant/components/auth/__init__.py | 37 ++++++------- homeassistant/components/http/auth.py | 4 +- homeassistant/components/onboarding/views.py | 2 +- .../components/websocket_api/auth.py | 4 +- tests/auth/test_init.py | 52 +++++++++---------- tests/components/api/test_init.py | 6 +-- tests/components/auth/test_init.py | 34 +++++------- tests/components/config/test_auth.py | 2 +- tests/components/http/test_auth.py | 24 ++++----- tests/components/onboarding/test_views.py | 10 ++-- tests/components/websocket_api/test_auth.py | 6 +-- .../components/websocket_api/test_commands.py | 2 +- 14 files changed, 98 insertions(+), 124 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 0e9a2429fe4..0194be10ba9 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -458,23 +458,22 @@ class AuthManager: credential, ) - async def async_get_refresh_token( - self, token_id: str - ) -> models.RefreshToken | None: + @callback + def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: """Get refresh token by id.""" - return await self._store.async_get_refresh_token(token_id) + return self._store.async_get_refresh_token(token_id) - async def async_get_refresh_token_by_token( + @callback + def async_get_refresh_token_by_token( self, token: str ) -> models.RefreshToken | None: """Get refresh token by token.""" - return await self._store.async_get_refresh_token_by_token(token) + return self._store.async_get_refresh_token_by_token(token) - async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken - ) -> None: + @callback + def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: """Delete a refresh token.""" - await self._store.async_remove_refresh_token(refresh_token) + self._store.async_remove_refresh_token(refresh_token) callbacks = self._revoke_callbacks.pop(refresh_token.id, ()) for revoke_callback in callbacks: @@ -554,16 +553,15 @@ class AuthManager: if provider := self._async_resolve_provider(refresh_token): provider.async_validate_refresh_token(refresh_token, remote_ip) - async def async_validate_access_token( - self, token: str - ) -> models.RefreshToken | None: + @callback + def async_validate_access_token(self, token: str) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None - refresh_token = await self.async_get_refresh_token( + refresh_token = self.async_get_refresh_token( cast(str, unverif_claims.get("iss")) ) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 5de5d087a65..6d63f9bfd50 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -207,18 +207,16 @@ class AuthStore: self._async_schedule_save() return refresh_token - async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken - ) -> None: + @callback + def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: """Remove a refresh token.""" for user in self._users.values(): if user.refresh_tokens.pop(refresh_token.id, None): self._async_schedule_save() break - async def async_get_refresh_token( - self, token_id: str - ) -> models.RefreshToken | None: + @callback + def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: """Get refresh token by id.""" for user in self._users.values(): refresh_token = user.refresh_tokens.get(token_id) @@ -227,7 +225,8 @@ class AuthStore: return None - async def async_get_refresh_token_by_token( + @callback + def async_get_refresh_token_by_token( self, token: str ) -> models.RefreshToken | None: """Get refresh token by token.""" diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 78a1383012d..f4a59f13486 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -124,7 +124,6 @@ as part of a config flow. """ from __future__ import annotations -import asyncio from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus @@ -220,12 +219,12 @@ class RevokeTokenView(HomeAssistantView): if (token := data.get("token")) is None: return web.Response(status=HTTPStatus.OK) - refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + refresh_token = hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: return web.Response(status=HTTPStatus.OK) - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) return web.Response(status=HTTPStatus.OK) @@ -355,7 +354,7 @@ class TokenView(HomeAssistantView): {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST ) - refresh_token = await hass.auth.async_get_refresh_token_by_token(token) + refresh_token = hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: return self.json( @@ -597,7 +596,7 @@ async def websocket_delete_refresh_token( connection.send_error(msg["id"], "invalid_token_id", "Received invalid token") return - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) connection.send_result(msg["id"], {}) @@ -613,28 +612,23 @@ async def websocket_delete_all_refresh_tokens( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete all refresh tokens request.""" - tasks = [] current_refresh_token: RefreshToken - for token in connection.user.refresh_tokens.values(): + remove_failed = False + for token in list(connection.user.refresh_tokens.values()): if token.id == connection.refresh_token_id: # Skip the current refresh token as it has revoke_callback, # which cancels/closes the connection. # It will be removed after sending the result. current_refresh_token = token continue - tasks.append( - hass.async_create_task(hass.auth.async_remove_refresh_token(token)) - ) - - remove_failed = False - if tasks: - for result in await asyncio.gather(*tasks, return_exceptions=True): - if isinstance(result, Exception): - getLogger(__name__).exception( - "During refresh token removal, the following error occurred: %s", - result, - ) - remove_failed = True + try: + hass.auth.async_remove_refresh_token(token) + except Exception as err: # pylint: disable=broad-except + getLogger(__name__).exception( + "During refresh token removal, the following error occurred: %s", + err, + ) + remove_failed = True if remove_failed: connection.send_error( @@ -643,7 +637,8 @@ async def websocket_delete_all_refresh_tokens( else: connection.send_result(msg["id"], {}) - hass.async_create_task(hass.auth.async_remove_refresh_token(current_refresh_token)) + # This will close the connection so we need to send the result first. + hass.loop.call_soon(hass.auth.async_remove_refresh_token, current_refresh_token) @websocket_api.websocket_command( diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 618bab91f7f..98d17637f89 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -151,7 +151,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if auth_type != "Bearer": return False - refresh_token = await hass.auth.async_validate_access_token(auth_val) + refresh_token = hass.auth.async_validate_access_token(auth_val) if refresh_token is None: return False @@ -189,7 +189,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["params"] != params: return False - refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) + refresh_token = hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: return False diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index c403bcd5ab2..e1edfa82a62 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -259,7 +259,7 @@ class IntegrationOnboardingView(_BaseOnboardingView): "invalid client id or redirect uri", HTTPStatus.BAD_REQUEST ) - refresh_token = await hass.auth.async_get_refresh_token(refresh_token_id) + refresh_token = hass.auth.async_get_refresh_token(refresh_token_id) if refresh_token is None or refresh_token.credential is None: return self.json_message( "Credentials for user not available", HTTPStatus.FORBIDDEN diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index 176b561f583..3940e1333d0 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -80,9 +80,7 @@ class AuthPhase: raise Disconnect from err if (access_token := valid_msg.get("access_token")) and ( - refresh_token := await self._hass.auth.async_validate_access_token( - access_token - ) + refresh_token := self._hass.auth.async_validate_access_token(access_token) ): conn = ActiveConnection( self._logger, diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 53c4c680700..5e08f5e3aeb 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -371,7 +371,7 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None assert refresh_token.client_id == CLIENT_ID access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is refresh_token + assert manager.async_validate_access_token(access_token) is refresh_token # We patch time directly here because we want the access token to be created with # an expired time, but we do not want to freeze time so that jwt will compare it @@ -385,7 +385,7 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None ): access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_generating_system_user(hass: HomeAssistant) -> None: @@ -572,10 +572,10 @@ async def test_remove_refresh_token(mock_hass) -> None: refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) access_token = manager.async_create_access_token(refresh_token) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) - assert await manager.async_get_refresh_token(refresh_token.id) is None - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_get_refresh_token(refresh_token.id) is None + assert manager.async_validate_access_token(access_token) is None async def test_register_revoke_token_callback(mock_hass) -> None: @@ -591,7 +591,7 @@ async def test_register_revoke_token_callback(mock_hass) -> None: called = True manager.async_register_revoke_token_callback(refresh_token.id, cb) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert called @@ -610,7 +610,7 @@ async def test_unregister_revoke_token_callback(mock_hass) -> None: unregister = manager.async_register_revoke_token_callback(refresh_token.id, cb) unregister() - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert not called @@ -664,7 +664,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: access_token = manager.async_create_access_token(refresh_token) jwt_key = refresh_token.jwt_key - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id with pytest.raises(ValueError): @@ -675,9 +675,9 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: access_token_expiration=timedelta(days=3000), ) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) assert refresh_token.id not in user.refresh_tokens - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt is None, "Previous issued access token has been invoked" refresh_token_2 = await manager.async_create_refresh_token( @@ -694,7 +694,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: assert access_token != access_token_2 assert jwt_key != jwt_key_2 - rt = await manager.async_validate_access_token(access_token_2) + rt = manager.async_validate_access_token(access_token_2) jwt_payload = jwt.decode(access_token_2, rt.jwt_key, algorithms=["HS256"]) assert jwt_payload["iss"] == refresh_token_2.id assert ( @@ -1144,7 +1144,7 @@ async def test_access_token_with_invalid_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we corrupt the signature @@ -1154,7 +1154,7 @@ async def test_access_token_with_invalid_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1171,7 +1171,7 @@ async def test_access_token_with_null_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we make the signature all nulls @@ -1181,7 +1181,7 @@ async def test_access_token_with_null_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1198,7 +1198,7 @@ async def test_access_token_with_empty_signature(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id # Now we make the signature all nulls @@ -1207,7 +1207,7 @@ async def test_access_token_with_empty_signature(mock_hass) -> None: assert access_token != invalid_token - result = await manager.async_validate_access_token(invalid_token) + result = manager.async_validate_access_token(invalid_token) assert result is None @@ -1225,17 +1225,17 @@ async def test_access_token_with_empty_key(mock_hass) -> None: access_token = manager.async_create_access_token(refresh_token) - await manager.async_remove_refresh_token(refresh_token) + manager.async_remove_refresh_token(refresh_token) # Now remove the token from the keyring # so we will get an empty key - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_reject_access_token_with_impossible_large_size(mock_hass) -> None: """Test rejecting access tokens with impossible sizes.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token("a" * 10000) is None + assert manager.async_validate_access_token("a" * 10000) is None async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: @@ -1245,7 +1245,7 @@ async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: b"invalid", b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token(token_with_invalid_json) is None + assert manager.async_validate_access_token(token_with_invalid_json) is None async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: @@ -1255,7 +1255,7 @@ async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: b'["invalid"]', b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) - assert await manager.async_validate_access_token(token_not_a_dict_json) is None + assert manager.async_validate_access_token(token_not_a_dict_json) is None async def test_access_token_that_expires_soon(mock_hass) -> None: @@ -1272,11 +1272,11 @@ async def test_access_token_that_expires_soon(mock_hass) -> None: assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN access_token = manager.async_create_access_token(refresh_token) - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id with freeze_time(now + timedelta(minutes=1)): - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None async def test_access_token_from_the_future(mock_hass) -> None: @@ -1296,8 +1296,8 @@ async def test_access_token_from_the_future(mock_hass) -> None: ) access_token = manager.async_create_access_token(refresh_token) - assert await manager.async_validate_access_token(access_token) is None + assert manager.async_validate_access_token(access_token) is None with freeze_time(now + timedelta(days=365)): - rt = await manager.async_validate_access_token(access_token) + rt = manager.async_validate_access_token(access_token) assert rt.id == refresh_token.id diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 0d6f2498c79..49da0078229 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -588,7 +588,7 @@ async def test_api_fire_event_context( ) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(test_value) == 1 assert test_value[0].context.user_id == refresh_token.user.id @@ -606,7 +606,7 @@ async def test_api_call_service_context( ) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(calls) == 1 assert calls[0].context.user_id == refresh_token.user.id @@ -622,7 +622,7 @@ async def test_api_set_state_context( headers={"authorization": f"Bearer {hass_access_token}"}, ) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) state = hass.states.get("light.kitchen") assert state.context.user_id == refresh_token.user.id diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 4088b1819fa..666ee4cac07 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -88,9 +88,7 @@ async def test_login_new_user_and_trying_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None assert tokens["ha_auth_provider"] == "insecure_example" # Use refresh token to get more tokens. @@ -106,9 +104,7 @@ async def test_login_new_user_and_trying_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() assert "refresh_token" not in tokens - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Test using access token to hit API. resp = await client.get("/api/") @@ -205,7 +201,7 @@ async def test_ws_current_user( """Test the current user command with Home Assistant creds.""" assert await async_setup_component(hass, "auth", {}) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) user = refresh_token.user client = await hass_ws_client(hass, hass_access_token) @@ -275,9 +271,7 @@ async def test_refresh_token_system_generated( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None async def test_refresh_token_different_client_id( @@ -323,9 +317,7 @@ async def test_refresh_token_different_client_id( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None async def test_refresh_token_checks_local_only_user( @@ -406,16 +398,14 @@ async def test_revoking_refresh_token( assert resp.status == HTTPStatus.OK tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Revoke refresh token resp = await client.post(url, data={**base_data, "token": refresh_token.token}) assert resp.status == HTTPStatus.OK # Old access token should be no longer valid - assert await hass.auth.async_validate_access_token(tokens["access_token"]) is None + assert hass.auth.async_validate_access_token(tokens["access_token"]) is None # Test that we no longer can create an access token resp = await client.post( @@ -454,7 +444,7 @@ async def test_ws_long_lived_access_token( long_lived_access_token = result["result"] assert long_lived_access_token is not None - refresh_token = await hass.auth.async_validate_access_token(long_lived_access_token) + refresh_token = hass.auth.async_validate_access_token(long_lived_access_token) assert refresh_token.client_id is None assert refresh_token.client_name == "GPS Logger" assert refresh_token.client_icon is None @@ -474,7 +464,7 @@ async def test_ws_refresh_tokens( assert result["success"], result assert len(result["result"]) == 1 token = result["result"][0] - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert token["id"] == refresh_token.id assert token["type"] == refresh_token.token_type assert token["client_id"] == refresh_token.client_id @@ -514,7 +504,7 @@ async def test_ws_delete_refresh_token( result = await ws_client.receive_json() assert result["success"], result - refresh_token = await hass.auth.async_get_refresh_token(refresh_token.id) + refresh_token = hass.auth.async_get_refresh_token(refresh_token.id) assert refresh_token is None @@ -573,7 +563,7 @@ async def test_ws_delete_all_refresh_tokens_error( ) in caplog.record_tuples for token in tokens: - refresh_token = await hass.auth.async_get_refresh_token(token["id"]) + refresh_token = hass.auth.async_get_refresh_token(token["id"]) assert refresh_token is None @@ -614,7 +604,7 @@ async def test_ws_delete_all_refresh_tokens( result = await ws_client.receive_json() assert result, result["success"] for token in tokens: - refresh_token = await hass.auth.async_get_refresh_token(token["id"]) + refresh_token = hass.auth.async_get_refresh_token(token["id"]) assert refresh_token is None diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 16d89ec08f5..c85b9ba3b0f 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -136,7 +136,7 @@ async def test_delete_unable_self_account( ) -> None: """Test we cannot delete our own account.""" client = await hass_ws_client(hass, hass_access_token) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) await client.send_json( {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": refresh_token.user.id} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 2f1259c22de..ab56dca5580 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -211,7 +211,7 @@ async def test_auth_active_access_with_access_token_in_header( token = hass_access_token await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.OK @@ -231,7 +231,7 @@ async def test_auth_active_access_with_access_token_in_header( req = await client.get("/", headers={"Authorization": f"BEARER {token}"}) assert req.status == HTTPStatus.UNAUTHORIZED - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.UNAUTHORIZED @@ -297,7 +297,7 @@ async def test_auth_access_signed_path_with_refresh_token( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -325,7 +325,7 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED # refresh token gone should also invalidate signature - await hass.auth.async_remove_refresh_token(refresh_token) + hass.auth.async_remove_refresh_token(refresh_token) req = await client.get(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED @@ -342,7 +342,7 @@ async def test_auth_access_signed_path_with_query_param( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -372,7 +372,7 @@ async def test_auth_access_signed_path_with_query_param_order( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, @@ -413,7 +413,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, @@ -452,7 +452,7 @@ async def test_auth_access_signed_path_with_query_param_tamper( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) signed_path = async_sign_path( hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id @@ -491,9 +491,7 @@ async def test_auth_access_signed_path_via_websocket( assert msg["id"] == 5 assert msg["success"] - refresh_token = await hass.auth.async_validate_access_token( - hass_read_only_access_token - ) + refresh_token = hass.auth.async_validate_access_token(hass_read_only_access_token) signature = yarl.URL(msg["result"]["path"]).query["authSig"] claims = jwt.decode( signature, @@ -523,7 +521,7 @@ async def test_auth_access_signed_path_with_http( await async_setup_auth(hass, app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get( "/hello", headers={"Authorization": f"Bearer {hass_access_token}"} @@ -567,7 +565,7 @@ async def test_local_only_user_rejected( await async_setup_auth(hass, app) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) assert req.status == HTTPStatus.OK diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 47568a7d760..b23f693b230 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -232,9 +232,7 @@ async def test_onboarding_user( assert resp.status == 200 tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Validate created areas assert len(area_registry.areas) == 3 @@ -347,9 +345,7 @@ async def test_onboarding_integration( assert const.STEP_INTEGRATION in hass_storage[const.DOMAIN]["data"]["done"] tokens = await resp.json() - assert ( - await hass.auth.async_validate_access_token(tokens["access_token"]) is not None - ) + assert hass.auth.async_validate_access_token(tokens["access_token"]) is not None # Onboarding refresh token and new refresh token user = await hass.auth.async_get_user(hass_admin_user.id) @@ -368,7 +364,7 @@ async def test_onboarding_integration_missing_credential( assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.credential = None client = await hass_client() diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index d5ff879de78..7c5a34a755a 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -134,7 +134,7 @@ async def test_auth_active_user_inactive( hass_access_token: str, ) -> None: """Test authenticating with a token.""" - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) refresh_token.user.is_active = False assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() @@ -216,8 +216,8 @@ async def test_auth_close_after_revoke( """Test that a websocket is closed after the refresh token is revoked.""" assert not websocket_client.closed - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - await hass.auth.async_remove_refresh_token(refresh_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + hass.auth.async_remove_refresh_token(refresh_token) msg = await websocket_client.receive() assert msg.type == aiohttp.WSMsgType.CLOSE diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 68e2e14a08c..9db74b9a857 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -775,7 +775,7 @@ async def test_call_service_context_with_user( msg = await ws.receive_json() assert msg["success"] - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + refresh_token = hass.auth.async_validate_access_token(hass_access_token) assert len(calls) == 1 call = calls[0] From 329eca49185034a722f91881568de53b9e8a1971 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 Jan 2024 08:14:28 +0100 Subject: [PATCH 0941/1544] Store area registry entries in a UserDict (#108656) * Store area registry entries in a UserDict * Address review comments --- homeassistant/helpers/area_registry.py | 92 +++++++++++++++++--------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 95f889281fc..b3da01114d3 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -1,8 +1,8 @@ """Provide a way to connect devices to one physical location.""" from __future__ import annotations -from collections import OrderedDict -from collections.abc import Iterable, MutableMapping +from collections import UserDict +from collections.abc import Iterable, ValuesView import dataclasses from typing import Any, Literal, TypedDict, cast @@ -39,6 +39,52 @@ class AreaEntry: picture: str | None +class AreaRegistryItems(UserDict[str, AreaEntry]): + """Container for area registry items, maps area id -> entry. + + Maintains an additional index: + - normalized name -> entry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._normalized_names: dict[str, AreaEntry] = {} + + def values(self) -> ValuesView[AreaEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: AreaEntry) -> None: + """Add an item.""" + data = self.data + normalized_name = normalize_area_name(entry.name) + + if key in data: + old_entry = data[key] + if ( + normalized_name != old_entry.normalized_name + and normalized_name in self._normalized_names + ): + raise ValueError( + f"The name {entry.name} ({normalized_name}) is already in use" + ) + del self._normalized_names[old_entry.normalized_name] + data[key] = entry + self._normalized_names[normalized_name] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + normalized_name = normalize_area_name(entry.name) + del self._normalized_names[normalized_name] + super().__delitem__(key) + + def get_area_by_name(self, name: str) -> AreaEntry | None: + """Get area by name.""" + return self._normalized_names.get(normalize_area_name(name)) + + class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): """Store area registry data.""" @@ -69,10 +115,12 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): class AreaRegistry: """Class to hold a registry of areas.""" + areas: AreaRegistryItems + _area_data: dict[str, AreaEntry] + def __init__(self, hass: HomeAssistant) -> None: """Initialize the area registry.""" self.hass = hass - self.areas: MutableMapping[str, AreaEntry] = {} self._store = AreaRegistryStore( hass, STORAGE_VERSION_MAJOR, @@ -80,20 +128,20 @@ class AreaRegistry: atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, ) - self._normalized_name_area_idx: dict[str, str] = {} @callback def async_get_area(self, area_id: str) -> AreaEntry | None: - """Get area by id.""" - return self.areas.get(area_id) + """Get area by id. + + We retrieve the DeviceEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._area_data.get(area_id) @callback def async_get_area_by_name(self, name: str) -> AreaEntry | None: """Get area by name.""" - normalized_name = normalize_area_name(name) - if normalized_name not in self._normalized_name_area_idx: - return None - return self.areas[self._normalized_name_area_idx[normalized_name]] + return self.areas.get_area_by_name(name) @callback def async_list_areas(self) -> Iterable[AreaEntry]: @@ -131,7 +179,6 @@ class AreaRegistry: ) assert area.id is not None self.areas[area.id] = area - self._normalized_name_area_idx[normalized_name] = area.id self.async_schedule_save() self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "create", "area_id": area.id} @@ -141,14 +188,12 @@ class AreaRegistry: @callback def async_delete(self, area_id: str) -> None: """Delete area.""" - area = self.areas[area_id] device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) entity_registry.async_clear_area_id(area_id) del self.areas[area_id] - del self._normalized_name_area_idx[area.normalized_name] self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "remove", "area_id": area_id} @@ -195,29 +240,14 @@ class AreaRegistry: if value is not UNDEFINED and value != getattr(old, attr_name): new_values[attr_name] = value - normalized_name = None - if name is not UNDEFINED and name != old.name: - normalized_name = normalize_area_name(name) - - if normalized_name != old.normalized_name and self.async_get_area_by_name( - name - ): - raise ValueError( - f"The name {name} ({normalized_name}) is already in use" - ) - new_values["name"] = name - new_values["normalized_name"] = normalized_name + new_values["normalized_name"] = normalize_area_name(name) if not new_values: return old new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] - if normalized_name is not None: - self._normalized_name_area_idx[ - normalized_name - ] = self._normalized_name_area_idx.pop(old.normalized_name) self.async_schedule_save() return new @@ -226,7 +256,7 @@ class AreaRegistry: """Load the area registry.""" data = await self._store.async_load() - areas: MutableMapping[str, AreaEntry] = OrderedDict() + areas = AreaRegistryItems() if data is not None: for area in data["areas"]: @@ -239,9 +269,9 @@ class AreaRegistry: normalized_name=normalized_name, picture=area["picture"], ) - self._normalized_name_area_idx[normalized_name] = area["id"] self.areas = areas + self._area_data = areas.data @callback def async_schedule_save(self) -> None: From f9a4840ce215e7b726dd7d405d530fb89738aeb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 23 Jan 2024 08:16:51 +0100 Subject: [PATCH 0942/1544] Group sensor calculate attributes (#106972) * Group sensor calculate attributes * Use entity helpers * Fix sensor tests * Test change of uom * Add tests and fix UoM issue * Fix test * Fix state class * repair and logs * delete issues * pass through hass * Update descriotion text to be more descriptive * Comments * Add pr to comment * fix if in updating * Fix test valid units * Fix strings * Fix issues --- .../components/group/binary_sensor.py | 2 +- homeassistant/components/group/config_flow.py | 6 +- homeassistant/components/group/cover.py | 2 +- homeassistant/components/group/event.py | 2 +- homeassistant/components/group/fan.py | 4 +- homeassistant/components/group/light.py | 2 +- homeassistant/components/group/lock.py | 4 +- .../components/group/media_player.py | 2 +- homeassistant/components/group/sensor.py | 288 +++++++++++++----- homeassistant/components/group/strings.json | 18 ++ homeassistant/components/group/switch.py | 2 +- tests/components/group/test_sensor.py | 179 +++++++++-- 12 files changed, 416 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index d1e91db8f86..d63dcb5e8f2 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -89,7 +89,7 @@ async def async_setup_entry( @callback def async_create_preview_binary_sensor( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> BinarySensorGroup: """Create a preview sensor.""" return BinarySensorGroup( diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 93160b0db5b..488f5e131f3 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -269,7 +269,7 @@ PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} CREATE_PREVIEW_ENTITY: dict[ str, - Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup], + Callable[[HomeAssistant, str, dict[str, Any]], GroupEntity | MediaPlayerGroup], ] = { "binary_sensor": async_create_preview_binary_sensor, "cover": async_create_preview_cover, @@ -392,7 +392,9 @@ def ws_start_preview( ) ) - preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) + preview_entity: GroupEntity | MediaPlayerGroup = CREATE_PREVIEW_ENTITY[group_type]( + hass, name, validated + ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index d22184c0922..78d29378076 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -97,7 +97,7 @@ async def async_setup_entry( @callback def async_create_preview_cover( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> CoverGroup: """Create a preview sensor.""" return CoverGroup( diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index ca0c88867fe..b98991e13fc 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -90,7 +90,7 @@ async def async_setup_entry( @callback def async_create_preview_event( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> EventGroup: """Create a preview sensor.""" return EventGroup( diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4e3bb824266..afd240c5767 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -91,7 +91,9 @@ async def async_setup_entry( @callback -def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup: +def async_create_preview_fan( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> FanGroup: """Create a preview sensor.""" return FanGroup( None, diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 3c1ad7f0d57..5a113491891 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -112,7 +112,7 @@ async def async_setup_entry( @callback def async_create_preview_light( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> LightGroup: """Create a preview sensor.""" return LightGroup( diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 5558eab5475..4a6fdc3e2ed 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -91,7 +91,9 @@ async def async_setup_entry( @callback -def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup: +def async_create_preview_lock( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> LockGroup: """Create a preview sensor.""" return LockGroup( None, diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index b85fbf32a0d..aa38f364d93 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -109,7 +109,7 @@ async def async_setup_entry( @callback def async_create_preview_media_player( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> MediaPlayerGroup: """Create a preview sensor.""" return MediaPlayerGroup( diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index c35c96d38aa..84827ef89fa 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, + UNIT_CONVERTERS, SensorDeviceClass, SensorEntity, SensorStateClass, @@ -34,11 +35,22 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity import ( + get_capability, + get_device_class, + get_unit_of_measurement, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import GroupEntity +from . import DOMAIN as GROUP_DOMAIN, GroupEntity from .const import CONF_IGNORE_NON_NUMERIC DEFAULT_NAME = "Sensor Group" @@ -97,6 +109,7 @@ async def async_setup_platform( async_add_entities( [ SensorGroup( + hass, config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES], @@ -123,6 +136,7 @@ async def async_setup_entry( async_add_entities( [ SensorGroup( + hass, config_entry.entry_id, config_entry.title, entities, @@ -138,10 +152,11 @@ async def async_setup_entry( @callback def async_create_preview_sensor( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> SensorGroup: """Create a preview sensor.""" return SensorGroup( + hass, None, name, validated_config[CONF_ENTITIES], @@ -280,6 +295,7 @@ class SensorGroup(GroupEntity, SensorEntity): def __init__( self, + hass: HomeAssistant, unique_id: str | None, name: str, entity_ids: list[str], @@ -290,14 +306,13 @@ class SensorGroup(GroupEntity, SensorEntity): device_class: SensorDeviceClass | None, ) -> None: """Initialize a sensor group.""" + self.hass = hass self._entity_ids = entity_ids self._sensor_type = sensor_type - self._attr_state_class = state_class - self.calc_state_class: SensorStateClass | None = None - self._attr_device_class = device_class - self.calc_device_class: SensorDeviceClass | None = None - self._attr_native_unit_of_measurement = unit_of_measurement - self.calc_unit_of_measurement: str | None = None + self._state_class = state_class + self._device_class = device_class + self._native_unit_of_measurement = unit_of_measurement + self._valid_units: set[str | None] = set() self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() @@ -311,6 +326,16 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} + async def async_added_to_hass(self) -> None: + """When added to hass.""" + self._attr_state_class = self._calculate_state_class(self._state_class) + self._attr_device_class = self._calculate_device_class(self._device_class) + self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement( + self._native_unit_of_measurement + ) + self._valid_units = self._get_valid_units() + await super().async_added_to_hass() + @callback def async_update_group_state(self) -> None: """Query all members and determine the sensor group state.""" @@ -321,7 +346,16 @@ class SensorGroup(GroupEntity, SensorEntity): if (state := self.hass.states.get(entity_id)) is not None: states.append(state.state) try: - sensor_values.append((entity_id, float(state.state), state)) + numeric_state = float(state.state) + if ( + self._valid_units + and (uom := state.attributes["unit_of_measurement"]) + in self._valid_units + ): + numeric_state = UNIT_CONVERTERS[self.device_class].convert( + numeric_state, uom, self.native_unit_of_measurement + ) + sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) except ValueError: @@ -330,9 +364,29 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect.add(entity_id) _LOGGER.warning( "Unable to use state. Only numerical states are supported," - " entity %s with value %s excluded from calculation", + " entity %s with value %s excluded from calculation in %s", entity_id, state.state, + self.entity_id, + ) + continue + except (KeyError, HomeAssistantError): + # This exception handling can be simplified + # once sensor entity doesn't allow incorrect unit of measurement + # with a device class, implementation see PR #107639 + valid_states.append(False) + if entity_id not in self._state_incorrect: + self._state_incorrect.add(entity_id) + _LOGGER.warning( + "Unable to use state. Only entities with correct unit of measurement" + " is supported when having a device class," + " entity %s, value %s with device class %s" + " and unit of measurement %s excluded from calculation in %s", + entity_id, + state.state, + self.device_class, + state.attributes.get("unit_of_measurement"), + self.entity_id, ) continue valid_states.append(True) @@ -350,7 +404,6 @@ class SensorGroup(GroupEntity, SensorEntity): return # Calculate values - self._calculate_entity_properties() self._extra_state_attribute, self._attr_native_value = self._state_calc( sensor_values ) @@ -360,13 +413,6 @@ class SensorGroup(GroupEntity, SensorEntity): """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property - def device_class(self) -> SensorDeviceClass | None: - """Return device class.""" - if self._attr_device_class is not None: - return self._attr_device_class - return self.calc_device_class - @property def icon(self) -> str | None: """Return the icon. @@ -377,59 +423,165 @@ class SensorGroup(GroupEntity, SensorEntity): return "mdi:calculator" return None - @property - def state_class(self) -> SensorStateClass | str | None: - """Return state class.""" - if self._attr_state_class is not None: - return self._attr_state_class - return self.calc_state_class - - @property - def native_unit_of_measurement(self) -> str | None: - """Return native unit of measurement.""" - if self._attr_native_unit_of_measurement is not None: - return self._attr_native_unit_of_measurement - return self.calc_unit_of_measurement - - def _calculate_entity_properties(self) -> None: - """Calculate device_class, state_class and unit of measurement.""" - device_classes = [] - state_classes = [] - unit_of_measurements = [] - - if ( - self._attr_device_class - and self._attr_state_class - and self._attr_native_unit_of_measurement - ): - return + def _calculate_state_class( + self, state_class: SensorStateClass | None + ) -> SensorStateClass | None: + """Calculate state class. + If user has configured a state class we will use that. + If a state class is not set then test if same state class + on source entities and use that. + Otherwise return no state class. + """ + if state_class: + return state_class + state_classes: list[SensorStateClass] = [] for entity_id in self._entity_ids: - if (state := self.hass.states.get(entity_id)) is not None: - device_classes.append(state.attributes.get("device_class")) - state_classes.append(state.attributes.get("state_class")) - unit_of_measurements.append(state.attributes.get("unit_of_measurement")) + try: + _state_class = get_capability(self.hass, entity_id, "state_class") + except HomeAssistantError: + return None + if not _state_class: + return None + state_classes.append(_state_class) - self.calc_device_class = None - self.calc_state_class = None - self.calc_unit_of_measurement = None + if all(x == state_classes[0] for x in state_classes): + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_state_classes_not_matching" + ) + return state_classes[0] + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_state_classes_not_matching", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="state_classes_not_matching", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "state_classes:": ", ".join(state_classes), + }, + ) + return None - # Calculate properties and save if all same - if ( - not self._attr_device_class - and device_classes - and all(x == device_classes[0] for x in device_classes) + def _calculate_device_class( + self, device_class: SensorDeviceClass | None + ) -> SensorDeviceClass | None: + """Calculate device class. + + If user has configured a device class we will use that. + If a device class is not set then test if same device class + on source entities and use that. + Otherwise return no device class. + """ + if device_class: + return device_class + device_classes: list[SensorDeviceClass] = [] + for entity_id in self._entity_ids: + try: + _device_class = get_device_class(self.hass, entity_id) + except HomeAssistantError: + return None + if not _device_class: + return None + device_classes.append(SensorDeviceClass(_device_class)) + + if all(x == device_classes[0] for x in device_classes): + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_device_classes_not_matching" + ) + return device_classes[0] + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_device_classes_not_matching", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="device_classes_not_matching", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "device_classes:": ", ".join(device_classes), + }, + ) + return None + + def _calculate_unit_of_measurement( + self, unit_of_measurement: str | None + ) -> str | None: + """Calculate the unit of measurement. + + If user has configured a unit of measurement we will use that. + If a device class is set then test if unit of measurements are compatible. + If no device class or uom's not compatible we will use no unit of measurement. + """ + if unit_of_measurement: + return unit_of_measurement + + unit_of_measurements: list[str] = [] + for entity_id in self._entity_ids: + try: + _unit_of_measurement = get_unit_of_measurement(self.hass, entity_id) + except HomeAssistantError: + return None + if not _unit_of_measurement: + return None + unit_of_measurements.append(_unit_of_measurement) + + # Ensure only valid unit of measurements for the specific device class can be used + if (device_class := self.device_class) in UNIT_CONVERTERS and all( + x in UNIT_CONVERTERS[device_class].VALID_UNITS for x in unit_of_measurements ): - self.calc_device_class = device_classes[0] + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class" + ) + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class" + ) + return unit_of_measurements[0] + if device_class: + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_uoms_not_matching_device_class", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="uoms_not_matching_device_class", + translation_placeholders={ + "entity_id": self.entity_id, + "device_class": device_class, + "source_entities": ", ".join(self._entity_ids), + "uoms:": ", ".join(unit_of_measurements), + }, + ) + else: + async_create_issue( + self.hass, + GROUP_DOMAIN, + f"{self.entity_id}_uoms_not_matching_no_device_class", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="uoms_not_matching_no_device_class", + translation_placeholders={ + "entity_id": self.entity_id, + "source_entities": ", ".join(self._entity_ids), + "uoms:": ", ".join(unit_of_measurements), + }, + ) + return None + + def _get_valid_units(self) -> set[str | None]: + """Return valid units. + + If device class is set and compatible unit of measurements. + """ if ( - not self._attr_state_class - and state_classes - and all(x == state_classes[0] for x in state_classes) - ): - self.calc_state_class = state_classes[0] - if ( - not self._attr_unit_of_measurement - and unit_of_measurements - and all(x == unit_of_measurements[0] for x in unit_of_measurements) - ): - self.calc_unit_of_measurement = unit_of_measurements[0] + device_class := self.device_class + ) in UNIT_CONVERTERS and self.native_unit_of_measurement: + return UNIT_CONVERTERS[device_class].VALID_UNITS + return set() diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index c5cebbc4707..25ae20da995 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -249,5 +249,23 @@ } } } + }, + "issues": { + "uoms_not_matching_device_class": { + "title": "Unit of measurements are not correct", + "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue." + }, + "uoms_not_matching_no_device_class": { + "title": "Unit of measurements is not correct", + "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible using no device class of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue." + }, + "device_classes_not_matching": { + "title": "Device classes is not correct", + "description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue." + }, + "state_classes_not_matching": { + "title": "State classes is not correct", + "description": "Device classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." + } } } diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 64bc9a99636..3f68d7125aa 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -87,7 +87,7 @@ async def async_setup_entry( @callback def async_create_preview_switch( - name: str, validated_config: dict[str, Any] + hass: HomeAssistant, name: str, validated_config: dict[str, Any] ) -> SwitchGroup: """Create a preview sensor.""" return SwitchGroup( diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 71a53042938..12bb8d0f7de 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -62,7 +63,7 @@ PRODUCT_VALUE = prod(VALUES) ("product", PRODUCT_VALUE, {}), ], ) -async def test_sensors( +async def test_sensors2( hass: HomeAssistant, entity_registry: er.EntityRegistry, sensor_type: str, @@ -88,7 +89,7 @@ async def test_sensors( value, { ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIT_OF_MEASUREMENT: "L", }, ) @@ -105,7 +106,7 @@ async def test_sensors( assert state.attributes.get(key) == value assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" entity = entity_registry.async_get(f"sensor.sensor_group_{sensor_type}") @@ -146,7 +147,8 @@ async def test_sensors_attributes_defined(hass: HomeAssistant) -> None: state = hass.states.get("sensor.sensor_group_sum") - assert state.state == str(float(SUM_VALUE)) + # Liter to M3 = 1:0.001 + assert state.state == str(float(SUM_VALUE * 0.001)) assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -324,9 +326,6 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - entity_ids = config["sensor"]["entities"] hass.states.async_set( @@ -334,7 +333,7 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: VALUES[0], { "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.MEASUREMENT, + "state_class": SensorStateClass.TOTAL, "unit_of_measurement": "kWh", }, ) @@ -343,35 +342,181 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: VALUES[1], { "device_class": SensorDeviceClass.ENERGY, - "state_class": SensorStateClass.MEASUREMENT, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "Wh", + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(float(sum([VALUES[0], VALUES[1], VALUES[2] / 1000]))) + assert state.attributes.get("device_class") == "energy" + assert state.attributes.get("state_class") == "total" + assert state.attributes.get("unit_of_measurement") == "kWh" + + # Test that a change of source entity's unit of measurement + # is converted correctly by the group sensor + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, "unit_of_measurement": "kWh", }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test_sum") - assert state.state == str(float(sum([VALUES[0], VALUES[1]]))) - assert state.attributes.get("device_class") == "energy" - assert state.attributes.get("state_class") == "measurement" - assert state.attributes.get("unit_of_measurement") == "kWh" + assert state.state == str(float(sum(VALUES))) + +async def test_sensor_calculated_properties_not_same( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the sensor calculating device_class, state_class and unit of measurement not same.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) hass.states.async_set( entity_ids[2], VALUES[2], { - "device_class": SensorDeviceClass.BATTERY, - "state_class": SensorStateClass.TOTAL, - "unit_of_measurement": None, + "device_class": SensorDeviceClass.CURRENT, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": "A", }, ) await hass.async_block_till_done() + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_sum") - assert state.state == str(sum(VALUES)) + assert state.state == str(float(sum(VALUES))) assert state.attributes.get("device_class") is None assert state.attributes.get("state_class") is None assert state.attributes.get("unit_of_measurement") is None + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_uoms_not_matching_no_device_class" + ) + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_device_classes_not_matching" + ) + assert issue_registry.async_get_issue( + GROUP_DOMAIN, "sensor.test_sum_state_classes_not_matching" + ) + + +async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> None: + """Test the sensor calculating fails as UoM not part of device class.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(float(sum(VALUES))) + assert state.attributes.get("device_class") == "energy" + assert state.attributes.get("state_class") == "total" + assert state.attributes.get("unit_of_measurement") == "kWh" + + hass.states.async_set( + entity_ids[2], + 12, + { + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL, + }, + True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("device_class") == "energy" + assert state.attributes.get("state_class") == "total" + assert state.attributes.get("unit_of_measurement") == "kWh" + async def test_last_sensor(hass: HomeAssistant) -> None: """Test the last sensor.""" From acd07b4826db321c47d65617fe95c075b4adbd16 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Tue, 23 Jan 2024 08:56:11 +0100 Subject: [PATCH 0943/1544] Fix for justnimbus integration (#99212) * Fix for justnimbus integration * Fixed tests and moved const * fix: added reauth flow * fix: fixed reauth config flow * chore: added config_flow reauth test * chore: Processed PR feedback --------- Co-authored-by: G Johansson --- .../components/justnimbus/__init__.py | 6 +- .../components/justnimbus/config_flow.py | 34 ++++++-- homeassistant/components/justnimbus/const.py | 5 +- .../components/justnimbus/coordinator.py | 6 +- .../components/justnimbus/manifest.json | 2 +- homeassistant/components/justnimbus/sensor.py | 86 +++++-------------- .../components/justnimbus/strings.json | 39 +++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/justnimbus/conftest.py | 8 ++ .../components/justnimbus/test_config_flow.py | 63 ++++++++++---- tests/components/justnimbus/test_init.py | 21 +++++ 12 files changed, 153 insertions(+), 121 deletions(-) create mode 100644 tests/components/justnimbus/conftest.py create mode 100644 tests/components/justnimbus/test_init.py diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 695faa4f529..c30e213814e 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN, PLATFORMS from .coordinator import JustNimbusCoordinator @@ -10,7 +11,10 @@ from .coordinator import JustNimbusCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JustNimbus from a config entry.""" - coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + if "zip_code" in entry.data: + coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + else: + raise ConfigEntryAuthFailed() await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/justnimbus/config_flow.py b/homeassistant/components/justnimbus/config_flow.py index bb55b1852b8..536943ef607 100644 --- a/homeassistant/components/justnimbus/config_flow.py +++ b/homeassistant/components/justnimbus/config_flow.py @@ -1,6 +1,7 @@ """Config flow for JustNimbus integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,13 +13,14 @@ from homeassistant.const import CONF_CLIENT_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from .const import DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ZIP_CODE): cv.string, }, ) @@ -27,6 +29,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for JustNimbus.""" VERSION = 1 + reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -39,10 +42,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} - await self.async_set_unique_id(user_input[CONF_CLIENT_ID]) - self._abort_if_unique_id_configured() + unique_id = f"{user_input[CONF_CLIENT_ID]}{user_input[CONF_ZIP_CODE]}" + await self.async_set_unique_id(unique_id=unique_id) + if not self.reauth_entry: + self._abort_if_unique_id_configured() - client = justnimbus.JustNimbusClient(client_id=user_input[CONF_CLIENT_ID]) + client = justnimbus.JustNimbusClient( + client_id=user_input[CONF_CLIENT_ID], zip_code=user_input[CONF_ZIP_CODE] + ) try: await self.hass.async_add_executor_job(client.get_data) except justnimbus.InvalidClientID: @@ -53,8 +60,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title="JustNimbus", data=user_input) + if not self.reauth_entry: + return self.async_create_entry(title="JustNimbus", data=user_input) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=user_input, unique_id=unique_id + ) + + # Reload the config entry otherwise devices will remain unavailable + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() diff --git a/homeassistant/components/justnimbus/const.py b/homeassistant/components/justnimbus/const.py index cf3d4ef825f..11a4ae487c4 100644 --- a/homeassistant/components/justnimbus/const.py +++ b/homeassistant/components/justnimbus/const.py @@ -1,13 +1,14 @@ """Constants for the JustNimbus integration.""" + from typing import Final from homeassistant.const import Platform DOMAIN = "justnimbus" -VOLUME_FLOW_RATE_LITERS_PER_MINUTE: Final = "L/min" - PLATFORMS = [ Platform.SENSOR, ] + +CONF_ZIP_CODE: Final = "zip_code" diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index 606cea0e922..9dc7dcbc743 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_CLIENT_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,9 @@ class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): name=DOMAIN, update_interval=timedelta(minutes=1), ) - self._client = justnimbus.JustNimbusClient(client_id=entry.data[CONF_CLIENT_ID]) + self._client = justnimbus.JustNimbusClient( + client_id=entry.data[CONF_CLIENT_ID], zip_code=entry.data[CONF_ZIP_CODE] + ) async def _async_update_data(self) -> justnimbus.JustNimbusModel: """Fetch the latest data from the source.""" diff --git a/homeassistant/components/justnimbus/manifest.json b/homeassistant/components/justnimbus/manifest.json index 76c5060376b..26cbc80e166 100644 --- a/homeassistant/components/justnimbus/manifest.json +++ b/homeassistant/components/justnimbus/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/justnimbus", "iot_class": "cloud_polling", - "requirements": ["justnimbus==0.6.0"] + "requirements": ["justnimbus==0.7.3"] } diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index cb428fa5eea..14b89b6c2c1 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( EntityCategory, UnitOfPressure, UnitOfTemperature, - UnitOfTime, UnitOfVolume, ) from homeassistant.core import HomeAssistant @@ -25,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import JustNimbusCoordinator -from .const import DOMAIN, VOLUME_FLOW_RATE_LITERS_PER_MINUTE +from .const import DOMAIN from .entity import JustNimbusEntity @@ -44,54 +43,20 @@ class JustNimbusEntityDescription( SENSOR_TYPES = ( - JustNimbusEntityDescription( - key="pump_flow", - translation_key="pump_flow", - icon="mdi:pump", - native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_flow, - ), - JustNimbusEntityDescription( - key="drink_flow", - translation_key="drink_flow", - icon="mdi:water-pump", - native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.drink_flow, - ), JustNimbusEntityDescription( key="pump_pressure", translation_key="pump_pressure", + icon="mdi:water-pump", native_unit_of_measurement=UnitOfPressure.BAR, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: coordinator.data.pump_pressure, ), - JustNimbusEntityDescription( - key="pump_starts", - translation_key="pump_starts", - icon="mdi:restart", - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_starts, - ), - JustNimbusEntityDescription( - key="pump_hours", - translation_key="pump_hours", - icon="mdi:clock", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.pump_hours, - ), JustNimbusEntityDescription( key="reservoir_temp", translation_key="reservoir_temperature", + icon="mdi:coolant-temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -104,57 +69,46 @@ SENSOR_TYPES = ( icon="mdi:car-coolant-level", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda coordinator: coordinator.data.reservoir_content, ), JustNimbusEntityDescription( - key="total_saved", - translation_key="total_saved", + key="water_saved", + translation_key="water_saved", icon="mdi:water-opacity", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.total_saved, + value_fn=lambda coordinator: coordinator.data.water_saved, ), JustNimbusEntityDescription( - key="total_replenished", - translation_key="total_replenished", - icon="mdi:water", - native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.total_replenished, - ), - JustNimbusEntityDescription( - key="error_code", - translation_key="error_code", - icon="mdi:bug", - entity_registry_enabled_default=False, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.error_code, - ), - JustNimbusEntityDescription( - key="totver", - translation_key="total_use", + key="water_used", + translation_key="water_used", icon="mdi:chart-donut", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.totver, + value_fn=lambda coordinator: coordinator.data.water_used, ), JustNimbusEntityDescription( - key="reservoir_content_max", - translation_key="reservoir_content_max", + key="reservoir_capacity", + translation_key="reservoir_capacity", icon="mdi:waves", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda coordinator: coordinator.data.reservoir_content_max, + value_fn=lambda coordinator: coordinator.data.reservoir_capacity, + ), + JustNimbusEntityDescription( + key="pump_type", + translation_key="pump_type", + icon="mdi:pump", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coordinator: coordinator.data.pump_type, ), ) diff --git a/homeassistant/components/justnimbus/strings.json b/homeassistant/components/justnimbus/strings.json index 92ebf19714a..bb9d0a44ebe 100644 --- a/homeassistant/components/justnimbus/strings.json +++ b/homeassistant/components/justnimbus/strings.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "client_id": "Client ID" + "client_id": "Client ID", + "zip_code": "ZIP code" } } }, @@ -13,46 +14,32 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { "sensor": { - "pump_flow": { - "name": "Pump flow" - }, - "drink_flow": { - "name": "Drink flow" - }, "pump_pressure": { "name": "Pump pressure" }, - "pump_starts": { - "name": "Pump starts" + "pump_type": { + "name": "Pump type" }, - "pump_hours": { - "name": "Pump hours" - }, - "reservoir_temperature": { - "name": "Reservoir temperature" + "reservoir_capacity": { + "name": "Reservoir capacity" }, "reservoir_content": { "name": "Reservoir content" }, - "total_saved": { - "name": "Total saved" + "reservoir_temperature": { + "name": "Reservoir temperature" }, - "total_replenished": { - "name": "Total replenished" - }, - "error_code": { - "name": "Error code" - }, - "total_use": { + "water_used": { "name": "Total use" }, - "reservoir_content_max": { - "name": "Maximum reservoir content" + "water_saved": { + "name": "Total saved" } } } diff --git a/requirements_all.txt b/requirements_all.txt index a8693c59d7e..48cf6be7193 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1147,7 +1147,7 @@ jellyfin-apiclient-python==1.9.2 jsonpath==0.82.2 # homeassistant.components.justnimbus -justnimbus==0.6.0 +justnimbus==0.7.3 # homeassistant.components.kaiterra kaiterra-async-client==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce584dce903..a0f1ade9ee0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -919,7 +919,7 @@ jellyfin-apiclient-python==1.9.2 jsonpath==0.82.2 # homeassistant.components.justnimbus -justnimbus==0.6.0 +justnimbus==0.7.3 # homeassistant.components.kegtron kegtron-ble==0.4.0 diff --git a/tests/components/justnimbus/conftest.py b/tests/components/justnimbus/conftest.py new file mode 100644 index 00000000000..c67f9470a1f --- /dev/null +++ b/tests/components/justnimbus/conftest.py @@ -0,0 +1,8 @@ +"""Reusable fixtures for justnimbus tests.""" + +from homeassistant.components.justnimbus.const import CONF_ZIP_CODE +from homeassistant.const import CONF_CLIENT_ID + +FIXTURE_OLD_USER_INPUT = {CONF_CLIENT_ID: "test_id"} +FIXTURE_USER_INPUT = {CONF_CLIENT_ID: "test_id", CONF_ZIP_CODE: "test_zip"} +FIXTURE_UNIQUE_ID = "test_idtest_zip" diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index 2c8d41929df..8db8dd09b23 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -4,12 +4,13 @@ from unittest.mock import patch from justnimbus.exceptions import InvalidClientID, JustNimbusError import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.justnimbus.const import DOMAIN -from homeassistant.const import CONF_CLIENT_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import FIXTURE_OLD_USER_INPUT, FIXTURE_UNIQUE_ID, FIXTURE_USER_INPUT + from tests.common import MockConfigEntry @@ -57,9 +58,7 @@ async def test_form_errors( ): result2 = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) assert result2["type"] == FlowResultType.FORM @@ -73,8 +72,8 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, title="JustNimbus", - data={CONF_CLIENT_ID: "test_id"}, - unique_id="test_id", + data=FIXTURE_USER_INPUT, + unique_id=FIXTURE_UNIQUE_ID, ) entry.add_to_hass(hass) @@ -86,9 +85,7 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) assert result2.get("type") == FlowResultType.ABORT @@ -103,15 +100,49 @@ async def _set_up_justnimbus(hass: HomeAssistant, flow_id: str) -> None: ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( flow_id=flow_id, - user_input={ - CONF_CLIENT_ID: "test_id", - }, + user_input=FIXTURE_USER_INPUT, ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "JustNimbus" - assert result2["data"] == { - CONF_CLIENT_ID: "test_id", - } + assert result2["data"] == FIXTURE_USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + with patch( + "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", + return_value=False, + ): + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=FIXTURE_UNIQUE_ID, data=FIXTURE_OLD_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config.entry_id, + }, + data=FIXTURE_OLD_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config.data == FIXTURE_USER_INPUT diff --git a/tests/components/justnimbus/test_init.py b/tests/components/justnimbus/test_init.py new file mode 100644 index 00000000000..223e36d2bbc --- /dev/null +++ b/tests/components/justnimbus/test_init.py @@ -0,0 +1,21 @@ +"""Tests for JustNimbus initialization.""" +from homeassistant.components.justnimbus.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import FIXTURE_OLD_USER_INPUT, FIXTURE_UNIQUE_ID + +from tests.common import MockConfigEntry + + +async def test_config_entry_reauth_at_setup(hass: HomeAssistant) -> None: + """Test that setting up with old config results in reauth.""" + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=FIXTURE_UNIQUE_ID, data=FIXTURE_OLD_USER_INPUT + ) + mock_config.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert mock_config.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config.async_get_active_flows(hass, {"reauth"})) From 52ede95c4f489ff686bd43026fdb2ad9cc376846 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 09:08:03 +0100 Subject: [PATCH 0944/1544] Scrub internal data for newer tplink devices (#108704) --- homeassistant/components/tplink/diagnostics.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index 65646e8b858..c1b0cf12bfc 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -29,6 +29,14 @@ TO_REDACT = { "longitude_i", # Cloud connectivity info "username", + # SMART devices + "device_id", + "hw_id", + "fw_id", + "oem_id", + "ssid", + "nickname", + "ip", } From d9f1450ee64e6f927ae6826e34cd04be42c084ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 23 Jan 2024 10:32:31 +0100 Subject: [PATCH 0945/1544] Add Homeassistant Analytics Insights integration (#107634) * Add Homeassistant Analytics integration * Add Homeassistant Analytics integration * Add Homeassistant Analytics integration * Fix feedback * Fix test * Update conftest.py * Add some testcases * Make code clear * log exception * Bump python-homeassistant-analytics to 0.2.1 * Bump python-homeassistant-analytics to 0.3.0 * Change domain to homeassistant_analytics_consumer * Add integration name to config flow selector * Update homeassistant/components/homeassistant_analytics_consumer/manifest.json Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Fix hassfest * Apply suggestions from code review Co-authored-by: Robert Resch * Bump python-homeassistant-analytics to 0.4.0 * Rename to Home Assistant Analytics Insights * Update homeassistant/components/analytics_insights/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/analytics_insights/manifest.json Co-authored-by: Robert Resch * Rename to Home Assistant Analytics Insights * add test * Fallback to 0 when there is no data found * Allow to select any integration * Fix tests * Fix tests * Update tests/components/analytics_insights/conftest.py Co-authored-by: Robert Resch * Update tests/components/analytics_insights/test_sensor.py Co-authored-by: Robert Resch * Fix format * Fix tests --------- Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> Co-authored-by: Robert Resch --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/analytics_insights/__init__.py | 58 + .../analytics_insights/config_flow.py | 74 + .../components/analytics_insights/const.py | 8 + .../analytics_insights/coordinator.py | 53 + .../analytics_insights/manifest.json | 11 + .../components/analytics_insights/sensor.py | 62 + .../analytics_insights/strings.json | 18 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/analytics_insights/__init__.py | 11 + .../components/analytics_insights/conftest.py | 54 + .../fixtures/current_data.json | 2516 +++++++++++++++++ .../fixtures/integrations.json | 9 + .../snapshots/test_sensor.ambr | 142 + .../analytics_insights/test_config_flow.py | 70 + .../analytics_insights/test_init.py | 28 + .../analytics_insights/test_sensor.py | 86 + 22 files changed, 3226 insertions(+) create mode 100644 homeassistant/components/analytics_insights/__init__.py create mode 100644 homeassistant/components/analytics_insights/config_flow.py create mode 100644 homeassistant/components/analytics_insights/const.py create mode 100644 homeassistant/components/analytics_insights/coordinator.py create mode 100644 homeassistant/components/analytics_insights/manifest.json create mode 100644 homeassistant/components/analytics_insights/sensor.py create mode 100644 homeassistant/components/analytics_insights/strings.json create mode 100644 tests/components/analytics_insights/__init__.py create mode 100644 tests/components/analytics_insights/conftest.py create mode 100644 tests/components/analytics_insights/fixtures/current_data.json create mode 100644 tests/components/analytics_insights/fixtures/integrations.json create mode 100644 tests/components/analytics_insights/snapshots/test_sensor.ambr create mode 100644 tests/components/analytics_insights/test_config_flow.py create mode 100644 tests/components/analytics_insights/test_init.py create mode 100644 tests/components/analytics_insights/test_sensor.py diff --git a/.strict-typing b/.strict-typing index d528484cc98..8ffb02024c9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -69,6 +69,7 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.analytics_insights.* homeassistant.components.android_ip_webcam.* homeassistant.components.androidtv.* homeassistant.components.androidtv_remote.* diff --git a/CODEOWNERS b/CODEOWNERS index f4a1d72edc0..8ad0c7e5273 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -76,6 +76,8 @@ build.json @home-assistant/supervisor /homeassistant/components/amcrest/ @flacjacket /homeassistant/components/analytics/ @home-assistant/core @ludeeus /tests/components/analytics/ @home-assistant/core @ludeeus +/homeassistant/components/analytics_insights/ @joostlek +/tests/components/analytics_insights/ @joostlek /homeassistant/components/android_ip_webcam/ @engrbm87 /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py new file mode 100644 index 00000000000..2078d9715f4 --- /dev/null +++ b/homeassistant/components/analytics_insights/__init__.py @@ -0,0 +1,58 @@ +"""The Homeassistant Analytics integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from python_homeassistant_analytics import HomeassistantAnalyticsClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN +from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass(frozen=True) +class AnalyticsData: + """Analytics data class.""" + + coordinator: HomeassistantAnalyticsDataUpdateCoordinator + names: dict[str, str] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Homeassistant Analytics from a config entry.""" + client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) + + integrations = await client.get_integrations() + + names = {} + for integration in entry.options[CONF_TRACKED_INTEGRATIONS]: + if integration not in integrations: + names[integration] = integration + continue + names[integration] = integrations[integration].title + + coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsData( + coordinator=coordinator, names=names + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py new file mode 100644 index 00000000000..afa6b2bac38 --- /dev/null +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Homeassistant Analytics integration.""" +from __future__ import annotations + +from typing import Any + +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, +) +from python_homeassistant_analytics.models import IntegrationType +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN, LOGGER + +INTEGRATION_TYPES_WITHOUT_ANALYTICS = ( + IntegrationType.BRAND, + IntegrationType.ENTITY, + IntegrationType.VIRTUAL, +) + + +class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Homeassistant Analytics.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + self._async_abort_entries_match() + if user_input: + return self.async_create_entry( + title="Home Assistant Analytics Insights", data={}, options=user_input + ) + + client = HomeassistantAnalyticsClient( + session=async_get_clientsession(self.hass) + ) + try: + integrations = await client.get_integrations() + except HomeassistantAnalyticsConnectionError: + LOGGER.exception("Error connecting to Home Assistant analytics") + return self.async_abort(reason="cannot_connect") + + options = [ + SelectOptionDict( + value=domain, + label=integration.title, + ) + for domain, integration in integrations.items() + if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=True, + sort=True, + ) + ), + } + ), + ) diff --git a/homeassistant/components/analytics_insights/const.py b/homeassistant/components/analytics_insights/const.py new file mode 100644 index 00000000000..3b9bf01d11e --- /dev/null +++ b/homeassistant/components/analytics_insights/const.py @@ -0,0 +1,8 @@ +"""Constants for the Homeassistant Analytics integration.""" +import logging + +DOMAIN = "analytics_insights" + +CONF_TRACKED_INTEGRATIONS = "tracked_integrations" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py new file mode 100644 index 00000000000..f8eefe7db27 --- /dev/null +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -0,0 +1,53 @@ +"""DataUpdateCoordinator for the Homeassistant Analytics integration.""" +from __future__ import annotations + +from datetime import timedelta + +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, + HomeassistantAnalyticsNotModifiedError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN, LOGGER + + +class HomeassistantAnalyticsDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, int]] +): + """A Homeassistant Analytics Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, client: HomeassistantAnalyticsClient + ) -> None: + """Initialize the Homeassistant Analytics data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=12), + ) + self._client = client + self._tracked_integrations = self.config_entry.options[ + CONF_TRACKED_INTEGRATIONS + ] + + async def _async_update_data(self) -> dict[str, int]: + try: + data = await self._client.get_current_analytics() + except HomeassistantAnalyticsConnectionError as err: + raise UpdateFailed( + "Error communicating with Homeassistant Analytics" + ) from err + except HomeassistantAnalyticsNotModifiedError: + return self.data + return { + integration: data.integrations.get(integration, 0) + for integration in self._tracked_integrations + } diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json new file mode 100644 index 00000000000..0dfb1396a72 --- /dev/null +++ b/homeassistant/components/analytics_insights/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "analytics_insights", + "name": "Home Assistant Analytics Insights", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/analytics_insights", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["python_homeassistant_analytics"], + "requirements": ["python-homeassistant-analytics==0.5.0"] +} diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py new file mode 100644 index 00000000000..f8a8b244c60 --- /dev/null +++ b/homeassistant/components/analytics_insights/sensor.py @@ -0,0 +1,62 @@ +"""Sensor for Home Assistant analytics.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AnalyticsData +from .const import DOMAIN +from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + analytics_data: AnalyticsData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + HomeassistantAnalyticsSensor( + analytics_data.coordinator, + integration_domain, + analytics_data.names[integration_domain], + ) + for integration_domain in analytics_data.coordinator.data + ) + + +class HomeassistantAnalyticsSensor( + CoordinatorEntity[HomeassistantAnalyticsDataUpdateCoordinator], SensorEntity +): + """Home Assistant Analytics Sensor.""" + + _attr_has_entity_name = True + _attr_state_class = SensorStateClass.TOTAL + _attr_native_unit_of_measurement = "active installations" + + def __init__( + self, + coordinator: HomeassistantAnalyticsDataUpdateCoordinator, + integration_domain: str, + name: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_name = name + self._attr_unique_id = f"core_{integration_domain}_active_installations" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, DOMAIN)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._integration_domain = integration_domain + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + return self.coordinator.data.get(self._integration_domain) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json new file mode 100644 index 00000000000..c6890524a6b --- /dev/null +++ b/homeassistant/components/analytics_insights/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "tracked_integrations": "Integrations" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 91a572e1514..5e88b2f9e8a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -42,6 +42,7 @@ FLOWS = { "amberelectric", "ambiclimate", "ambient_station", + "analytics_insights", "android_ip_webcam", "androidtv", "androidtv_remote", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1cb43016efc..bbc5476896a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -255,6 +255,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "analytics_insights": { + "name": "Home Assistant Analytics Insights", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "android_ip_webcam": { "name": "Android IP Webcam", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index f3e3df193d3..68e40b51c50 100644 --- a/mypy.ini +++ b/mypy.ini @@ -450,6 +450,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.analytics_insights.*] +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.android_ip_webcam.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 48cf6be7193..9a02be3602c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2197,6 +2197,9 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.analytics_insights +python-homeassistant-analytics==0.5.0 + # homeassistant.components.homewizard python-homewizard-energy==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0f1ade9ee0..9037316fbae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1673,6 +1673,9 @@ python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 +# homeassistant.components.analytics_insights +python-homeassistant-analytics==0.5.0 + # homeassistant.components.homewizard python-homewizard-energy==4.1.0 diff --git a/tests/components/analytics_insights/__init__.py b/tests/components/analytics_insights/__init__.py new file mode 100644 index 00000000000..9e20a72c438 --- /dev/null +++ b/tests/components/analytics_insights/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Homeassistant Analytics integration.""" +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/analytics_insights/conftest.py b/tests/components/analytics_insights/conftest.py new file mode 100644 index 00000000000..a1a32cb3f74 --- /dev/null +++ b/tests/components/analytics_insights/conftest.py @@ -0,0 +1,54 @@ +"""Common fixtures for the Homeassistant Analytics tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_homeassistant_analytics import CurrentAnalytics +from python_homeassistant_analytics.models import Integration + +from homeassistant.components.analytics_insights import DOMAIN +from homeassistant.components.analytics_insights.const import CONF_TRACKED_INTEGRATIONS + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.analytics_insights.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_analytics_client() -> Generator[AsyncMock, None, None]: + """Mock a Homeassistant Analytics client.""" + with patch( + "homeassistant.components.analytics_insights.HomeassistantAnalyticsClient", + autospec=True, + ) as mock_client, patch( + "homeassistant.components.analytics_insights.config_flow.HomeassistantAnalyticsClient", + new=mock_client, + ): + client = mock_client.return_value + client.get_current_analytics.return_value = CurrentAnalytics.from_json( + load_fixture("analytics_insights/current_data.json") + ) + integrations = load_json_object_fixture("analytics_insights/integrations.json") + client.get_integrations.return_value = { + key: Integration.from_dict(value) for key, value in integrations.items() + } + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Homeassistant Analytics", + data={}, + options={CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify", "myq"]}, + ) diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json new file mode 100644 index 00000000000..c652a8c0154 --- /dev/null +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -0,0 +1,2516 @@ +{ + "last_updated": 1704885791095, + "countries": { + "US": 53255, + "DE": 43792, + "IN": 1135, + "CA": 8212, + "FI": 2859, + "IT": 11088, + "GB": 18165, + "SE": 8389, + "AU": 8996, + "PL": 8283, + "CH": 3845, + "BG": 798, + "NO": 3985, + "NL": 20162, + "CN": 13037, + "FR": 15643, + "RO": 2769, + "GR": 1255, + "GA": 4, + "HU": 2771, + "HK": 1777, + "UA": 2521, + "ES": 8712, + "CZ": 4694, + "BR": 4255, + "SK": 1212, + "RU": 9289, + "PT": 3334, + "ZA": 2642, + "BE": 6386, + "DK": 5059, + "CY": 181, + "AT": 4164, + "IL": 1132, + "IS": 262, + "LU": 281, + "AR": 842, + "BJ": 3, + "KR": 1323, + "PA": 76, + "ID": 772, + "IE": 1205, + "TW": 2187, + "SG": 961, + "BA": 49, + "TH": 1995, + "KZ": 227, + "VN": 998, + "HR": 518, + "BH": 53, + "CU": 18, + "BY": 507, + "MT": 109, + "MX": 1147, + "TR": 904, + "CO": 361, + "LT": 789, + "SI": 718, + "RE": 86, + "LB": 84, + "DO": 126, + "AZ": 36, + "NZ": 1692, + "SA": 274, + "AE": 310, + "JP": 901, + "LK": 61, + "EE": 693, + "HN": 31, + "EC": 117, + "CL": 615, + "LV": 450, + "MY": 639, + "UY": 167, + "BF": 4, + "QA": 47, + "PR": 120, + "SX": 3, + "NG": 80, + "BM": 10, + "MQ": 14, + "NA": 36, + "LY": 11, + "JM": 32, + "EG": 273, + "KY": 6, + "RS": 312, + "NP": 24, + "PK": 91, + "MD": 122, + "JE": 23, + "MK": 57, + "PE": 125, + "TT": 59, + "ZM": 10, + "PY": 60, + "PH": 348, + "IM": 38, + "LS": 4, + "ME": 30, + "TG": 15, + "IR": 66, + "GT": 30, + "MO": 55, + "IQ": 64, + "GI": 6, + "MZ": 15, + "GE": 77, + "CR": 161, + "MM": 24, + "TJ": 37, + "UZ": 85, + "AD": 10, + "AM": 73, + "PF": 12, + "CI": 16, + "KG": 30, + "BQ": 7, + "DZ": 58, + "GG": 17, + "BZ": 4, + "JO": 57, + "MV": 13, + "SV": 17, + "VE": 39, + "YE": 6, + "MA": 143, + "MU": 21, + "OM": 49, + "NC": 29, + "BO": 54, + "XK": 20, + "KW": 38, + "GU": 5, + "BS": 12, + "GP": 27, + "MN": 19, + "ET": 13, + "TN": 60, + "FO": 18, + "ZW": 20, + "KE": 40, + "LI": 17, + "BB": 15, + "KH": 38, + "CW": 26, + "BD": 73, + "SC": 3, + "SL": 1, + "SM": 12, + "GH": 52, + "PS": 20, + "PG": 3, + "AO": 11, + "FJ": 4, + "AX": 17, + "NI": 8, + "AW": 17, + "GD": 2, + "SN": 13, + "LA": 14, + "MG": 8, + "AL": 17, + "GF": 6, + "CG": 2, + "GY": 4, + "SR": 17, + "TC": 1, + "UG": 12, + "GL": 2, + "VC": 3, + "IO": 1, + "TZ": 8, + "RW": 3, + "CV": 7, + "LC": 8, + "YT": 2, + "ML": 2, + "AG": 7, + "MF": 1, + "BN": 12, + "HT": 3, + "VI": 2, + "MW": 4, + "BT": 3, + "WS": 1, + "VG": 3, + "SY": 2, + "BW": 8, + "CM": 4, + "DJ": 1, + "TL": 1, + "SS": 1, + "TM": 1, + "MC": 2, + "T1": 1, + "AI": 1, + "FM": 1, + "CD": 2 + }, + "installation_types": { + "os": 223469, + "container": 52434, + "core": 9752, + "supervised": 15221, + "unsupported_container": 6100, + "unknown": 3424 + }, + "active_installations": 310400, + "avg_users": 1, + "avg_automations": 12, + "avg_integrations": 27, + "avg_addons": 6, + "avg_states": 208, + "integrations": { + "script": 246128, + "cpuspeed": 6523, + "apple_tv": 31246, + "hue": 39499, + "radio_browser": 176070, + "scene": 242257, + "dlna_dmr": 68815, + "google_translate": 129024, + "frontend": 204291, + "knx": 2931, + "hassio": 193035, + "cast": 124532, + "webostv": 28423, + "thread": 56573, + "met": 193200, + "person": 248256, + "openai_conversation": 5151, + "androidtv_remote": 42649, + "sun": 247811, + "mobile_app": 202779, + "default_config": 242787, + "automation": 247261, + "homekit": 46540, + "google": 17436, + "homekit_controller": 32230, + "tuya": 48547, + "xbox": 6322, + "shopping_list": 96171, + "cloud": 88674, + "zone": 90365, + "group": 88150, + "generic": 17347, + "timer": 49307, + "persistent_notification": 27681, + "notify": 58782, + "diagnostics": 24576, + "auth": 27797, + "raspberry_pi": 5212, + "button": 16379, + "recorder": 61879, + "energy": 74107, + "stream": 32220, + "zeroconf": 30200, + "dhcp": 28169, + "input_number": 67186, + "hikvision": 1167, + "samsungtv": 40354, + "lock": 12257, + "repairs": 21514, + "device_automation": 27672, + "websocket_api": 28669, + "siren": 10473, + "input_text": 51877, + "nest": 12686, + "media_player": 32265, + "schedule": 32924, + "number": 15660, + "logger": 41301, + "hardware": 21352, + "stt": 8616, + "image_upload": 16394, + "file_upload": 20736, + "onboarding": 27672, + "search": 27672, + "input_select": 55495, + "conversation": 18537, + "input_button": 45918, + "usb": 27664, + "map": 31265, + "camera": 29502, + "cover": 22675, + "vacuum": 11051, + "ipp": 68927, + "ffmpeg": 21083, + "blueprint": 27578, + "tplink": 23668, + "update": 15896, + "assist_pipeline": 7784, + "counter": 39800, + "weather": 23585, + "input_datetime": 55333, + "media_source": 30841, + "input_boolean": 87498, + "binary_sensor": 50815, + "humidifier": 10005, + "tag": 28172, + "system_log": 29282, + "network": 27897, + "ssdp": 29607, + "alarm_control_panel": 20449, + "system_health": 33296, + "application_credentials": 22649, + "bluetooth_adapters": 8357, + "device_tracker": 33009, + "zwave_js": 24553, + "http": 89834, + "history": 35110, + "climate": 31824, + "sensor": 101798, + "my": 32475, + "light": 36609, + "homeassistant": 108327, + "webhook": 28963, + "trace": 27575, + "bluetooth": 124139, + "fan": 17378, + "upnp": 84171, + "logbook": 35151, + "rpi_power": 86861, + "config": 33096, + "analytics": 27699, + "homeassistant_alerts": 22022, + "lovelace": 37644, + "remote": 8677, + "switch": 50974, + "select": 14321, + "api": 32847, + "google_assistant": 14358, + "tts": 140044, + "co2signal": 24353, + "ibeacon": 46067, + "dlna_dms": 55732, + "accuweather": 11610, + "forecast_solar": 25140, + "sharkiq": 1272, + "derivative": 4282, + "google_assistant_sdk": 5727, + "wyoming": 13699, + "oralb": 7812, + "rhasspy": 1020, + "braviatv": 9300, + "utility_meter": 27735, + "shell_command": 10702, + "ecobee": 7789, + "govee_ble": 3648, + "lifx": 4829, + "sma": 1717, + "esphome": 56656, + "wake_on_lan": 19186, + "ovo_energy": 272, + "generic_thermostat": 3333, + "asuswrt": 2656, + "sonos": 36389, + "homeassistant_sky_connect": 13911, + "local_calendar": 17171, + "nfandroidtv": 2838, + "workday": 12748, + "trafikverket_weatherstation": 440, + "jellyfin": 2079, + "inkbird": 1768, + "nut": 8750, + "mqtt": 108113, + "min_max": 14592, + "smhi": 1917, + "zha": 58308, + "roborock": 7363, + "template": 50756, + "python_script": 7050, + "synology_dsm": 26998, + "shelly": 44653, + "imap": 789, + "spotify": 24388, + "enphase_envoy": 2833, + "ld2410_ble": 935, + "command_line": 10749, + "mjpeg": 6094, + "open_meteo": 2590, + "intent_script": 1526, + "local_todo": 8378, + "ping": 13284, + "deconz": 5495, + "telegram_bot": 14008, + "systemmonitor": 32242, + "fritzbox": 14039, + "fully_kiosk": 6518, + "fritz": 18266, + "androidtv": 8058, + "brother": 19543, + "xiaomi_miio": 16242, + "influxdb": 21696, + "home_connect": 5784, + "edl21": 288, + "text": 5868, + "tasmota": 26322, + "onvif": 9899, + "blink": 5458, + "time_date": 1168, + "panel_custom": 7196, + "wiz": 8421, + "plex": 13197, + "pi_hole": 7098, + "devolo_home_network": 1472, + "syncthru": 2507, + "ring": 13663, + "tado": 8639, + "broadlink": 18804, + "yeelight": 9919, + "goodwe": 1302, + "integration": 13884, + "wled": 17444, + "prometheus": 1447, + "reolink": 12919, + "water_heater": 3028, + "switch_as_x": 36720, + "mvglive": 1, + "coronavirus": 256, + "openweathermap": 21362, + "calendar": 4770, + "smartthings": 10651, + "panel_iframe": 7752, + "version": 8944, + "adguard": 13192, + "unifi": 16589, + "xiaomi_aqara": 2887, + "unifiprotect": 10382, + "speedtestdotnet": 23310, + "twilio": 984, + "radarr": 1962, + "twinkly": 3075, + "sonarr": 2555, + "cloudflare": 1916, + "matter": 17099, + "fronius": 3296, + "doorbird": 979, + "dsmr": 4086, + "moon": 12149, + "roku": 10092, + "airnow": 799, + "abode": 531, + "flux_led": 5932, + "bond": 2105, + "opower": 1282, + "yolink": 990, + "philips_js": 3285, + "cert_expiry": 4618, + "harmony": 10081, + "alexa": 7705, + "rfxtrx": 2359, + "vesync": 3161, + "wemo": 7182, + "ios": 12512, + "xiaomi_ble": 15798, + "lutron_caseta": 5293, + "qingping": 698, + "profiler": 1696, + "ifttt": 7056, + "youtube": 339, + "vizio": 2630, + "kodi": 5174, + "foscam": 1652, + "overkiz": 4492, + "rest_command": 10337, + "yamaha_musiccast": 4436, + "tibber": 5121, + "surepetcare": 838, + "whirlpool": 231, + "freebox": 2558, + "tile": 3222, + "netatmo": 11034, + "github": 3137, + "meteo_france": 3916, + "homewizard": 5398, + "elgato": 1956, + "lastfm": 302, + "history_stats": 865, + "owntracks": 2356, + "backup": 11951, + "aussie_broadband": 506, + "image": 11833, + "buienradar": 6651, + "sql": 4960, + "mikrotik": 1761, + "local_ip": 15081, + "uptime": 12332, + "otbr": 5596, + "octoprint": 9560, + "aemet": 1390, + "melcloud": 2786, + "heos": 6197, + "denonavr": 10899, + "image_processing": 1319, + "panasonic_viera": 1191, + "lacrosse_view": 181, + "starlink": 444, + "google_mail": 762, + "tailscale": 737, + "icloud": 5767, + "nmap_tracker": 5171, + "dnsip": 4586, + "konnected": 1305, + "tesla_wall_connector": 2325, + "roomba": 8552, + "decora_wifi": 622, + "ambient_station": 1249, + "life360": 2360, + "scrape": 2436, + "threshold": 7052, + "august": 4752, + "proximity": 4814, + "srp_energy": 82, + "dialogflow": 623, + "songpal": 1483, + "soundtouch": 2080, + "opnsense": 322, + "lg_soundbar": 362, + "statistics": 2149, + "frontier_silicon": 1612, + "mazda": 66, + "daikin": 3030, + "emulated_hue": 2537, + "tautulli": 1287, + "syncthing": 448, + "amcrest": 1324, + "motioneye": 3538, + "directv": 312, + "rainbird": 704, + "solaredge": 4777, + "filesize": 2163, + "flume": 848, + "discord": 1478, + "sense": 1635, + "waze_travel_time": 5655, + "season": 6327, + "tod": 4741, + "verisure": 1468, + "energyzero": 715, + "hunterdouglas_powerview": 519, + "plant": 1728, + "qnap": 1871, + "metoffice": 1787, + "hdmi_cec": 67, + "sensibo": 2862, + "adax": 473, + "netgear": 2291, + "iss": 1718, + "vlc_telnet": 4141, + "kostal_plenticore": 635, + "solax": 159, + "prusalink": 744, + "econet": 761, + "coinbase": 414, + "hive": 1436, + "bmw_connected_drive": 2445, + "compensation": 171, + "nina": 2515, + "tractive": 688, + "tankerkoenig": 2261, + "nanoleaf": 3266, + "tomorrowio": 1651, + "slimproto": 338, + "squeezebox": 1398, + "schlage": 479, + "switcher_kis": 265, + "switchbot": 6961, + "switchbot_cloud": 1020, + "bayesian": 195, + "pushover": 2445, + "keenetic_ndms2": 1945, + "ourgroceries": 469, + "vicare": 1495, + "thermopro": 639, + "neato": 935, + "roon": 405, + "renault": 1287, + "bthome": 4166, + "nuki": 1974, + "modbus": 4746, + "telegram": 660, + "updater": 3559, + "aladdin_connect": 772, + "deluge": 245, + "opensky": 404, + "airzone_cloud": 120, + "ecovacs": 765, + "nws": 4082, + "alert": 2209, + "media_extractor": 1489, + "openuv": 1917, + "iqvia": 567, + "tradfri": 5548, + "velux": 291, + "growatt_server": 842, + "pvoutput": 441, + "intent": 1357, + "withings": 2143, + "soma": 95, + "ps4": 1727, + "lametric": 680, + "google_sheets": 1171, + "downloader": 1259, + "rest": 7444, + "powerwall": 1163, + "nissan_leaf": 296, + "wallbox": 885, + "glances": 2791, + "hyperion": 1487, + "dwd_weather_warnings": 2788, + "airvisual": 1363, + "dsmr_reader": 989, + "apcupsd": 871, + "sleepiq": 956, + "sabnzbd": 1023, + "steam_online": 1547, + "tellduslive": 864, + "airthings_ble": 1018, + "tellstick": 230, + "airthings": 1308, + "mill": 892, + "yalexs_ble": 953, + "environment_canada": 1702, + "homeassistant_hardware": 310, + "smtp": 233, + "thermobeacon": 614, + "eufylife_ble": 640, + "evohome": 1266, + "meater": 1302, + "notion": 134, + "fibaro": 660, + "fastdotcom": 1076, + "volvooncall": 1228, + "seventeentrack": 854, + "voip": 1332, + "lidarr": 312, + "onewire": 257, + "joaoapps_join": 177, + "zodiac": 496, + "aurora": 977, + "transmission": 1793, + "emulated_roku": 1110, + "asterisk_mbox": 26, + "rtsp_to_webrtc": 2378, + "feedreader": 729, + "google_tasks": 655, + "here_travel_time": 354, + "minecraft_server": 1106, + "gdacs": 1234, + "snmp": 345, + "gpslogger": 618, + "rachio": 3084, + "iaqualink": 328, + "kef": 51, + "fireservicerota": 34, + "gios": 280, + "mysensors": 481, + "airly": 774, + "ezviz": 2849, + "opentherm_gw": 230, + "picnic": 698, + "traccar": 556, + "bluetooth_tracker": 98, + "recollect_waste": 124, + "mqtt_room": 465, + "snapcast": 353, + "incomfort": 106, + "browser": 557, + "rpi_camera": 86, + "qbittorrent": 1307, + "nexia": 475, + "modern_forms": 340, + "fritzbox_callmonitor": 1945, + "google_travel_time": 820, + "discovery": 602, + "bosch_shc": 587, + "gree": 2372, + "waqi": 1539, + "mqtt_json": 5, + "caldav": 515, + "mqtt_statestream": 573, + "islamic_prayer_times": 523, + "todoist": 1150, + "hardkernel": 357, + "honeywell": 1736, + "ruuvitag_ble": 743, + "android_ip_webcam": 920, + "aranet": 308, + "color_extractor": 434, + "youless": 321, + "smappee": 253, + "subaru": 265, + "ukraine_alarm": 555, + "rainforest_eagle": 254, + "homematicip_cloud": 2030, + "pvpc_hourly_pricing": 1229, + "sia": 478, + "mediaroom": 36, + "iotawatt": 446, + "ipma": 636, + "trend": 207, + "uptimerobot": 933, + "huawei_lte": 646, + "mailgun": 106, + "duckdns": 1942, + "onkyo": 145, + "folder_watcher": 651, + "private_ble_device": 414, + "zwave_me": 64, + "somfy": 32, + "slack": 521, + "debugpy": 104, + "vera": 352, + "led_ble": 642, + "pushbullet": 1096, + "rflink": 835, + "weatherflow": 441, + "slide": 109, + "litterrobot": 1349, + "smart_meter_texas": 241, + "stookalert": 443, + "proxmoxve": 1348, + "motion_blinds": 628, + "lupusec": 29, + "ambiclimate": 52, + "keyboard_remote": 77, + "habitica": 94, + "zamg": 443, + "enigma2": 485, + "geo_location": 657, + "universal": 554, + "mystrom": 601, + "holiday": 1155, + "livisi": 231, + "tessie": 122, + "awair": 1025, + "microsoft_face": 1, + "zoneminder": 256, + "nibe_heatpump": 285, + "envisalink": 853, + "danfoss_air": 30, + "ihc": 257, + "nextdns": 500, + "homematic": 1187, + "lyric": 713, + "comfoconnect": 157, + "arris_tg2492lg": 2, + "nextcloud": 1136, + "hydrawise": 588, + "baf": 399, + "manual": 353, + "volumio": 1412, + "blebox": 287, + "mold_indicator": 24, + "remote_rpi_gpio": 36, + "wolflink": 179, + "modem_callerid": 24, + "rdw": 772, + "hisense_aehw4a1": 38, + "velbus": 79, + "yale_smart_alarm": 350, + "sfr_box": 174, + "luftdaten": 735, + "wiffi": 267, + "agent_dvr": 706, + "starline": 299, + "ecoforest": 10, + "nsw_fuel_station": 98, + "nzbget": 320, + "epson": 215, + "ecowitt": 3008, + "alarmdecoder": 118, + "snips": 27, + "notify_events": 423, + "jewish_calendar": 217, + "flux": 30, + "stookwijzer": 191, + "openexchangerates": 326, + "simplisafe": 804, + "trafikverket_camera": 110, + "p1_monitor": 338, + "air_quality": 386, + "totalconnect": 201, + "launch_library": 279, + "random": 180, + "no_ip": 236, + "nuheat": 166, + "tilt_ble": 84, + "satel_integra": 384, + "risco": 276, + "qnap_qsw": 52, + "kraken": 300, + "emby": 595, + "geofency": 313, + "hvv_departures": 70, + "devolo_home_control": 65, + "vulcan": 24, + "laundrify": 151, + "openhome": 730, + "rainmachine": 381, + "sms": 162, + "schluter": 92, + "sensorpro": 35, + "pegel_online": 219, + "worldclock": 30, + "rss_feed_template": 29, + "emulated_kasa": 116, + "obihai": 448, + "filter": 735, + "remember_the_milk": 35, + "tplink_omada": 1033, + "isy994": 415, + "mullvad": 121, + "rapt_ble": 32, + "eufy": 101, + "eafm": 352, + "meteoclimatic": 158, + "prosegur": 92, + "insteon": 1086, + "flipr": 67, + "intesishome": 263, + "twentemilieu": 197, + "brottsplatskartan": 170, + "kitchen_sink": 3, + "demo": 79, + "balboa": 90, + "saj": 76, + "rpi_gpio": 42, + "advantage_air": 266, + "emoncms": 15, + "synology_srm": 7, + "twilio_call": 18, + "intellifire": 34, + "opengarage": 425, + "luci": 36, + "aurora_abb_powerone": 34, + "sensorpush": 104, + "iperf3": 136, + "mutesync": 61, + "google_generative_ai_conversation": 199, + "yamaha": 135, + "solarlog": 177, + "radiotherm": 104, + "easyenergy": 140, + "plugwise": 542, + "aqualogic": 17, + "google_wifi": 43, + "dexcom": 338, + "arcam_fmj": 57, + "gogogate2": 407, + "serial": 21, + "thingspeak": 43, + "watttime": 15, + "axis": 927, + "mpd": 82, + "locative": 179, + "mopeka": 225, + "fitbit": 619, + "pilight": 10, + "anova": 80, + "control4": 137, + "electric_kiwi": 20, + "geonetnz_quakes": 157, + "amberelectric": 336, + "efergy": 286, + "coolmaster": 60, + "mitemp_bt": 24, + "omnilogic": 86, + "tmb": 10, + "trafikverket_train": 77, + "simplepush": 77, + "garmin_connect": 3, + "homeassistant_yellow": 114, + "clicksend": 7, + "flo": 412, + "supla": 109, + "netgear_lte": 64, + "forked_daapd": 293, + "safe_mode": 81, + "airzone": 141, + "somfy_mylink": 108, + "airvisual_pro": 132, + "nam": 58, + "geocaching": 328, + "electrasmart": 25, + "loqed": 211, + "twitch": 127, + "toon": 253, + "system_bridge": 214, + "recswitch": 21, + "met_eireann": 303, + "ziggo_mediabox_xl": 3, + "swiss_public_transport": 123, + "purpleair": 87, + "freedns": 50, + "venstar": 210, + "lightwave": 103, + "kaiterra": 51, + "graphite": 13, + "bluesound": 313, + "ondilo_ico": 153, + "atag": 87, + "generic_hygrostat": 763, + "screenlogic": 309, + "ws66i": 12, + "rituals_perfume_genie": 351, + "maxcube": 200, + "senseme": 9, + "elkm1": 239, + "vallox": 158, + "bsblan": 28, + "google_cloud": 19, + "darksky": 79, + "peco": 57, + "ruckus_unleashed": 97, + "splunk": 54, + "local_file": 71, + "file": 90, + "configurator": 114, + "snooz": 53, + "vodafone_station": 76, + "faa_delays": 143, + "dht": 6, + "egardia": 33, + "oncue": 73, + "tailwind": 44, + "dunehd": 102, + "flick_electric": 42, + "home_plus_control": 85, + "weishaupt_wcm_com": 1, + "sentry": 63, + "linux_battery": 6, + "nmbs": 48, + "linode": 22, + "aseko_pool_live": 7, + "senz": 90, + "whois": 365, + "ness_alarm": 40, + "google_pubsub": 23, + "worxlandroid": 14, + "goalzero": 38, + "canary": 108, + "dlink": 175, + "skybell": 68, + "moat": 4, + "dynalite": 37, + "namecheapdns": 89, + "flic": 131, + "datadog": 22, + "google_maps": 11, + "spc": 46, + "wirelesstag": 132, + "dormakaba_dkey": 8, + "microsoft": 11, + "digital_ocean": 43, + "marytts": 4, + "geniushub": 25, + "lutron": 116, + "keba": 91, + "mqtt_eventstream": 96, + "zeversolar": 88, + "fjaraskupan": 28, + "juicenet": 194, + "niko_home_control": 11, + "ads": 37, + "zabbix": 50, + "eight_sleep": 56, + "nobo_hub": 82, + "flexit_bacnet": 14, + "doods": 18, + "matrix": 213, + "mfi": 17, + "brunt": 39, + "device_sun_light_trigger": 77, + "ohmconnect": 96, + "folder": 19, + "alpha_vantage": 7, + "edimax": 14, + "lcn": 38, + "zwave": 33, + "airq": 51, + "flunearyou": 9, + "moehlenhoff_alpha2": 40, + "aftership": 60, + "thethingsnetwork": 38, + "escea": 17, + "trafikverket_ferry": 12, + "firmata": 84, + "lookin": 33, + "izone": 91, + "enocean": 219, + "geonetnz_volcano": 41, + "miflora": 15, + "airtouch4": 90, + "v2c": 32, + "ruuvi_gateway": 72, + "html5": 58, + "spider": 22, + "repetier": 35, + "idasen_desk": 125, + "melissa": 13, + "xiaomi_tv": 19, + "google_domains": 110, + "geo_json_events": 44, + "pocketcasts": 5, + "landisgyr_heat_meter": 14, + "waterfurnace": 15, + "devialet": 81, + "bluemaestro": 19, + "mochad": 25, + "mailbox": 3, + "acmeda": 29, + "anthemav": 99, + "point": 25, + "lg_netcast": 15, + "lifx_cloud": 4, + "bluetooth_le_tracker": 27, + "uvc": 46, + "ombi": 67, + "mycroft": 5, + "streamlabswater": 18, + "azure_service_bus": 2, + "reddit": 2, + "fixer": 2, + "pure_energie": 59, + "iammeter": 94, + "hlk_sw16": 14, + "foobot": 34, + "bloomsky": 6, + "smarty": 1, + "ialarm": 27, + "time": 14, + "touchline": 3, + "signal_messenger": 10, + "vlc": 7, + "ebusd": 43, + "jvc_projector": 29, + "starlingbank": 3, + "weatherkit": 95, + "openhardwaremonitor": 20, + "rympro": 21, + "gardena_bluetooth": 44, + "swiss_hydrological_data": 69, + "usgs_earthquakes_feed": 9, + "melnor": 42, + "plaato": 45, + "freedompro": 26, + "sunweg": 3, + "logi_circle": 18, + "proxy": 16, + "statsd": 4, + "baidu": 35, + "sensirion_ble": 52, + "manual_mqtt": 11, + "aws": 60, + "qvr_pro": 44, + "amazon_polly": 16, + "duotecno": 5, + "huisbaasje": 43, + "suez_water": 30, + "ridwell": 33, + "switchbee": 3, + "nightscout": 172, + "almond": 29, + "bitcoin": 25, + "smarttub": 37, + "foursquare": 7, + "aosmith": 14, + "discovergy": 91, + "w800rf32": 6, + "opple": 15, + "route53": 72, + "pioneer": 11, + "linksys_smart": 3, + "fivem": 19, + "synology_chat": 4, + "nederlandse_spoorwegen": 3, + "emoncms_history": 48, + "osramlightify": 58, + "renson": 8, + "monoprice": 114, + "ffmpeg_motion": 13, + "zestimate": 16, + "qwikswitch": 9, + "meteoalarm": 8, + "denon": 6, + "kaleidescape": 20, + "tomato": 7, + "azure_event_hub": 20, + "ozw": 7, + "panasonic_bluray": 3, + "keymitt_ble": 9, + "tcp": 10, + "kmtronic": 21, + "syslog": 8, + "picotts": 21, + "twilio_sms": 29, + "upb": 32, + "hp_ilo": 12, + "minio": 7, + "quantum_gateway": 5, + "itunes": 7, + "nsw_rural_fire_service_feed": 2, + "transport_nsw": 4, + "x10": 2, + "familyhub": 13, + "switchmate": 37, + "harman_kardon_avr": 3, + "pjlink": 6, + "kegtron": 5, + "azure_devops": 23, + "beewi_smartclim": 1, + "poolsense": 22, + "meraki": 4, + "dte_energy_bridge": 2, + "apache_kafka": 1, + "todo": 5, + "torque": 5, + "vilfo": 2, + "itach": 3, + "msteams": 13, + "sisyphus": 22, + "hikvisioncam": 18, + "tami4": 16, + "london_underground": 3, + "sendgrid": 4, + "emonitor": 5, + "free_mobile": 9, + "limitlessled": 22, + "entur_public_transport": 7, + "linear_garage_door": 2, + "event": 4, + "voicerss": 3, + "comapsmarthome": 2, + "yardian": 13, + "plum_lightpad": 1, + "xiaomi": 14, + "bt_smarthub": 4, + "imap_email_content": 14, + "prowl": 9, + "russound_rio": 3, + "serial_pm": 2, + "raspyrfm": 1, + "oru": 4, + "sesame": 5, + "watson_tts": 7, + "sky_hub": 2, + "tolo": 3, + "ffmpeg_noise": 9, + "kira": 8, + "aprs": 5, + "otp": 2, + "gtfs": 2, + "stiebel_eltron": 19, + "osoenergy": 10, + "orvibo": 14, + "homeworks": 8, + "lannouncer": 7, + "guardian": 9, + "rmvtransport": 2, + "greeneye_monitor": 6, + "openevse": 1, + "nextbus": 16, + "clickatell": 2, + "gc100": 4, + "facebook": 3, + "progettihwsw": 2, + "warnwetter": 2, + "unifiled": 1, + "telnet": 9, + "ted5000": 2, + "dremel_3d_printer": 11, + "neurio_energy": 1, + "ddwrt": 7, + "climacell": 2, + "lirc": 3, + "refoss": 2, + "nad": 2, + "raincloud": 3, + "aquostv": 4, + "auth_header": 3, + "sharpai": 3, + "channels": 3, + "yandex_transport": 2, + "mastodon": 1, + "comelit": 3, + "sighthound": 2, + "eq3btsmart": 5, + "message_bird": 4, + "pyload": 1, + "citybikes": 2, + "comed_hourly_pricing": 4, + "xmpp": 3, + "worldtidesinfo": 2, + "ccm15": 2, + "netdata": 3, + "fritzbox_netmonitor": 4, + "apprise": 2, + "drop_connect": 1, + "permobil": 3, + "norway_air": 3, + "push": 2, + "upc_connect": 2, + "ecoal_boiler": 3, + "garages_amsterdam": 10, + "elmax": 6, + "unifi_direct": 5, + "tesla": 6, + "atome": 2, + "london_air": 1, + "anel_pwrctrl": 5, + "pushsafer": 2, + "supervisord": 2, + "hangouts": 4, + "crownstone": 6, + "noaa_tides": 6, + "delijn": 1, + "numato": 1, + "evil_genius_labs": 4, + "temper": 4, + "ign_sismologia": 2, + "yi": 3, + "xs1": 4, + "datetime": 2, + "opensensemap": 4, + "sinch": 2, + "logentries": 1, + "dominos": 2, + "shodan": 1, + "ephember": 3, + "mcp23017": 1, + "currencylayer": 2, + "music_light_switch": 1, + "weather_light_switch": 1, + "fortios": 1, + "clicksend_tts": 1, + "deutsche_bahn": 3, + "geo_rss_events": 2, + "rejseplanen": 4, + "wilight": 3, + "lacrosse": 3, + "aruba": 1, + "yeelightsunflower": 2, + "hddtemp": 2, + "volkszaehler": 1, + "llamalab_automate": 1, + "viaggiatreno": 1, + "valve": 1, + "smartthings_soundbar": 1, + "samsungtv_smart": 1, + "gardena_smart_system": 1, + "sonoff": 1, + "sony_projector": 3, + "facebox": 1, + "bbox": 2, + "twitter": 4, + "asustor": 1, + "vivotek": 1, + "mythicbeastsdns": 2, + "dweet": 1, + "vasttrafik": 1, + "uk_transport": 4, + "scsgate": 1, + "veea": 1, + "dovado": 1, + "garadget": 2, + "simulated": 1, + "gpsd": 1, + "wsdot": 1, + "swisscom": 1, + "bme280": 1, + "spaceapi": 3, + "nx584": 1, + "discogs": 1, + "fail2ban": 1, + "futurenow": 1, + "idteck_prox": 1, + "seven_segments": 3, + "qld_bushfire": 1, + "cups": 2, + "arest": 1, + "eliqonline": 1, + "yandextts": 2, + "blockchain": 1, + "date": 1, + "elv": 1, + "tank_utility": 1, + "etherscan": 1, + "blue_current": 1, + "tplink_lte": 1, + "rpi_rf": 1 + }, + "operating_system": { + "boards": { + "ova": 62036, + "rpi3-64": 17386, + "rpi4-64": 61880, + "rpi4": 1476, + "generic-x86-64": 27962, + "yellow": 4767, + "odroid-xu4": 191, + "green": 3671, + "rpi3": 2016, + "odroid-n2": 4919, + "rpi2": 516, + "odroid-c2": 122, + "generic-aarch64": 1088, + "odroid-m1": 195, + "tinker": 111, + "rpi5-64": 686, + "khadas-vim3": 53, + "odroid-c4": 227 + }, + "versions": { + "10.5": 9182, + "11.3": 50875, + "11.2": 53976, + "10.2": 1131, + "11.4": 26448, + "11.1": 25489, + "10.4": 2017, + "9.3": 991, + "10.3": 3390, + "11.0": 3494, + "9.5": 3518, + "11.4.rc1": 313, + "11.3.rc1": 405, + "9.4": 1314, + "10.0": 337, + "11.0.rc1": 12, + "9.0": 293, + "10.1": 1631, + "8.4": 258, + "7.0": 124, + "9.2": 255, + "8.5": 629, + "11.2.rc1": 86, + "8.2": 373, + "7.6": 540, + "6.4": 110, + "7.1": 117, + "6.6": 253, + "6.3": 35, + "5.13": 85, + "6.2": 97, + "6.0": 31, + "11.3.dev20231221": 39, + "8.3": 11, + "7.4": 264, + "11.4.dev20231223": 45, + "11.3.dev20231212": 41, + "11.4.dev20240107": 13, + "8.1": 180, + "11.3.dev20231214": 47, + "7.3": 27, + "11.0.dev20230926": 3, + "6.5": 64, + "11.2.rc2": 83, + "11.3.rc2": 119, + "8.0": 32, + "6.1": 67, + "7.2": 96, + "11.4.dev20240108": 44, + "8.0.rc3": 1, + "11.2.dev20231120": 3, + "11.4.dev20231226": 87, + "10.0.rc3": 10, + "7.5": 99, + "9.0.rc1": 3, + "11.3.dev20231220": 10, + "11.4.dev20240105": 14, + "8.0.dev20220126": 1, + "7.0.rc1": 3, + "8.0.rc2": 4, + "10.0.rc2": 4, + "11.2.dev20231116": 3, + "11.1.rc1": 8, + "10.0.dev20220912": 1, + "5.11": 2, + "11.3.dev20231201": 12, + "11.0.dev20230511": 2, + "11.0.dev20230609": 1, + "4.20": 2, + "11.0.dev20230921": 2, + "11.2.dev20231102": 2, + "11.0.dev20230627": 1, + "11.3.dev20231211": 3, + "5.12": 7, + "11.0.dev20231004": 1, + "10.0.dev20221130": 1, + "11.2.dev20231109": 3, + "9.0.rc2": 6, + "11.0.dev20230705": 1, + "11.0.dev20230524": 1, + "11.0.dev20230601": 1, + "5.10": 1, + "11.2.dev20231106": 1, + "8.0.dev20220505": 1, + "11.0.rc2": 2, + "11.0.dev20230720": 1, + "4.12": 1, + "11.0.dev20230622": 1, + "9.1": 1, + "10.0.dev20221110": 1, + "11.3.dev0": 1, + "11.0.dev20230416": 1, + "8.0.rc4": 1, + "10.0.rc1": 1, + "11.0.dev20230413": 1, + "10.0.dev20221222": 1, + "11.2.dev20231031": 1, + "11.1.dev20231011": 1, + "11.0.dev20230823": 1 + } + }, + "supervisor": { + "arch": { + "amd64": 96508, + "aarch64": 100709, + "armv7": 4920, + "armhf": 375, + "i386": 20 + }, + "unhealthy": 3486, + "unsupported": 9329 + }, + "reports_integrations": 249256, + "reports_addons": 174735, + "reports_statistics": 241738, + "versions": { + "2023.5.4": 1615, + "2024.1.2": 97867, + "2023.9.1": 1000, + "2023.12.4": 29374, + "2022.8.6": 238, + "2023.11.3": 23970, + "2023.5.3": 979, + "2024.1.1": 6877, + "2023.11.1": 3990, + "2023.6.2": 1001, + "2023.12.3": 28121, + "2023.10.0": 726, + "2023.8.4": 2548, + "2023.12.1": 10408, + "2023.2.5": 1220, + "2023.1.7": 1346, + "2024.1.0": 12599, + "2023.10.1": 2179, + "2022.12.8": 904, + "2023.3.6": 1220, + "2023.8.0": 667, + "2023.7.0": 161, + "2022.12.6": 202, + "2023.3.3": 502, + "2023.10.4": 300, + "2024.2.0.dev20240105": 8, + "2023.6.1": 779, + "2023.11.2": 15694, + "2022.11.3": 216, + "2023.10.5": 5379, + "2023.12.0": 4192, + "2023.9.2": 3728, + "2023.7.3": 3932, + "2023.6.3": 2790, + "2023.4.2": 399, + "2023.12.2": 2328, + "2022.4.7": 184, + "2023.10.3": 4071, + "2023.9.3": 3649, + "2023.2.1": 189, + "2023.8.2": 1219, + "2023.3.1": 516, + "2023.6.0": 167, + "2021.4.3": 39, + "2022.10.4": 250, + "2023.3.5": 524, + "2023.5.2": 947, + "2023.4.5": 357, + "2023.8.3": 1211, + "2022.10.5": 677, + "2021.4.6": 89, + "2023.4.6": 1336, + "2023.3.4": 243, + "2022.12.9": 142, + "2022.3.5": 148, + "2023.4.4": 429, + "2022.8.7": 383, + "2021.12.3": 76, + "2023.1.0.dev20221210": 1, + "2023.8.1": 1298, + "2023.4.1": 271, + "2024.1.0.dev20231223": 14, + "2022.6.0.dev20220502": 1, + "2021.12.9": 156, + "2022.8.4": 80, + "2022.11.5": 434, + "2021.11.5": 397, + "2023.11.0": 1914, + "2024.1.0b2": 68, + "2023.9.0": 502, + "2023.1.6": 224, + "2023.1.2": 265, + "2022.4.5": 75, + "2022.11.2": 449, + "2021.6.6": 1306, + "2023.7.1": 1090, + "2022.11.4": 559, + "2021.11.1": 67, + "2022.4.0": 34, + "2023.2.3": 461, + "2023.7.2": 1224, + "2023.2.2": 243, + "2022.8.5": 53, + "2021.12.8": 436, + "2023.1.0": 110, + "2022.2.9": 273, + "2021.8.8": 174, + "2022.9.4": 162, + "2022.10.1": 117, + "2023.7.0.dev20230626": 233, + "2023.1.1": 271, + "2022.7.6": 111, + "2022.12.3": 73, + "2022.12.0": 101, + "2022.8.2": 62, + "2021.12.7": 131, + "2022.7.2": 56, + "2021.8.5": 7, + "2022.9.7": 344, + "2022.5.2": 34, + "2022.10.0": 50, + "2021.9.7": 433, + "2022.7.3": 69, + "2021.12.6": 34, + "2021.12.2": 40, + "2024.1.0b1": 9, + "2021.12.10": 429, + "2023.1.4": 319, + "2021.4.4": 27, + "2022.9.6": 191, + "2022.6.7": 379, + "2022.7.7": 189, + "2022.5.1": 21, + "2022.5.4": 132, + "2022.9.0": 59, + "2022.5.3": 87, + "2022.11.1": 255, + "2022.2.5": 45, + "2021.10.6": 163, + "2022.3.8": 164, + "2024.1.0b0": 26, + "2022.7.5": 158, + "2022.3.7": 106, + "2022.8.1": 63, + "2021.8.4": 20, + "2022.8.0": 37, + "2021.12.5": 89, + "2023.3.0": 83, + "2022.9.2": 67, + "2024.1.0.dev20231222": 7, + "2021.6.4": 22, + "2023.5.0.dev20230413": 1, + "2021.5.1": 23, + "2022.9.5": 87, + "2022.6.3": 28, + "2021.9.4": 17, + "2022.12.1": 191, + "2023.2.0b9": 2, + "2022.12.7": 228, + "2023.10.2": 316, + "2022.4.4": 39, + "2022.3.6": 29, + "2023.12.0.dev20231122": 5, + "2022.5.5": 250, + "2023.2.0": 116, + "2022.2.0": 25, + "2021.9.5": 23, + "2023.2.4": 165, + "2022.10.3": 154, + "2022.4.3": 29, + "2021.10.7": 35, + "2023.4.3": 54, + "2022.12.4": 40, + "2021.10.5": 23, + "2023.5.0": 99, + "2022.3.1": 68, + "2022.6.6": 175, + "2022.2.2": 49, + "2023.8.0.dev20230723": 123, + "2021.8.7": 28, + "2022.7.4": 33, + "2023.5.1": 91, + "2024.1.0.dev20231212": 15, + "2024.2.0.dev20240109": 23, + "2024.1.0.dev20231215": 7, + "2023.11.0.dev20231019": 1, + "2022.2.7": 16, + "2023.6.0.dev20230528": 1, + "2023.1.0b5": 1, + "2021.6.5": 52, + "2021.11.4": 57, + "2021.5.5": 78, + "2021.11.0": 33, + "2022.2.1": 13, + "2023.3.2": 98, + "2021.8.6": 35, + "2022.9.1": 99, + "2023.9.0.dev20230830": 1, + "2022.2.8": 44, + "2023.12.0.dev20231121": 2, + "2023.1.5": 217, + "2021.10.2": 30, + "2023.5.0.dev20230408": 1, + "2022.10.2": 57, + "2021.10.0.dev20210916": 1, + "2022.6.5": 106, + "2022.3.0": 41, + "2023.4.0": 122, + "2022.11.0b0": 1, + "2021.5.2": 13, + "2023.10.0.dev20230910": 1, + "2023.3.0b4": 1, + "2022.6.4": 73, + "2021.9.1": 6, + "2021.9.6": 82, + "2023.10.0b4": 3, + "2024.1.0.dev20231218": 99, + "2021.7.4": 106, + "2024.2.0.dev20240101": 2, + "2023.12.0b1": 46, + "2022.6.2": 59, + "2022.2.3": 76, + "2021.11.3": 53, + "2024.2.0.dev20240110": 22, + "2024.1.0b3": 54, + "2022.12.2": 23, + "2024.2.0.dev20240107": 10, + "2022.8.3": 69, + "2022.7.0": 40, + "2023.10.0.dev20230916": 1, + "2022.3.3": 103, + "2022.12.5": 56, + "2022.4.6": 90, + "2021.9.3": 18, + "2023.9.0b3": 6, + "2024.2.0.dev20240108": 14, + "2022.2.6": 98, + "2021.12.4": 49, + "2022.4.1": 62, + "2021.5.0": 11, + "2022.6.0": 31, + "2023.4.0.dev20230304": 1, + "2022.6.0b1": 1, + "2021.10.4": 32, + "2021.6.0.dev20210512": 1, + "2024.1.0b5": 32, + "2022.11.0": 40, + "2022.7.0.dev20220618": 1, + "2021.6.3": 35, + "2024.1.0b4": 27, + "2021.5.4": 34, + "2021.7.1": 36, + "2024.1.0.dev20231211": 4, + "2021.12.1": 36, + "2023.4.0.dev20230307": 3, + "2024.1.0b7": 13, + "2024.1.0b8": 15, + "2021.4.5": 33, + "2022.6.1": 47, + "2023.10.0.dev20230927": 2, + "2024.1.0.dev20231220": 6, + "2022.7.0.dev20220526": 1, + "2022.10.0.dev20220918": 1, + "2024.1.0.dev20231227": 7, + "2021.8.0": 9, + "2023.9.0b4": 1, + "2021.7.0": 4, + "2021.7.3": 46, + "2022.3.4": 30, + "2023.10.0.dev20230917": 1, + "2023.11.0b0": 3, + "2024.1.0.dev20231209": 9, + "2021.8.3": 12, + "2022.11.0.dev20221002": 1, + "2023.5.0.dev20230403": 1, + "2022.2.0.dev20211217": 1, + "2022.7.1": 16, + "2021.6.2": 28, + "2024.1.0.dev20231221": 15, + "2023.2.0.dev20230102": 4, + "2022.12.0.dev20221119": 1, + "2023.5.0.dev20230418": 1, + "2023.4.0.dev20230324": 1, + "2021.10.3": 11, + "2022.4.2": 23, + "2022.3.2": 36, + "2022.6.0.dev20220515": 1, + "2022.5.0": 37, + "2023.12.0.dev20231102": 2, + "2023.1.3": 6, + "2024.2.0.dev20231231": 2, + "2024.2.0.dev20240106": 6, + "2021.9.2": 10, + "2021.5.3": 19, + "2021.12.0": 29, + "2022.9.0.dev20220827": 2, + "2023.6.0.dev20230515": 1, + "2021.12.0b5": 1, + "2024.2.0.dev20231229": 5, + "2022.4.0.dev20220327": 1, + "2022.8.0.dev20220711": 1, + "2024.1.0.dev20231216": 7, + "2022.3.0.dev20220205": 2, + "2022.3.0b4": 1, + "2024.1.0.dev20231217": 9, + "2023.11.0b4": 2, + "2023.4.0.dev20230227": 1, + "2023.5.0b9": 1, + "2023.2.0b6": 1, + "2022.12.0.dev20221120": 1, + "2022.11.0b7": 1, + "2021.6.0": 4, + "2024.1.0.dev20231204": 1, + "2021.11.2": 29, + "2021.4.1": 6, + "2023.3.0.dev20230205": 1, + "2023.9.0b5": 2, + "2021.7.2": 23, + "2023.1.0.dev20221218": 1, + "2023.3.0.dev20230210": 3, + "2023.12.0b0": 13, + "2023.12.0b3": 5, + "2023.1.0.dev20221220": 1, + "2023.12.0.dev20231128": 5, + "2022.2.0.dev20220121": 1, + "2023.7.0.dev20230604": 1, + "2022.3.0.dev20220214": 2, + "2022.6.0.dev20220525": 2, + "2021.4.0": 3, + "2023.12.0.dev20231110": 1, + "2023.11.0.dev20231012": 4, + "2023.5.0.dev20230410": 1, + "2023.12.0b5": 5, + "2022.7.0.dev20220623": 1, + "2024.2.0.dev20231228": 3, + "2024.2.0.dev20231230": 8, + "2023.12.0.dev20231127": 1, + "2023.5.0.dev20230423": 1, + "2022.11.0.dev20221009": 1, + "2022.12.0.dev20221105": 5, + "2022.8.0.dev20220704": 1, + "2023.2.0.dev20230116": 1, + "2022.10.0b5": 1, + "2022.4.0.dev20220225": 1, + "2023.3.0.dev20230216": 1, + "2022.12.0.dev20221029": 1, + "2023.6.0.dev20230530": 2, + "2024.1.0.dev20231226": 9, + "2023.9.0.dev20230810": 2, + "2023.10.0b0": 3, + "2023.9.0b0": 2, + "2023.9.0b2": 1, + "2021.9.0.dev20210824": 1, + "2024.2.0.dev20240102": 19, + "2023.9.0.dev20230807": 2, + "2023.11.0b3": 2, + "2021.11.0.dev20211007": 1, + "2023.6.0.dev20230512": 1, + "2023.12.0.dev20231123": 2, + "2023.8.0.dev20230721": 1, + "2021.9.0": 7, + "2023.5.0b6": 1, + "2023.10.0b2": 4, + "2023.10.0.dev20230920": 1, + "2023.4.0.dev20230223": 1, + "2023.11.0b2": 6, + "2023.12.0.dev20231116": 1, + "2023.5.0.dev20230416": 1, + "2023.10.0b9": 3, + "2022.8.0b7": 1, + "2022.8.0b3": 1, + "2022.10.0.dev20220903": 2, + "2023.12.0b2": 13, + "2021.10.0": 21, + "2023.6.0.dev20230520": 1, + "2022.9.3": 8, + "2023.4.0.dev20230320": 3, + "2022.5.0.dev20220423": 1, + "2023.3.0.dev20230201": 1, + "2023.7.0.dev20230616": 1, + "2022.2.0.dev20220103": 1, + "2023.10.0b3": 3, + "2023.2.0.dev20221230": 1, + "2021.10.1": 7, + "2022.9.0.dev20220823": 1, + "2023.6.0b1": 1, + "2022.6.0.dev20220504": 1, + "2021.8.2": 7, + "2024.1.0.dev20231225": 5, + "2023.9.0.dev20230728": 1, + "2023.12.0.dev20231118": 2, + "2022.6.0.dev20220429": 1, + "2023.8.0.dev20230701": 1, + "2023.12.0.dev20231103": 1, + "2023.4.0.dev20230318": 1, + "2021.11.0.dev20211020": 1, + "2023.1.0.dev20221222": 2, + "2023.8.0b1": 2, + "2023.5.0.dev20230412": 2, + "2022.2.0.dev20220108": 1, + "2021.9.0.dev20210823": 1, + "2024.1.0.dev20231213": 9, + "2023.4.0b6": 1, + "2021.8.1": 7, + "2024.1.0.dev20231201": 3, + "2023.7.0b2": 1, + "2023.5.0.dev20230331": 1, + "2023.11.0.dev20231017": 1, + "2023.12.0.dev20231111": 1, + "2024.1.0.dev20231130": 1, + "2023.7.0.dev20230606": 1, + "2023.12.0.dev20231119": 5, + "2024.1.0.dev20231219": 6, + "2022.1.0.dev20211212": 1, + "2023.12.0.dev20231107": 1, + "2022.2.0.dev20211225": 1, + "2023.4.0.dev20230312": 1, + "2023.12.0.dev20231126": 6, + "2022.9.0b6": 2, + "2023.4.0.dev20230326": 1, + "2023.3.0.dev20230203": 1, + "2024.1.0.dev20231205": 2, + "2023.5.0.dev20230411": 1, + "2023.12.0b4": 6, + "2022.5.0.dev20220410": 3, + "2022.9.0.dev20220901": 3, + "2023.4.0b5": 2, + "2023.6.0b0": 1, + "2024.1.0.dev20231208": 3, + "2023.2.0b2": 2, + "2023.1.0.dev20221217": 1, + "2022.12.0.dev20221123": 1, + "2023.5.0.dev20230406": 2, + "2022.5.0.dev20220411": 1, + "2022.12.0.dev20221112": 1, + "2023.1.0.dev20221228": 2, + "2023.5.0.dev20230417": 1, + "2023.5.0.dev20230401": 2, + "2023.6.0.dev20230428": 1, + "2023.1.0b3": 1, + "2022.2.0b6": 1, + "2023.4.0.dev20230305": 1, + "2021.7.0.dev20210603": 1, + "2023.12.0.dev20231125": 2, + "2021.5.0.dev20210422": 1, + "2021.12.0b1": 1, + "2023.3.0b3": 3, + "2023.6.0.dev20230502": 1, + "2023.11.0.dev20231008": 1, + "2021.10.0b5": 1, + "2023.12.0.dev20231029": 2, + "2023.6.0.dev20230511": 1, + "2023.6.0.dev20230519": 1, + "2023.6.0b4": 2, + "2023.2.0.dev20230120": 2, + "2021.9.0b3": 1, + "2023.9.0.dev20230805": 1, + "2022.11.0b6": 3, + "2023.6.0.dev20230527": 1, + "2022.8.0.dev20220721": 1, + "2023.6.0.dev20230523": 1, + "2022.12.0.dev20221125": 1, + "2022.3.0.dev20220220": 1, + "2023.5.0.dev20230424": 1, + "2022.11.0b4": 1, + "2023.3.0.dev20230222": 1, + "2023.11.0b1": 1, + "2023.6.0.dev20230508": 1, + "2023.9.0.dev20230812": 1, + "2022.7.0b5": 1, + "2023.4.0.dev20230329": 1, + "2023.11.0b5": 3, + "2023.1.0b0": 1, + "2022.10.0b1": 1, + "2022.12.0b4": 1, + "2023.1.0.dev20221204": 1, + "2023.7.0b3": 2, + "2021.6.1": 2, + "2023.1.0.dev20221221": 1, + "2022.11.0b3": 1, + "2023.3.0.dev20230130": 1, + "2021.10.0.dev20210911": 1, + "2023.9.0.dev20230821": 1, + "2022.10.0b6": 1, + "2021.9.0.dev20210814": 1, + "2024.1.0.dev20231207": 2, + "2023.12.0.dev20231106": 1, + "2022.8.0.dev20220630": 1, + "2021.4.2": 4, + "2023.12.0.dev20231113": 1, + "2022.3.0.dev20220212": 1, + "2023.8.0b4": 2, + "2023.7.0.dev20230624": 1, + "2022.9.0.dev20220731": 1, + "2023.1.0.dev20221201": 1, + "2023.4.0.dev20230303": 1, + "2022.10.0.dev20220928": 1, + "2023.12.0.dev20231108": 1, + "2021.9.0b7": 1, + "2022.12.0.dev20221110": 1, + "2022.6.0.dev20220509": 1, + "2022.4.0.dev20220323": 1, + "2023.5.0.dev20230414": 3, + "2022.2.0.dev20211228": 1, + "2023.3.0b7": 1, + "2023.4.0.dev20230310": 1, + "2023.8.0.dev20230725": 1, + "2023.4.0.dev20230224": 1, + "2024.1.0.dev20231210": 1, + "2022.10.0.dev20220925": 1, + "2022.12.0.dev20221115": 1, + "2022.6.0.dev20220510": 1, + "2023.4.0.dev20230325": 1, + "2023.6.0.dev20230509": 1, + "2023.6.0.dev20230501": 1, + "2023.7.0.dev20230623": 1, + "2022.12.0.dev20221111": 1, + "2023.10.0.dev20230922": 1, + "2021.5.0.dev20210410": 1, + "2023.12.0.dev20231114": 1, + "2023.9.0.dev20230727": 1, + "2023.12.0.dev20231120": 1, + "2023.10.0.dev20230904": 1, + "2022.7.0.dev20220621": 1, + "2023.7.0.dev20230611": 1, + "2023.6.0b3": 1, + "2023.10.0b6": 1, + "2023.4.0.dev20230311": 1, + "2023.11.0b6": 1, + "2022.12.0b2": 1, + "2023.3.0.dev20230202": 1, + "2023.10.0.dev20230918": 1, + "2023.11.0.dev20231009": 1, + "2022.2.0.dev20220123": 1, + "2023.6.0.dev20230526": 1, + "2023.4.0.dev20230313": 1, + "2023.12.0.dev20231124": 1, + "2023.6.0b6": 1, + "2023.2.0.dev20230124": 1, + "2023.4.0.dev20230309": 1, + "2023.8.0.dev20230712": 2, + "2022.8.0.dev20220725": 1, + "2023.3.0.dev20230127": 1, + "2022.7.0b3": 1, + "2022.5.0.dev20220406": 1, + "2023.9.0.dev20230826": 1, + "2023.2.0.dev20230115": 1, + "2023.12.0.dev20231115": 1, + "2022.5.0b3": 1, + "2023.2.0b1": 1, + "2022.4.0.dev20220330": 1, + "2023.2.0.dev20230121": 2, + "2022.12.0.dev20221108": 1, + "2023.11.0.dev20231011": 1, + "2022.9.0b5": 1, + "2023.11.0.dev20231007": 1, + "2024.1.0.dev20231203": 2, + "2024.1.0b6": 2, + "2023.5.0.dev20230402": 1, + "2023.5.0.dev20230426": 2, + "2023.6.0.dev20230507": 1, + "2021.8.0.dev20210707": 1, + "2022.7.0.dev20220616": 1, + "2022.11.0b5": 1, + "2023.1.0.dev20221208": 1, + "2021.5.0.dev20210427": 1, + "2022.7.0.dev20220603": 1, + "2023.6.0.dev20230522": 1, + "2023.4.0.dev20230327": 1, + "2023.11.0.dev20231001": 1, + "2021.8.0.dev20210716": 1, + "2023.12.0.dev20231101": 1, + "2023.6.0.dev20230524": 1, + "2023.5.0.dev20230421": 1, + "2022.12.0b7": 1 + }, + "certificate_count_configured": 19511, + "energy": { + "count_configured": 90827 + }, + "recorder": { + "engines": { + "sqlite": { + "versions": { + "3.41.2": 198778, + "3.40.1": 3763, + "3.38.5": 4503, + "3.42.0": 2753, + "3.39.3": 187, + "3.37.2": 666, + "3.31.1": 864, + "3.43.1": 47, + "3.39.2": 13, + "3.43.0": 19, + "3.34.1": 284, + "3.44.2": 251, + "3.36.0": 30, + "3.39.4": 42, + "3.43.2": 56, + "3.39.5": 2, + "3.44.0": 87, + "3.44.1": 4, + "3.32.3": 2, + "3.41.1": 2, + "3.38.2": 1, + "3.37.0": 2, + "3.41.0": 5, + "3.40.0": 3, + "3.35.5": 3, + "3.37.1": 1, + "3.39.0": 3 + }, + "count_configured": 212371 + }, + "mysql": { + "versions": { + "10.6.12-MariaDB": 13673, + "10.6.12-MariaDB-0ubuntu0.22.04.1": 124, + "10.11.4-MariaDB-1~deb12u1": 91, + "8.0.31": 7, + "11.1.2-MariaDB-1:11.1.2+maria~ubu2204": 131, + "10.6.11-MariaDB-1:10.6.11+maria~ras11": 1, + "11.1.3-MariaDB-1:11.1.3+maria~deb12": 43, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2204": 621, + "10.11.2-MariaDB-log": 5, + "10.11.5-MariaDB": 102, + "8.0.32": 35, + "10.7.8-MariaDB-1:10.7.8+maria~ubu2004": 32, + "10.6.16-MariaDB-log": 1, + "10.7.7-MariaDB": 1, + "11.0.3-MariaDB-1:11.0.3+maria~ubu2204": 13, + "10.10.2-MariaDB-1:10.10.2+maria~deb10": 3, + "11.0.3-MariaDB-1:11.0.3+maria~deb11": 7, + "10.11.2-MariaDB-1": 10, + "11.2.2-MariaDB": 23, + "10.5.21-MariaDB-0+deb11u1-log": 6, + "10.6.11-MariaDB-log": 22, + "10.11.2-MariaDB-1:10.11.2+maria~ubu2204": 97, + "10.5.19-MariaDB-1:10.5.19+maria~deb11": 1, + "11.1.2-MariaDB-1:11.1.2+maria~deb12": 62, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2204": 57, + "10.6.10-MariaDB": 133, + "10.5.21-MariaDB-0+deb11u1": 100, + "10.11.4-MariaDB-1:10.11.4+maria~ubu2204": 8, + "10.6.16-MariaDB-cll-lve-log": 1, + "8.0.17": 2, + "10.10.2-MariaDB-1:10.10.2+maria~deb11": 6, + "10.5.8-MariaDB-log": 25, + "8.0.23-0ubuntu0.20.04.1": 1, + "10.11.5-MariaDB-log": 297, + "10.3.32-MariaDB": 95, + "8.0.35": 26, + "10.10.7-MariaDB-1:10.10.7+maria~deb11": 20, + "10.9.5-MariaDB": 1, + "11.0.2-MariaDB-1:11.0.2+maria~ubu2204": 50, + "10.6.14-MariaDB-1:10.6.14+maria~ubu2004": 5, + "10.6.12-MariaDB-log": 56, + "10.9.8-MariaDB-1:10.9.8+maria~deb11": 14, + "10.5.23-MariaDB-1:10.5.23+maria~ubu2004": 19, + "8.1.0": 15, + "11.2.2-MariaDB-1:11.2.2+maria~deb12": 112, + "8.2.0": 76, + "10.11.2-MariaDB": 167, + "10.9.3-MariaDB-1:10.9.3+maria~ubu2204": 13, + "10.5.19-MariaDB-1:10.5.19+maria~ubu2004": 10, + "10.8.8-MariaDB-1:10.8.8+maria~deb11": 8, + "10.11.3-MariaDB-1:10.11.3+maria~ubu2204": 26, + "10.11.3-MariaDB-1+rpi1": 11, + "10.6.12-MariaDB-1:10.6.12+maria~deb11": 2, + "10.3.29-MariaDB": 45, + "10.11.4-MariaDB": 10, + "8.0.35-0ubuntu0.22.04.1": 57, + "10.6.12-MariaDB-1:10.6.12+maria~deb10": 2, + "10.11.3-MariaDB-1": 23, + "10.4.19-MariaDB": 5, + "10.10.7-MariaDB-1:10.10.7+maria~ubu2204": 11, + "10.10.3-MariaDB-1:10.10.3+maria~ubu2204": 31, + "10.11.6-MariaDB": 7, + "10.5.18-MariaDB-log": 2, + "10.11.2-MariaDB-1:10.11.2+maria~deb11": 15, + "8.0.35-27": 4, + "10.5.11-MariaDB": 2, + "11.1.3-MariaDB-1:11.1.3+maria~ubu2204": 29, + "10.7.8-MariaDB-1:10.7.8+maria~deb11": 11, + "10.5.19-MariaDB": 4, + "10.5.23-MariaDB": 9, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2004-log": 3, + "10.10.5-MariaDB-1:10.10.5+maria~deb11": 1, + "8.0.35-0ubuntu0.20.04.1": 17, + "10.10.3-MariaDB-1:10.10.3+maria~deb11": 8, + "10.6.12-MariaDB-1:10.6.12+maria~ubu2004": 8, + "10.5.17-MariaDB-log": 15, + "10.9.3-MariaDB-1:10.9.3+maria~deb11": 6, + "10.8.8-MariaDB": 3, + "10.6.12-MariaDB-0ubuntu0.22.04.1-log": 23, + "10.11.3-MariaDB": 2, + "10.9.3-MariaDB": 5, + "10.9.4-MariaDB-1:10.9.4+maria~ubu2204": 7, + "10.9.2-MariaDB-1:10.9.2+maria~ubu2204": 6, + "8.0.34-0ubuntu0.22.04.1": 3, + "10.6.14-MariaDB": 9, + "10.5.18-MariaDB-1:10.5.18+maria~ubu2004": 7, + "11.1.2-MariaDB": 7, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2004": 23, + "10.5.18-MariaDB-0+deb11u1": 21, + "10.6.13-MariaDB-1:10.6.13+maria~ubu1804": 1, + "10.11.4-MariaDB-log": 40, + "10.8.8-MariaDB-1:10.8.8+maria~ubu2204": 10, + "10.3.38-MariaDB-0+deb10u1": 3, + "10.10.2-MariaDB": 2, + "10.4.8-MariaDB-1:10.4.8+maria~bionic": 1, + "10.11.5-MariaDB-1:10.11.5+maria~ubu2204": 14, + "10.5.18-MariaDB": 5, + "8.0.27": 15, + "10.3.12-MariaDB-1:10.3.12+maria~bionic": 1, + "10.7.5-MariaDB-1:10.7.5+maria~ubu2004": 7, + "10.11.6-MariaDB-1:10.11.6+maria~ubu2204": 48, + "10.11.3-MariaDB-log": 2, + "8.0.26": 2, + "10.5.22-MariaDB": 7, + "10.6.16-MariaDB-1:10.6.16+maria~deb11": 4, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2204-log": 12, + "8.0.19": 2, + "10.10.7-MariaDB-1:10.10.7+maria~deb10": 1, + "10.6.11-MariaDB": 13, + "10.9.4-MariaDB": 2, + "11.1.2-MariaDB-1:11.1.2+maria~ubu2204-log": 1, + "11.2.2-MariaDB-1:11.2.2+maria~deb11": 8, + "8.0.28": 4, + "8.0.29": 15, + "11.2.2-MariaDB-log": 4, + "10.8.7-MariaDB-1:10.8.7+maria~ubu2204": 2, + "8.0.30": 12, + "10.7.8-MariaDB": 2, + "10.10.3-MariaDB": 4, + "11.1.3-MariaDB-log": 1, + "8.0.34-26": 6, + "10.5.19-MariaDB-0+deb11u2": 34, + "10.8.2-MariaDB-1:10.8.2+maria~focal": 2, + "10.11.6-MariaDB-1:10.11.6+maria~deb11": 17, + "10.6.10-MariaDB-log": 13, + "10.11.3-MariaDB-1:10.11.3+maria~deb11": 3, + "10.6.14-MariaDB-log": 4, + "10.5.17-MariaDB-1:10.5.17+maria~ubu2004": 9, + "8.0.34": 8, + "8.0.22": 2, + "10.3.39-MariaDB-0+deb10u1": 12, + "10.9.7-MariaDB-1:10.9.7+maria~deb11": 4, + "10.6.10-MariaDB-1:10.6.10+maria~ubu1804": 1, + "10.5.22-MariaDB-1:10.5.22+maria~ubu2004-log": 2, + "10.11.4-MariaDB-1~deb12u1-log": 9, + "10.11.3-MariaDB-1:10.11.3+maria~ubu2204-log": 3, + "10.8.3-MariaDB": 1, + "10.4.17-MariaDB-1:10.4.17+maria~bionic-log": 1, + "10.4.19-MariaDB-1:10.4.19+maria~focal": 1, + "10.8.6-MariaDB-1:10.8.6+maria~ubu2204": 4, + "10.3.37-MariaDB": 7, + "10.5.8-MariaDB": 2, + "8.0.25": 6, + "10.11.4-MariaDB-1": 5, + "10.6.12-MariaDB-1:10.6.12+maria~ubu1804": 2, + "10.5.21-MariaDB": 1, + "10.7.7-MariaDB-1:10.7.7+maria~ubu2004": 2, + "10.11.6-MariaDB-1": 1, + "10.6.11-MariaDB-0ubuntu0.22.04.1": 3, + "10.3.38-MariaDB-0ubuntu0.20.04.1": 11, + "10.11.5-MariaDB-1:10.11.5+maria~deb11": 8, + "8.0.24": 2, + "11.1.0-MariaDB-log": 1, + "10.11.6-MariaDB-1:10.11.6+maria~ubu2204-log": 5, + "10.6.12-MariaDB-1:10.6.12+maria~ubu2004-log": 3, + "10.6.5-MariaDB-1:10.6.5+maria~focal": 2, + "10.5.17-MariaDB": 3, + "11.0.2-MariaDB": 4, + "11.0.2-MariaDB-1:11.0.2+maria~ubu2204-log": 1, + "10.3.7-MariaDB": 2, + "10.10.6-MariaDB-1:10.10.6+maria~deb11": 2, + "8.0.23": 5, + "10.6.16-MariaDB": 5, + "8.0.33": 20, + "10.9.5-MariaDB-1:10.9.5+maria~ubu2204": 5, + "11.1.2-MariaDB-1:11.1.2+maria~deb11": 6, + "10.6.12-MariaDB-0ubuntu0.22.10.1": 2, + "10.6.15-MariaDB-log": 1, + "10.11.6-MariaDB-1:10.11.6+maria~deb12": 3, + "10.10.6-MariaDB-1:10.10.6+maria~ubu2004": 2, + "10.9.3-MariaDB-1:10.9.3+maria~ubu2204-log": 2, + "10.6.13-MariaDB-log": 14, + "10.5.23-MariaDB-log": 2, + "8.0.34-26.1": 1, + "10.10.3-MariaDB-1:10.10.3+maria~ubu2004": 3, + "11.1.3-MariaDB-1:11.1.3+maria~deb12-log": 1, + "10.6.11-MariaDB-1:10.6.11+maria~ubu2004": 12, + "10.6.11-MariaDB-1:10.6.11+maria~deb10": 1, + "10.8.6-MariaDB-1:10.8.6+maria~deb11": 5, + "8.0.33-0ubuntu0.20.04.2": 1, + "11.1.2-MariaDB-log": 2, + "10.3.39-MariaDB-1:10.3.39+maria~ubu2004": 1, + "10.4.28-MariaDB": 2, + "8.0.31-google": 4, + "10.3.37-MariaDB-0ubuntu0.20.04.1": 1, + "10.8.4-MariaDB-1:10.8.4+maria~ubu2204": 8, + "10.5.20-MariaDB-1:10.5.20+maria~ubu2004": 2, + "11.0.4-MariaDB-log": 1, + "10.5.21-MariaDB-log": 2, + "10.9.2-MariaDB-1:10.9.2+maria~deb11": 1, + "10.5.22-MariaDB-log": 1, + "11.0.4-MariaDB-1:11.0.4+maria~deb11": 8, + "10.11.6-MariaDB-1-log": 1, + "10.5.18-MariaDB-0+deb11u1-log": 3, + "10.6.8-MariaDB": 13, + "10.5.22-MariaDB-1:10.5.22+maria~ubu2004": 5, + "11.0.1-MariaDB": 1, + "11.1.3-MariaDB-1:11.1.3+maria~deb11": 4, + "8.0.30-0ubuntu0.20.04.2": 1, + "10.3.36-MariaDB-0+deb10u2": 3, + "8.0.31-23": 1, + "10.6.13-MariaDB": 3, + "10.10.5-MariaDB-1:10.10.5+maria~ubu2004": 1, + "10.5.23-MariaDB-1:10.5.23+maria~ubu2004-log": 5, + "10.5.23-MariaDB-1:10.5.23+maria~deb11": 3, + "10.3.29-MariaDB-log": 2, + "11.0.3-MariaDB-1:11.0.3+maria~ubu2204-log": 1, + "10.5.16-MariaDB": 1, + "8.0.32-0ubuntu0.20.04.2": 1, + "10.6.16-MariaDB-1:10.6.16+maria~ubu2204": 1, + "8.0.35-1ubuntu2": 1, + "10.11.3-MariaDB-1:10.11.3+maria~deb11-log": 1, + "10.9.8-MariaDB-1:10.9.8+maria~deb10": 2, + "10.4.21-MariaDB-1:10.4.21+maria~focal": 1, + "10.4.26-MariaDB": 1, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2004": 2, + "10.6.11-MariaDB-1:10.6.11+maria~ubu2004-log": 1, + "10.3.27-MariaDB-0+deb10u1": 3, + "10.8.8-MariaDB-1:10.8.8+maria~ubu2004": 1, + "10.11.6-MariaDB-1:10.11.6+maria~deb10": 1, + "10.9.5-MariaDB-1:10.9.5+maria~deb11": 2, + "10.11.6-MariaDB-1:10.11.6+maria~deb12-log": 1, + "10.9.8-MariaDB-1:10.9.8+maria~ubu2204": 7, + "10.5.22-MariaDB-1:10.5.22+maria~ubu1804": 2, + "10.11.5-MariaDB-3": 2, + "10.6.15-MariaDB-1:10.6.15+maria~ubu2004": 6, + "10.11.4-MariaDB-1:10.11.4+maria~deb11": 3, + "11.3.1-MariaDB": 1, + "10.5.22-MariaDB-1:10.5.22+maria~ras11": 1, + "10.6.14-MariaDB-1:10.6.14+maria~ubu2204": 1, + "8.0.26-google": 1, + "8.0.13": 1, + "10.5.20-MariaDB-1:10.5.20+maria~ras10": 1, + "11.0.3-MariaDB": 1, + "10.5.15-MariaDB-0+deb11u1": 4, + "11.0.4-MariaDB-1:11.0.4+maria~ubu2204-log": 1, + "10.5.13-MariaDB-log": 1, + "10.5.13-MariaDB": 2, + "10.11.6-MariaDB-2": 1, + "10.5.8-MariaDB-1:10.5.8+maria~focal": 1, + "10.10.6-MariaDB-1:10.10.6+maria~deb10": 1, + "8.0.35-1": 1, + "8.0.21": 2, + "10.3.39-MariaDB-0+deb10u1-log": 1, + "10.3.37-MariaDB-log": 1, + "8.0.18": 1, + "10.7.3-MariaDB": 3, + "10.6.10-MariaDB-1:10.6.10+maria~ubu2004": 1, + "10.6.16-MariaDB-cll-lve": 1, + "10.6.13-MariaDB-1:10.6.13+maria~ubu2204-log": 1, + "10.6.15-MariaDB-1:10.6.15+maria~deb10-log": 1, + "10.11.3-MariaDB-1+rpi1-log": 1, + "8.0.29-0ubuntu0.20.04.3": 2, + "10.3.31-MariaDB-0+deb10u1": 1, + "10.5.16-MariaDB-log": 1, + "10.4.24-MariaDB": 1, + "10.6.13-MariaDB-1:10.6.13+maria~ubu2004": 1, + "11.1.3-MariaDB": 4, + "8.0.20-0ubuntu0.19.10.1": 1, + "11.0.2-MariaDB-1:11.0.2+maria~deb11": 3, + "10.3.39-MariaDB-1:10.3.39+maria~ubu1804": 1, + "10.3.39-MariaDB-1:10.3.39+maria~ubu1804-log": 1, + "10.10.6-MariaDB-1:10.10.6+maria~ubu2204": 1, + "8.0.35-0ubuntu0.23.04.1": 2, + "11.0.2-MariaDB-log": 1, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2204-log": 1, + "10.10.2-MariaDB-1:10.10.2+maria~ubu2004": 1, + "11.2.2-MariaDB-1:11.2.2+maria~ubu2304": 1, + "8.0.34-0ubuntu0.20.04.1": 1, + "10.6.9-MariaDB-1:10.6.9+maria~ubu2004": 2, + "8.0.35-0ubuntu0.23.10.1": 1, + "10.6.15-MariaDB-cll-lve-log": 1, + "10.5.15-MariaDB-log": 1, + "8.0.33-0ubuntu0.22.04.2": 1, + "10.7.6-MariaDB-1:10.7.6+maria~deb11": 1, + "10.5.19-MariaDB-0+deb11u2-log": 1, + "10.9.8-MariaDB": 1, + "10.6.16-MariaDB-1:10.6.16+maria~deb10": 1, + "10.3.39-MariaDB-log-cll-lve": 2, + "10.5.19-MariaDB-1:10.5.19+maria~ubu1804": 1, + "10.10.7-MariaDB-1:10.10.7+maria~ubu2004": 1, + "10.7.7-MariaDB-1:10.7.7+maria~deb11": 2, + "8.0.20": 1, + "10.11.4-MariaDB-1:10.11.4+maria~ubu2004": 2, + "8.0.32-0ubuntu0.22.04.2": 3, + "10.5.15-MariaDB": 1, + "10.10.7-MariaDB-log": 1, + "10.5.21-MariaDB-1:10.5.21+maria~ubu2004": 3, + "10.8.3-MariaDB-1:10.8.3+maria~jammy": 1, + "10.11.5-MariaDB-1:10.11.5+maria~ubu2204-log": 2, + "10.6.15-MariaDB": 3, + "10.4.14-MariaDB-1:10.4.14+maria~bionic": 1, + "10.7.6-MariaDB-1:10.7.6+maria~ubu2004": 1, + "10.6.9-MariaDB-log": 2, + "10.11.2-MariaDB-1:10.11.2+maria~deb10": 2, + "10.10.7-MariaDB": 1, + "8.0.32-1": 1, + "10.9.4-MariaDB-1:10.9.4+maria~deb11": 3, + "8.0.34-Vitess": 1, + "8.0.29-21": 1, + "10.5.19-MariaDB-1:10.5.19+maria~ras10": 1, + "10.10.6-MariaDB": 1, + "10.5.10-MariaDB-1:10.5.10+maria~bionic": 1, + "10.4.25-MariaDB-1:10.4.25+maria~focal": 1 + }, + "count_configured": 17679 + }, + "postgresql": { + "versions": { + "16.0 (Debian 16.0-1.pgdg110+1)": 92, + "13.1 (Debian 13.1-1.pgdg100+1)": 912, + "16.1": 39, + "15.5 (Debian 15.5-1.pgdg120+1)": 67, + "15.3 (Debian 15.3-1.pgdg120+1)": 16, + "15.2 (Debian 15.2-1.pgdg110+1)": 36, + "14.2": 5, + "14.10 (Debian 14.10-1.pgdg120+1)": 60, + "12.2 (Debian 12.2-2.pgdg100+1)": 5, + "14.10 (Ubuntu 14.10-0ubuntu0.22.04.1)": 27, + "13.5 (Debian 13.5-1.pgdg110+1)": 2, + "15.4": 28, + "15.3": 136, + "13.13 (Debian 13.13-0+deb11u1)": 24, + "14.3 (Debian 14.3-1.pgdg110+1)": 8, + "15.4 (Debian 15.4-1.pgdg120+1)": 10, + "16.1 (Debian 16.1-1.pgdg120+1)": 72, + "14.4 (Debian 14.4-1.pgdg110+1)": 7, + "15.3 (Debian 15.3-1.pgdg110+1)": 71, + "13.13": 11, + "13.3 (Debian 13.3-1.pgdg100+1)": 3, + "15.4 (Debian 15.4-3)": 2, + "14.7 (Debian 14.7-1.pgdg110+1)": 9, + "14.1 (Debian 14.1-1.pgdg110+1)": 8, + "13.13 (Debian 13.13-1.pgdg120+1)": 19, + "15.5 (Debian 15.5-0+deb12u1)": 38, + "14.10 (Ubuntu 14.10-1.pgdg22.04+1)": 4, + "14.7 (Ubuntu 14.7-1.pgdg22.04+1)": 8, + "15.5": 41, + "13.10": 3, + "14.9": 13, + "14.5 (Debian 14.5-2.pgdg110+2)": 10, + "12.9 (Ubuntu 12.9-0ubuntu0.20.04.1)": 1, + "14.8 (Debian 14.8-1.pgdg100+1)": 1, + "14.7": 9, + "14.2 (Debian 14.2-1.pgdg110+1)": 11, + "12.11 (Ubuntu 12.11-1.pgdg18.04+1)": 1, + "14.3": 7, + "16.0 (Debian 16.0-1.pgdg120+1)": 30, + "16.1 (Debian 16.1-1.pgdg110+1)": 30, + "13.4 (Debian 13.4-4.pgdg110+1)": 6, + "12.8 (Ubuntu 12.8-0ubuntu0.20.04.1)": 1, + "14.8 (Debian 14.8-1.pgdg110+1)": 4, + "15.2 (Ubuntu 15.2-1.pgdg22.04+1)": 12, + "13.4 (Debian 13.4-1.pgdg100+1)": 1, + "13.11 (Raspbian 13.11-0+deb11u1)": 3, + "14.1": 13, + "12.3 (Debian 12.3-1.pgdg100+1)": 2, + "12.16 (Debian 12.16-1.pgdg120+1)": 2, + "15.1": 6, + "12.11 (Debian 12.11-1.pgdg110+1)": 2, + "12.17 (Ubuntu 12.17-0ubuntu0.20.04.1)": 8, + "14.9 (Debian 14.9-1.pgdg100+1)": 2, + "15.5 (Ubuntu 15.5-1.pgdg22.04+1)": 8, + "16.1 (Ubuntu 16.1-1.pgdg22.04+1)": 10, + "12.5": 4, + "13.2 (Debian 13.2-1.pgdg100+1)": 5, + "14.9 (Debian 14.9-1.pgdg120+1)": 5, + "14.5": 18, + "14.6": 4, + "16.0": 5, + "15.4 (Debian 15.4-2.pgdg110+1)": 4, + "15.2": 6, + "14.10": 38, + "14.5 (Debian 14.5-1.pgdg110+1)": 10, + "12.15 (Debian 12.15-1.pgdg110+1)": 2, + "13.11 (Debian 13.11-0+deb11u1)": 6, + "12.11 (Ubuntu 12.11-0ubuntu0.20.04.1)": 3, + "15.1 (Ubuntu 15.1-1.pgdg22.04+1)": 1, + "15.4 (Debian 15.4-1.pgdg110+1)": 4, + "14.9 (Ubuntu 14.9-0ubuntu0.22.04.1)": 9, + "15.4 (Debian 15.4-1.pgdg100+1)": 1, + "13.8 (Debian 13.8-1.pgdg110+1)": 3, + "14.8": 5, + "13.9": 2, + "12.17 (Debian 12.17-1.pgdg120+1)": 12, + "14.7 (Ubuntu 14.7-0ubuntu0.22.04.1)": 1, + "12.14 (Ubuntu 12.14-0ubuntu0.20.04.1)": 3, + "13.11": 3, + "12.15 (Ubuntu 12.15-1.pgdg18.04+1)": 2, + "15.4 (Ubuntu 15.4-1ubuntu1)": 1, + "12.16 (Debian 12.16-1.pgdg110+1)": 1, + "12.15 (Ubuntu 12.15-0ubuntu0.20.04.1)": 1, + "16.0 (Debian 16.0-1.pgdg100+1)": 1, + "15.1 (Debian 15.1-1.pgdg110+1)": 10, + "12.6": 1, + "13.0 (Debian 13.0-1.pgdg100+1)": 1, + "13.9 (Debian 13.9-1.pgdg110+1)": 2, + "14.10 (Debian 14.10-1.pgdg110+1)": 11, + "14.10 (Ubuntu 14.10-1.pgdg20.04+1)": 2, + "14.8 (Debian 14.8-1.pgdg120+1)": 4, + "12.13 (Debian 12.13-1.pgdg110+1)": 4, + "14.6 (Debian 14.6-1.pgdg110+1)": 7, + "12.9": 1, + "13.12 (Debian 13.12-1.pgdg120+1)": 8, + "13.0": 2, + "13.6 (Debian 13.6-1.pgdg110+1)": 4, + "12.14": 2, + "13.10 (Debian 13.10-1.pgdg110+1)": 3, + "16.1 (Ubuntu 16.1-1.pgdg20.04+1)": 1, + "12.15 (Debian 12.15-1.pgdg120+1)": 3, + "12.7 (Debian 12.7-1.pgdg100+1)": 1, + "13.13 (Raspbian 13.13-0+deb11u1)": 4, + "12.14 (Ubuntu 12.14-1.pgdg22.04+1)": 1, + "13.5": 1, + "12.5 (Debian 12.5-1.pgdg100+1)": 1, + "12.17": 10, + "12.12": 2, + "13.7 (Ubuntu 13.7-0ubuntu0.21.10.1)": 1, + "13.9 (Debian 13.9-0+deb11u1)": 3, + "12.15": 1, + "13.2": 3, + "12.7": 3, + "12.3": 1, + "15.4 (Ubuntu 15.4-2.pgdg23.04+1)": 2, + "15.3 (Debian 15.3-0+deb12u1)": 2, + "13.12 (Debian 13.12-1.pgdg110+1)": 1, + "13.8": 1, + "15.4 (Debian 15.4-2.pgdg120+1)": 9, + "12.16 (Ubuntu 12.16-0ubuntu0.20.04.1)": 2, + "15.3 (Ubuntu 15.3-1.pgdg22.04+1)": 3, + "16.1 (Debian 16.1-1)": 1, + "13.12": 4, + "14.6 (Ubuntu 14.6-1.pgdg22.04+1)": 1, + "12.8": 1, + "13.7 (Debian 13.7-1.pgdg110+1)": 2, + "12.4": 1, + "15.1 (Ubuntu 15.1-1.pgdg18.04+1)": 1, + "14.9 (Debian 14.9-1.pgdg110+1)": 2, + "14.0 (Debian 14.0-1.pgdg110+1)": 4, + "13.7 (Debian 13.7-0+deb11u1)": 2, + "15.5 (Debian 15.5-1.pgdg110+1)": 3, + "12.10": 2, + "12.14 (Debian 12.14-1.pgdg110+1)": 3, + "12.13 (Ubuntu 12.13-0ubuntu0.20.04.1)": 1, + "12.2": 1, + "13.9 (Debian 13.9-1.pgdg100+1)": 1, + "14.8 (Ubuntu 14.8-1.pgdg20.04+1)": 1, + "15.0 (Debian 15.0-1.pgdg110+1)": 4, + "13.8 (Debian 13.8-0+deb11u1)": 2, + "13.5 (Debian 13.5-1.pgdg100+1)": 1, + "13.11 (Debian 13.11-1.pgdg110+1)": 2, + "13.4": 3, + "15.0": 3, + "12.17 (Debian 12.17-1.pgdg110+1)": 2, + "14.7 (Ubuntu 14.7-1.pgdg20.04+1)": 1, + "13.11 (Debian 13.11-1.pgdg100+1)": 1, + "15.5 (Ubuntu 15.5-0ubuntu0.23.10.1)": 1, + "14.5 (Ubuntu 14.5-2.pgdg20.04+2)": 1, + "14.2 (Ubuntu 14.2-1ubuntu1)": 1, + "12.11": 1, + "13.7": 2, + "15.4 (Ubuntu 15.4-2.pgdg22.04+1)": 2, + "13.12 (Debian 13.12-1.pgdg100+1)": 1, + "14.4": 2, + "16.0 (Ubuntu 16.0-1.pgdg22.04+1)": 2, + "14.0": 2, + "14.2 (Ubuntu 14.2-1.pgdg20.04+1)": 1, + "12.16": 1, + "13.5 (Ubuntu 13.5-0ubuntu0.21.04.1)": 1, + "12.17 (Ubuntu 12.17-1.pgdg20.04+1)": 1, + "13.3": 2, + "14.8 (Ubuntu 14.8-1.pgdg22.04+1)": 1, + "14.2 (Ubuntu 14.2-1.pgdg18.04+1)": 1, + "15.5 (Debian 15.5-1.pgdg100+1)": 1, + "12.8 (Debian 12.8-1.pgdg100+1)": 1, + "14.10 (Ubuntu 14.10-1.pgdg23.04+1)": 1, + "15.5 (Ubuntu 15.5-1.pgdg20.04+1)": 1, + "13.6": 1 + }, + "count_configured": 2312 + } + } + }, + "extended_data_from": 310156 +} diff --git a/tests/components/analytics_insights/fixtures/integrations.json b/tests/components/analytics_insights/fixtures/integrations.json new file mode 100644 index 00000000000..eb42216c232 --- /dev/null +++ b/tests/components/analytics_insights/fixtures/integrations.json @@ -0,0 +1,9 @@ +{ + "youtube": { + "title": "YouTube", + "description": "Instructions on how to integrate YouTube within Home Assistant.", + "quality_scale": "", + "iot_class": "Cloud Polling", + "integration_type": "service" + } +} diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8a9688cb60c --- /dev/null +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_all_entities[sensor.homeassistant_analytics_myq-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.homeassistant_analytics_myq', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'myq', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'core_myq_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_myq-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics myq', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_myq', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_spotify-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.homeassistant_analytics_spotify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'spotify', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'core_spotify_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_spotify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics spotify', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_spotify', + 'last_changed': , + 'last_updated': , + 'state': '24388', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_youtube-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.homeassistant_analytics_youtube', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'YouTube', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'core_youtube_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_youtube-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics YouTube', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_youtube', + 'last_changed': , + 'last_updated': , + 'state': '339', + }) +# --- diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py new file mode 100644 index 00000000000..4046bd040df --- /dev/null +++ b/tests/components/analytics_insights/test_config_flow.py @@ -0,0 +1,70 @@ +"""Test the Homeassistant Analytics config flow.""" +from unittest.mock import AsyncMock + +from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError + +from homeassistant import config_entries +from homeassistant.components.analytics_insights.const import ( + CONF_TRACKED_INTEGRATIONS, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_analytics_client: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TRACKED_INTEGRATIONS: ["youtube"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Analytics Insights" + assert result["data"] == {} + assert result["options"] == {CONF_TRACKED_INTEGRATIONS: ["youtube"]} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_analytics_client: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + + mock_analytics_client.get_integrations.side_effect = ( + HomeassistantAnalyticsConnectionError + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify"]}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/analytics_insights/test_init.py b/tests/components/analytics_insights/test_init.py new file mode 100644 index 00000000000..08b898f13c1 --- /dev/null +++ b/tests/components/analytics_insights/test_init.py @@ -0,0 +1,28 @@ +"""Test the Home Assistant analytics init module.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.analytics_insights.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.analytics_insights import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/analytics_insights/test_sensor.py b/tests/components/analytics_insights/test_sensor.py new file mode 100644 index 00000000000..83ea2885456 --- /dev/null +++ b/tests/components/analytics_insights/test_sensor.py @@ -0,0 +1,86 @@ +"""Test the Home Assistant analytics sensor module.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from python_homeassistant_analytics import ( + HomeassistantAnalyticsConnectionError, + HomeassistantAnalyticsNotModifiedError, +) +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.analytics_insights.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + + +async def test_connection_error( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_analytics_client.get_current_analytics.side_effect = ( + HomeassistantAnalyticsConnectionError() + ) + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.homeassistant_analytics_spotify").state + == STATE_UNAVAILABLE + ) + + +async def test_data_not_modified( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test not updating data if its not modified.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.homeassistant_analytics_spotify").state == "24388" + mock_analytics_client.get_current_analytics.side_effect = ( + HomeassistantAnalyticsNotModifiedError + ) + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_analytics_client.get_current_analytics.assert_called() + assert hass.states.get("sensor.homeassistant_analytics_spotify").state == "24388" From fa63719161301abb08932712873c2dba093d5155 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 23 Jan 2024 01:50:00 -0800 Subject: [PATCH 0946/1544] Reduce overhead for google calendar state updates (#108133) --- homeassistant/components/google/calendar.py | 30 +++++++++++++++++-- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 3e34a7234a4..88f59ff44f7 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime, timedelta +import itertools import logging from typing import Any, cast @@ -18,6 +19,7 @@ from gcal_sync.model import AccessRole, DateOrDatetime, Event from gcal_sync.store import ScopedCalendarStore from gcal_sync.sync import CalendarEventSyncManager from gcal_sync.timeline import Timeline +from ical.iter import SortableItemValue from homeassistant.components.calendar import ( CREATE_EVENT_SCHEMA, @@ -76,6 +78,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +# Maximum number of upcoming events to consider for state changes between +# coordinator updates. +MAX_UPCOMING_EVENTS = 20 # Avoid syncing super old data on initial syncs. Note that old but active # recurring events are still included. @@ -244,6 +249,22 @@ async def async_setup_entry( ) +def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: + """Truncate the timeline to a maximum number of events. + + This is used to avoid repeated expansion of recurring events during + state machine updates. + """ + upcoming = timeline.active_after(dt_util.now()) + truncated = list(itertools.islice(upcoming, max_events)) + return Timeline( + [ + SortableItemValue(event.timespan_of(dt_util.DEFAULT_TIME_ZONE), event) + for event in truncated + ] + ) + + class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): """Coordinator for calendar RPC calls that use an efficient sync.""" @@ -263,6 +284,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): update_interval=MIN_TIME_BETWEEN_UPDATES, ) self.sync = sync + self._upcoming_timeline: Timeline | None = None async def _async_update_data(self) -> Timeline: """Fetch data from API endpoint.""" @@ -271,9 +293,11 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): except ApiException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - return await self.sync.store_service.async_get_timeline( + timeline = await self.sync.store_service.async_get_timeline( dt_util.DEFAULT_TIME_ZONE ) + self._upcoming_timeline = _truncate_timeline(timeline, MAX_UPCOMING_EVENTS) + return timeline async def async_get_events( self, start_date: datetime, end_date: datetime @@ -291,8 +315,8 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): @property def upcoming(self) -> Iterable[Event] | None: """Return upcoming events if any.""" - if self.data: - return self.data.active_after(dt_util.now()) + if self._upcoming_timeline: + return self._upcoming_timeline.active_after(dt_util.now()) return None diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 27e462a380e..d0705f9382a 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a02be3602c..ca1075a9bf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1088,6 +1088,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.watson_iot ibmiotf==0.3.4 +# homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo ical==6.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9037316fbae..a0eabe72941 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -872,6 +872,7 @@ iaqualink==0.5.0 # homeassistant.components.ibeacon ibeacon-ble==1.0.1 +# homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo ical==6.1.1 From eaa32146a69b2e0e66db3c4ea4bff9e9fcb5bc7f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:56:02 +0100 Subject: [PATCH 0947/1544] Add sensor platform to Proximity (#101497) * add sensor platform * transl. of distance already covered by dev.class * add untested files to .coveragerc * add missing state translations * remove translation key for distance sensor * proximity entity do not use HA number system * fix * extend tests * make const final to be usable as key for TypedDict * remove proximity from .coveragerc * replace typeddict by simple dict definition * make black happy * rework to create proximity sensor for each tracked entity and always recalculate all entites * apply review comments * move direction of travel calc out of the loop * make direction of travel an enum sensor * remove unique_id from sensors * don't set distance=0 when in monitored zone * set None when direction is unknown * keep distance 0 in case arrived for legacy entity * exclude from nearest when in ignored zone * keep distance=0 when arrived * use description name for entity name * remove uneeded typing * uses consistent variable name * fix debug messages * use entity_id as loop var * rename device_state to tracked_entity_state * correct MRO for sensor entity classes --- .../components/proximity/__init__.py | 36 +- homeassistant/components/proximity/const.py | 11 +- .../components/proximity/coordinator.py | 369 +++++---- homeassistant/components/proximity/sensor.py | 138 ++++ .../components/proximity/strings.json | 16 +- tests/components/proximity/test_init.py | 718 ++++++++++++------ 6 files changed, 898 insertions(+), 390 deletions(-) create mode 100644 homeassistant/components/proximity/sensor.py diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 4012d6e8ea1..c4ab915b577 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,15 +5,22 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.const import ( + CONF_DEVICES, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_ZONE, +) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, @@ -46,10 +53,12 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" hass.data.setdefault(DOMAIN, {}) - for zone, proximity_config in config[DOMAIN].items(): - _LOGGER.debug("setup %s with config:%s", zone, proximity_config) + for friendly_name, proximity_config in config[DOMAIN].items(): + _LOGGER.debug("setup %s with config:%s", friendly_name, proximity_config) - coordinator = ProximityDataUpdateCoordinator(hass, zone, proximity_config) + coordinator = ProximityDataUpdateCoordinator( + hass, friendly_name, proximity_config + ) async_track_state_change( hass, @@ -58,12 +67,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await coordinator.async_refresh() - hass.data[DOMAIN][zone] = coordinator + hass.data[DOMAIN][friendly_name] = coordinator - proximity = Proximity(hass, zone, coordinator) + proximity = Proximity(hass, friendly_name, coordinator) await proximity.async_added_to_hass() proximity.async_write_ha_state() + await async_load_platform( + hass, + "sensor", + DOMAIN, + {CONF_NAME: friendly_name, **proximity_config}, + config, + ) return True @@ -91,12 +107,14 @@ class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): @property def state(self) -> str | int | float: """Return the state.""" - return self.coordinator.data["dist_to_zone"] + return self.coordinator.data.proximity[ATTR_DIST_TO] @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - ATTR_DIR_OF_TRAVEL: str(self.coordinator.data["dir_of_travel"]), - ATTR_NEAREST: str(self.coordinator.data["nearest"]), + ATTR_DIR_OF_TRAVEL: str( + self.coordinator.data.proximity[ATTR_DIR_OF_TRAVEL] + ), + ATTR_NEAREST: str(self.coordinator.data.proximity[ATTR_NEAREST]), } diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index a5cee0ffce3..166029fef37 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -1,10 +1,15 @@ """Constants for Proximity integration.""" +from typing import Final + from homeassistant.const import UnitOfLength -ATTR_DIR_OF_TRAVEL = "dir_of_travel" -ATTR_DIST_TO = "dist_to_zone" -ATTR_NEAREST = "nearest" +ATTR_DIR_OF_TRAVEL: Final = "dir_of_travel" +ATTR_DIST_TO: Final = "dist_to_zone" +ATTR_ENTITIES_DATA: Final = "entities_data" +ATTR_IN_IGNORED_ZONE: Final = "is_in_ignored_zone" +ATTR_NEAREST: Final = "nearest" +ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" CONF_TOLERANCE = "tolerance" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 1f1c96c9490..05561bd0406 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -2,11 +2,11 @@ from dataclasses import dataclass import logging -from typing import TypedDict from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_NAME, CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, @@ -19,6 +19,10 @@ from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_IN_IGNORED_ZONE, + ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, DEFAULT_DIR_OF_TRAVEL, @@ -38,12 +42,22 @@ class StateChangedData: new_state: State | None -class ProximityData(TypedDict): - """ProximityData type class.""" +@dataclass +class ProximityData: + """ProximityCoordinatorData class.""" - dist_to_zone: str | float - dir_of_travel: str | float - nearest: str | float + proximity: dict[str, str | float] + entities: dict[str, dict[str, str | int | None]] + + +DEFAULT_DATA = ProximityData( + { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, + }, + {}, +) class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): @@ -54,7 +68,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) -> None: """Initialize the Proximity coordinator.""" self.ignored_zones: list[str] = config[CONF_IGNORED_ZONES] - self.proximity_devices: list[str] = config[CONF_DEVICES] + self.tracked_entities: list[str] = config[CONF_DEVICES] self.tolerance: int = config[CONF_TOLERANCE] self.proximity_zone: str = config[CONF_ZONE] self.unit_of_measurement: str = config.get( @@ -69,11 +83,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): update_interval=None, ) - self.data = { - "dist_to_zone": DEFAULT_DIST_TO_ZONE, - "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, - "nearest": DEFAULT_NEAREST, - } + self.data = DEFAULT_DATA self.state_change_data: StateChangedData | None = None @@ -81,177 +91,216 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self, entity: str, old_state: State | None, new_state: State | None ) -> None: """Fetch and process state change event.""" - if new_state is None: - _LOGGER.debug("no new_state -> abort") - return - self.state_change_data = StateChangedData(entity, old_state, new_state) await self.async_refresh() - async def _async_update_data(self) -> ProximityData: - """Calculate Proximity data.""" - if ( - state_change_data := self.state_change_data - ) is None or state_change_data.new_state is None: - return self.data - - entity_name = state_change_data.new_state.name - devices_to_calculate = False - devices_in_zone = [] - - zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") - proximity_latitude = ( - zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None - ) - proximity_longitude = ( - zone_state.attributes.get(ATTR_LONGITUDE) if zone_state else None + def _convert(self, value: float | str) -> float | str: + """Round and convert given distance value.""" + if isinstance(value, str): + return value + return round( + DistanceConverter.convert( + value, + UnitOfLength.METERS, + self.unit_of_measurement, + ) ) - # Check for devices in the monitored zone. - for device in self.proximity_devices: - if (device_state := self.hass.states.get(device)) is None: - devices_to_calculate = True - continue - - if device_state.state not in self.ignored_zones: - devices_to_calculate = True - - # Check the location of all devices. - if (device_state.state).lower() == (self.proximity_zone).lower(): - device_friendly = device_state.name - devices_in_zone.append(device_friendly) - - # No-one to track so reset the entity. - if not devices_to_calculate: - _LOGGER.debug("no devices_to_calculate -> abort") - return { - "dist_to_zone": DEFAULT_DIST_TO_ZONE, - "dir_of_travel": DEFAULT_DIR_OF_TRAVEL, - "nearest": DEFAULT_NEAREST, - } - - # At least one device is in the monitored zone so update the entity. - if devices_in_zone: - _LOGGER.debug("at least one device is in zone -> arrived") - return { - "dist_to_zone": 0, - "dir_of_travel": "arrived", - "nearest": ", ".join(devices_in_zone), - } - - # We can't check proximity because latitude and longitude don't exist. - if "latitude" not in state_change_data.new_state.attributes: - _LOGGER.debug("no latitude and longitude -> reset") - return self.data - - # Collect distances to the zone for all devices. - distances_to_zone: dict[str, float] = {} - for device in self.proximity_devices: - # Ignore devices in an ignored zone. - device_state = self.hass.states.get(device) - if not device_state or device_state.state in self.ignored_zones: - continue - - # Ignore devices if proximity cannot be calculated. - if "latitude" not in device_state.attributes: - continue - - # Calculate the distance to the proximity zone. - proximity = distance( - proximity_latitude, - proximity_longitude, - device_state.attributes[ATTR_LATITUDE], - device_state.attributes[ATTR_LONGITUDE], + def _calc_distance_to_zone( + self, + zone: State, + device: State, + latitude: float | None, + longitude: float | None, + ) -> int | None: + if device.state.lower() == self.proximity_zone.lower(): + _LOGGER.debug( + "%s: %s in zone -> distance=0", + self.friendly_name, + device.entity_id, ) + return 0 - # Add the device and distance to a dictionary. - if proximity is None: - continue - distances_to_zone[device] = round( - DistanceConverter.convert( - proximity, UnitOfLength.METERS, self.unit_of_measurement - ), - 1, + if latitude is None or longitude is None: + _LOGGER.debug( + "%s: %s has no coordinates -> distance=None", + self.friendly_name, + device.entity_id, ) + return None - # Loop through each of the distances collected and work out the - # closest. - closest_device: str | None = None - dist_to_zone: float | None = None + distance_to_zone = distance( + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + latitude, + longitude, + ) - for device, zone in distances_to_zone.items(): - if not dist_to_zone or zone < dist_to_zone: - closest_device = device - dist_to_zone = zone + # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + assert distance_to_zone is not None + return round(distance_to_zone) - # If the closest device is one of the other devices. - if closest_device is not None and closest_device != state_change_data.entity_id: - _LOGGER.debug("closest device is one of the other devices -> unknown") - device_state = self.hass.states.get(closest_device) - assert device_state - return { - "dist_to_zone": round(distances_to_zone[closest_device]), - "dir_of_travel": "unknown", - "nearest": device_state.name, - } + def _calc_direction_of_travel( + self, + zone: State, + device: State, + old_latitude: float | None, + old_longitude: float | None, + new_latitude: float | None, + new_longitude: float | None, + ) -> str | None: + if device.state.lower() == self.proximity_zone.lower(): + _LOGGER.debug( + "%s: %s in zone -> direction_of_travel=arrived", + self.friendly_name, + device.entity_id, + ) + return "arrived" - # Stop if we cannot calculate the direction of travel (i.e. we don't - # have a previous state and a current LAT and LONG). if ( - state_change_data.old_state is None - or "latitude" not in state_change_data.old_state.attributes + old_latitude is None + or old_longitude is None + or new_latitude is None + or new_longitude is None ): - _LOGGER.debug("no lat and lon in old_state -> unknown") - return { - "dist_to_zone": round(distances_to_zone[state_change_data.entity_id]), - "dir_of_travel": "unknown", - "nearest": entity_name, - } + return None - # Reset the variables - distance_travelled: float = 0 - - # Calculate the distance travelled. old_distance = distance( - proximity_latitude, - proximity_longitude, - state_change_data.old_state.attributes[ATTR_LATITUDE], - state_change_data.old_state.attributes[ATTR_LONGITUDE], + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + old_latitude, + old_longitude, ) new_distance = distance( - proximity_latitude, - proximity_longitude, - state_change_data.new_state.attributes[ATTR_LATITUDE], - state_change_data.new_state.attributes[ATTR_LONGITUDE], + zone.attributes[ATTR_LATITUDE], + zone.attributes[ATTR_LONGITUDE], + new_latitude, + new_longitude, ) - assert new_distance is not None and old_distance is not None + + # it is ensured, that distance can't be None, since zones must have lat/lon coordinates + assert old_distance is not None + assert new_distance is not None distance_travelled = round(new_distance - old_distance, 1) - # Check for tolerance if distance_travelled < self.tolerance * -1: - direction_of_travel = "towards" - elif distance_travelled > self.tolerance: - direction_of_travel = "away_from" - else: - direction_of_travel = "stationary" + return "towards" - # Update the proximity entity - dist_to: float | str - if dist_to_zone is not None: - dist_to = round(dist_to_zone) - else: - dist_to = DEFAULT_DIST_TO_ZONE + if distance_travelled > self.tolerance: + return "away_from" - _LOGGER.debug( - "%s updated: distance=%s: direction=%s: device=%s", - self.friendly_name, - dist_to, - direction_of_travel, - entity_name, - ) + return "stationary" - return { - "dist_to_zone": dist_to, - "dir_of_travel": direction_of_travel, - "nearest": entity_name, + async def _async_update_data(self) -> ProximityData: + """Calculate Proximity data.""" + if (zone_state := self.hass.states.get(f"zone.{self.proximity_zone}")) is None: + _LOGGER.debug( + "%s: zone %s does not exist -> reset", + self.friendly_name, + self.proximity_zone, + ) + return DEFAULT_DATA + + entities_data = self.data.entities + + # calculate distance for all tracked entities + for entity_id in self.tracked_entities: + if (tracked_entity_state := self.hass.states.get(entity_id)) is None: + if entities_data.pop(entity_id, None) is not None: + _LOGGER.debug( + "%s: %s does not exist -> remove", self.friendly_name, entity_id + ) + continue + + if entity_id not in entities_data: + _LOGGER.debug("%s: %s is new -> add", self.friendly_name, entity_id) + entities_data[entity_id] = { + ATTR_DIST_TO: None, + ATTR_DIR_OF_TRAVEL: None, + ATTR_NAME: tracked_entity_state.name, + ATTR_IN_IGNORED_ZONE: False, + } + entities_data[entity_id][ATTR_IN_IGNORED_ZONE] = ( + tracked_entity_state.state.lower() in self.ignored_zones + ) + entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( + zone_state, + tracked_entity_state, + tracked_entity_state.attributes.get(ATTR_LATITUDE), + tracked_entity_state.attributes.get(ATTR_LONGITUDE), + ) + if entities_data[entity_id][ATTR_DIST_TO] is None: + _LOGGER.debug( + "%s: %s has unknown distance got -> direction_of_travel=None", + self.friendly_name, + entity_id, + ) + entities_data[entity_id][ATTR_DIR_OF_TRAVEL] = None + + # calculate direction of travel only for last updated tracked entity + if (state_change_data := self.state_change_data) is not None and ( + new_state := state_change_data.new_state + ) is not None: + _LOGGER.debug( + "%s: calculate direction of travel for %s", + self.friendly_name, + state_change_data.entity_id, + ) + + if (old_state := state_change_data.old_state) is not None: + old_lat = old_state.attributes.get(ATTR_LATITUDE) + old_lon = old_state.attributes.get(ATTR_LONGITUDE) + else: + old_lat = None + old_lon = None + + entities_data[state_change_data.entity_id][ + ATTR_DIR_OF_TRAVEL + ] = self._calc_direction_of_travel( + zone_state, + new_state, + old_lat, + old_lon, + new_state.attributes.get(ATTR_LATITUDE), + new_state.attributes.get(ATTR_LONGITUDE), + ) + + # takeover data for legacy proximity entity + proximity_data: dict[str, str | float] = { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, } + for entity_data in entities_data.values(): + if (distance_to := entity_data[ATTR_DIST_TO]) is None or entity_data[ + ATTR_IN_IGNORED_ZONE + ]: + continue + + if isinstance((nearest_distance_to := proximity_data[ATTR_DIST_TO]), str): + _LOGGER.debug("set first entity_data: %s", entity_data) + proximity_data = { + ATTR_DIST_TO: distance_to, + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_NEAREST: str(entity_data[ATTR_NAME]), + } + continue + + if float(nearest_distance_to) > float(distance_to): + _LOGGER.debug("set closer entity_data: %s", entity_data) + proximity_data = { + ATTR_DIST_TO: distance_to, + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_NEAREST: str(entity_data[ATTR_NAME]), + } + continue + + if float(nearest_distance_to) == float(distance_to): + _LOGGER.debug("set equally close entity_data: %s", entity_data) + proximity_data[ + ATTR_NEAREST + ] = f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" + + proximity_data[ATTR_DIST_TO] = self._convert(proximity_data[ATTR_DIST_TO]) + + return ProximityData(proximity_data, entities_data) diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py new file mode 100644 index 00000000000..44121dcacb4 --- /dev/null +++ b/homeassistant/components/proximity/sensor.py @@ -0,0 +1,138 @@ +"""Support for Proximity sensors.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_NAME, UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTR_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN +from .coordinator import ProximityDataUpdateCoordinator + +SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=ATTR_DIST_TO, + name="Distance", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + name="Direction of travel", + translation_key=ATTR_DIR_OF_TRAVEL, + icon="mdi:compass-outline", + device_class=SensorDeviceClass.ENUM, + options=[ + "arrived", + "away_from", + "stationary", + "towards", + ], + ), +] + +SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=ATTR_NEAREST, + name="Nearest", + translation_key=ATTR_NEAREST, + icon="mdi:near-me", + ), +] + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Proximity sensor platform.""" + if discovery_info is None: + return + + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][ + discovery_info[CONF_NAME] + ] + + entities: list[ProximitySensor | ProximityTrackedEntitySensor] = [ + ProximitySensor(description, coordinator) + for description in SENSORS_PER_PROXIMITY + ] + + entities += [ + ProximityTrackedEntitySensor(description, coordinator, tracked_entity_id) + for description in SENSORS_PER_ENTITY + for tracked_entity_id in coordinator.tracked_entities + ] + + async_add_entities(entities) + + +class ProximitySensor(CoordinatorEntity[ProximityDataUpdateCoordinator], SensorEntity): + """Represents a Proximity sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + description: SensorEntityDescription, + coordinator: ProximityDataUpdateCoordinator, + ) -> None: + """Initialize the proximity.""" + super().__init__(coordinator) + + self.entity_description = description + + # entity name will be removed as soon as we have a config entry + # and can follow the entity naming guidelines + self._attr_name = f"{coordinator.friendly_name} {description.name}" + + @property + def native_value(self) -> str | float | None: + """Return native sensor value.""" + if ( + value := self.coordinator.data.proximity[self.entity_description.key] + ) == "not set": + return None + return value + + +class ProximityTrackedEntitySensor( + CoordinatorEntity[ProximityDataUpdateCoordinator], SensorEntity +): + """Represents a Proximity tracked entity sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + description: SensorEntityDescription, + coordinator: ProximityDataUpdateCoordinator, + tracked_entity_id: str, + ) -> None: + """Initialize the proximity.""" + super().__init__(coordinator) + + self.entity_description = description + self.tracked_entity_id = tracked_entity_id + + # entity name will be removed as soon as we have a config entry + # and can follow the entity naming guidelines + self._attr_name = ( + f"{coordinator.friendly_name} {tracked_entity_id} {description.name}" + ) + + @property + def native_value(self) -> str | float | None: + """Return native sensor value.""" + if (data := self.coordinator.data.entities.get(self.tracked_entity_id)) is None: + return None + return data.get(self.entity_description.key) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 4949ec80ba1..56802e08051 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,3 +1,17 @@ { - "title": "Proximity" + "title": "Proximity", + "entity": { + "sensor": { + "dir_of_travel": { + "name": "Direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "nearest": { "name": "Nearest device" } + } + } } diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index cd96d0d7b81..5a3fee629ac 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -1,10 +1,16 @@ """The tests for the Proximity component.""" + +import pytest + from homeassistant.components.proximity import DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import slugify -async def test_proximities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("friendly_name"), ["home", "home_test2", "work"]) +async def test_proximities(hass: HomeAssistant, friendly_name: str) -> None: """Test a list of proximities.""" config = { "proximity": { @@ -27,19 +33,28 @@ async def test_proximities(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() - proximities = ["home", "home_test2", "work"] + # proximity entity + state = hass.states.get(f"proximity.{friendly_name}") + assert state.state == "not set" + assert state.attributes.get("nearest") == "not set" + assert state.attributes.get("dir_of_travel") == "not set" + hass.states.async_set(f"proximity.{friendly_name}", "0") + await hass.async_block_till_done() + state = hass.states.get(f"proximity.{friendly_name}") + assert state.state == "0" - for prox in proximities: - state = hass.states.get(f"proximity.{prox}") - assert state.state == "not set" - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" + # sensor entities + state = hass.states.get(f"sensor.{friendly_name}_nearest") + assert state.state == STATE_UNKNOWN - hass.states.async_set(f"proximity.{prox}", "0") - await hass.async_block_till_done() - state = hass.states.get(f"proximity.{prox}") - assert state.state == "0" + for device in config["proximity"][friendly_name]["devices"]: + entity_base_name = f"sensor.{friendly_name}_{slugify(device)}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN async def test_proximities_setup(hass: HomeAssistant) -> None: @@ -58,31 +73,6 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) -async def test_proximity(hass: HomeAssistant) -> None: - """Test the proximity.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - state = hass.states.get("proximity.home") - assert state.state == "not set" - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" - - hass.states.async_set("proximity.home", "0") - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.state == "0" - - async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: """Test for tracker in zone.""" config = { @@ -103,11 +93,317 @@ async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: {"friendly_name": "test1", "latitude": 2.1, "longitude": 1.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.state == "0" assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "arrived" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "0" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "arrived" + + +async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: + """Test for tracker state away.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "11912010" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: + """Test for tracker state away further.""" + + config_zones(hass) + await hass.async_block_till_done() + + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "away_from" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "away_from" + + +async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: + """Test for tracker state away closer.""" + config_zones(hass) + await hass.async_block_till_done() + + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "towards" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "towards" + + +async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: + """Test for tracker in ignored zone.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.state == "not set" + assert state.attributes.get("nearest") == "not set" + assert state.attributes.get("dir_of_travel") == "not set" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: + """Test for tracker with no coordinates.""" + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": "1", + } + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set( + "device_tracker.test1", "not_home", {"friendly_name": "test1"} + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "not set" + assert state.attributes.get("dir_of_travel") == "not set" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> None: + """Test for tracker states.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1"], + "tolerance": 1000, + "zone": "home", + } + } + }, + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1000001, "longitude": 10.1000001}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "unknown" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "11912010" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1000002, "longitude": 10.1000002}, + ) + await hass.async_block_till_done() + + # proximity entity + state = hass.states.get("proximity.home") + assert state.attributes.get("nearest") == "test1" + assert state.attributes.get("dir_of_travel") == "stationary" + + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "11912010" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "stationary" + async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: """Test for trackers in zone.""" @@ -135,6 +431,8 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: {"friendly_name": "test2", "latitude": 2.1, "longitude": 1.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.state == "0" assert (state.attributes.get("nearest") == "test1, test2") or ( @@ -142,153 +440,16 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: ) assert state.attributes.get("dir_of_travel") == "arrived" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1, test2" -async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: - """Test for tracker state away.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - -async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: - """Test for tracker state away further.""" - - config_zones(hass) - await hass.async_block_till_done() - - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "away_from" - - -async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: - """Test for tracker state away closer.""" - config_zones(hass) - await hass.async_block_till_done() - - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 40.1, "longitude": 20.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "towards" - - -async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: - """Test for tracker in ignored zone.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.state == "not set" - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" - - -async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: - """Test for tracker with no coordinates.""" - config = { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": "1", - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - - hass.states.async_set( - "device_tracker.test1", "not_home", {"friendly_name": "test1"} - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "not set" - assert state.attributes.get("dir_of_travel") == "not set" + for device in ["device_tracker.test1", "device_tracker.test2"]: + entity_base_name = f"sensor.home_{slugify(device)}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "0" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "arrived" async def test_device_tracker_test1_awayfurther_than_test2_first_test1( @@ -328,20 +489,56 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "not_home", {"friendly_name": "test2", "latitude": 40.1, "longitude": 20.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_than_test2_first_test2( hass: HomeAssistant, @@ -378,20 +575,56 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( {"friendly_name": "test2", "latitude": 40.1, "longitude": 20.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test2" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test1", "not_home", {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "4625264" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( hass: HomeAssistant, @@ -423,10 +656,28 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "11912010" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + async def test_device_tracker_test1_awayfurther_test2_first( hass: HomeAssistant, @@ -489,47 +740,26 @@ async def test_device_tracker_test1_awayfurther_test2_first( hass.states.async_set("device_tracker.test1", "work", {"friendly_name": "test1"}) await hass.async_block_till_done() + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test2" -async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> None: - """Test for tracker states.""" - assert await async_setup_component( - hass, - DOMAIN, - { - "proximity": { - "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1"], - "tolerance": 1000, - "zone": "home", - } - } - }, - ) + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1000001, "longitude": 10.1000001}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "unknown" - - hass.states.async_set( - "device_tracker.test1", - "not_home", - {"friendly_name": "test1", "latitude": 20.1000002, "longitude": 10.1000002}, - ) - await hass.async_block_till_done() - state = hass.states.get("proximity.home") - assert state.attributes.get("nearest") == "test1" - assert state.attributes.get("dir_of_travel") == "stationary" + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( @@ -568,30 +798,84 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "not_home", {"friendly_name": "test2", "latitude": 10.1, "longitude": 5.1}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test2" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test2" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "989156" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + hass.states.async_set( "device_tracker.test2", "work", {"friendly_name": "test2", "latitude": 12.6, "longitude": 7.6}, ) await hass.async_block_till_done() + + # proximity entity state = hass.states.get("proximity.home") assert state.attributes.get("nearest") == "test1" assert state.attributes.get("dir_of_travel") == "unknown" + # sensor entities + state = hass.states.get("sensor.home_nearest") + assert state.state == "test1" + + entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218752" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1364567" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == "away_from" + def config_zones(hass): """Set up zones for test.""" From 65581e94ea4b75a832fb1dbccfc1fbfb395711a5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 23 Jan 2024 12:18:31 +0100 Subject: [PATCH 0948/1544] Add config flow for Time & Date (#104183) Co-authored-by: Erik --- .../components/time_date/__init__.py | 17 +++ .../components/time_date/config_flow.py | 130 +++++++++++++++++ homeassistant/components/time_date/const.py | 16 ++ .../components/time_date/manifest.json | 2 + homeassistant/components/time_date/sensor.py | 80 +++++++--- .../components/time_date/strings.json | 77 +++++++++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 +- homeassistant/helpers/entity_component.py | 4 +- homeassistant/helpers/entity_platform.py | 91 ++++++------ tests/components/time_date/__init__.py | 29 ++++ tests/components/time_date/conftest.py | 14 ++ .../components/time_date/test_config_flow.py | 138 ++++++++++++++++++ tests/components/time_date/test_init.py | 18 +++ tests/components/time_date/test_sensor.py | 38 +++-- 15 files changed, 573 insertions(+), 88 deletions(-) create mode 100644 homeassistant/components/time_date/config_flow.py create mode 100644 tests/components/time_date/conftest.py create mode 100644 tests/components/time_date/test_config_flow.py create mode 100644 tests/components/time_date/test_init.py diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 25e6fa14f39..cdd69a2bc1f 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -1 +1,18 @@ """The time_date component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Time & Date from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Time & Date config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py new file mode 100644 index 00000000000..09a5f2503d0 --- /dev/null +++ b/homeassistant/components/time_date/config_flow.py @@ -0,0 +1,130 @@ +"""Adds config flow for Time & Date integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.setup import async_prepare_setup_platform + +from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES +from .sensor import TimeDateSensor + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( + SelectSelectorConfig( + options=[option for option in OPTION_TYPES if option != "beat"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="display_options", + ) + ), + } +) + + +async def validate_input( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate rest setup.""" + hass = handler.parent_handler.hass + if hass.config.time_zone is None: + raise SchemaFlowError("timezone_not_exist") + return user_input + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=USER_SCHEMA, + preview=DOMAIN, + validate_user_input=validate_input, + ) +} + + +class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Time & Date.""" + + config_flow = CONFIG_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return f"Time & Date {options[CONF_DISPLAY_OPTIONS]}" + + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Abort if instance already exist.""" + self._async_abort_entries_match(dict(options)) + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "time_date/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + validated = USER_SCHEMA(msg["user_input"]) + + # Create an EntityPlatform, needed for name translations + platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) + entity_platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=SENSOR_DOMAIN, + platform_name=DOMAIN, + platform=platform, + scan_interval=timedelta(seconds=3600), + entity_namespace=None, + ) + await entity_platform.async_load_translations() + + @callback + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: + """Forward config entry state events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], {"attributes": attributes, "state": state} + ) + ) + + preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) + preview_entity.hass = hass + preview_entity.platform = entity_platform + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py index 4d0ff354a6c..dde9497b9a3 100644 --- a/homeassistant/components/time_date/const.py +++ b/homeassistant/components/time_date/const.py @@ -3,4 +3,20 @@ from __future__ import annotations from typing import Final +from homeassistant.const import Platform + +CONF_DISPLAY_OPTIONS = "display_options" DOMAIN: Final = "time_date" +PLATFORMS = [Platform.SENSOR] +TIME_STR_FORMAT = "%H:%M" + +OPTION_TYPES = [ + "time", + "date", + "date_time", + "date_time_utc", + "date_time_iso", + "time_date", + "beat", + "time_utc", +] diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json index 9d625b8587e..9247b60568a 100644 --- a/homeassistant/components/time_date/manifest.json +++ b/homeassistant/components/time_date/manifest.json @@ -2,7 +2,9 @@ "domain": "time_date", "name": "Time & Date", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/time_date", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index c00d362428b..bd0f9449aea 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,12 +1,19 @@ """Support for showing the date and the time.""" from __future__ import annotations +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging +from typing import Any import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -16,22 +23,12 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, OPTION_TYPES _LOGGER = logging.getLogger(__name__) TIME_STR_FORMAT = "%H:%M" -OPTION_TYPES = { - "time": "Time", - "date": "Date", - "date_time": "Date & Time", - "date_time_utc": "Date & Time (UTC)", - "date_time_iso": "Date & Time (ISO)", - "time_date": "Time & Date", - "beat": "Internet Time", - "time_utc": "Time (UTC)", -} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -71,7 +68,17 @@ async def async_setup_platform( _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") async_add_entities( - [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] + [TimeDateSensor(variable) for variable in config[CONF_DISPLAY_OPTIONS]] + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Time & Date sensor.""" + + async_add_entities( + [TimeDateSensor(entry.options[CONF_DISPLAY_OPTIONS], entry.entry_id)] ) @@ -79,19 +86,19 @@ class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" _attr_should_poll = False + _attr_has_entity_name = True + _state: str | None = None + unsub: CALLBACK_TYPE | None = None - def __init__(self, hass: HomeAssistant, option_type: str) -> None: + def __init__(self, option_type: str, entry_id: str | None = None) -> None: """Initialize the sensor.""" - self._name = OPTION_TYPES[option_type] + self._attr_translation_key = option_type self.type = option_type - self._state: str | None = None - self.hass = hass - self.unsub: CALLBACK_TYPE | None = None + object_id = "internet_time" if option_type == "beat" else option_type + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._attr_unique_id = option_type if entry_id else None - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name + self._update_internal_state(dt_util.utcnow()) @property def native_value(self) -> str | None: @@ -107,6 +114,35 @@ class TimeDateSensor(SensorEntity): return "mdi:calendar" return "mdi:clock" + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def point_in_time_listener(time_date: datetime | None) -> None: + """Update preview.""" + + now = dt_util.utcnow() + self._update_internal_state(now) + self.unsub = async_track_point_in_utc_time( + self.hass, point_in_time_listener, self.get_next_interval(now) + ) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) + + @callback + def async_stop_preview() -> None: + """Stop preview.""" + if self.unsub: + self.unsub() + self.unsub = None + + point_in_time_listener(None) + return async_stop_preview + async def async_added_to_hass(self) -> None: """Set up first update.""" diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 582fd44a45b..e9efe949b9b 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -1,8 +1,83 @@ { + "title": "Time & Date", + "config": { + "abort": { + "already_configured": "The chosen Time & Date sensor has already been configured" + }, + "step": { + "user": { + "description": "Select from the sensor options below", + "data": { + "display_options": "Sensor type" + } + } + }, + "error": { + "timezone_not_exist": "Timezone is not set in Home Assistant configuration" + } + }, + "options": { + "step": { + "init": { + "data": { + "display_options": "[%key:component::time_date::config::step::user::data::display_options%]" + } + } + } + }, + "selector": { + "display_options": { + "options": { + "time": "Time", + "date": "Date", + "date_time": "Date & Time", + "date_time_utc": "Date & Time (UTC)", + "date_time_iso": "Date & Time (ISO)", + "time_date": "Time & Date", + "beat": "Internet time", + "time_utc": "Time (UTC)" + } + } + }, + "entity": { + "sensor": { + "time": { + "name": "[%key:component::time_date::selector::display_options::options::time%]" + }, + "date": { + "name": "[%key:component::time_date::selector::display_options::options::date%]" + }, + "date_time": { + "name": "[%key:component::time_date::selector::display_options::options::date_time%]" + }, + "date_time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_utc%]" + }, + "date_time_iso": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_iso%]" + }, + "time_date": { + "name": "[%key:component::time_date::selector::display_options::options::time_date%]" + }, + "beat": { + "name": "[%key:component::time_date::selector::display_options::options::beat%]" + }, + "time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::time_utc%]" + } + } + }, "issues": { "deprecated_beat": { "title": "The `{config_key}` Time & Date sensor is being removed", - "description": "Please remove the `{config_key}` key from the `{display_options}` for the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::time_date::issues::deprecated_beat::title%]", + "description": "Please remove the `{config_key}` key from the {integration} config entry options and click submit to fix this issue." + } + } + } } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5e88b2f9e8a..61edf91b154 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -520,6 +520,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "time_date", "todoist", "tolo", "tomorrowio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bbc5476896a..cee10c5ff51 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6026,9 +6026,8 @@ "iot_class": "local_push" }, "time_date": { - "name": "Time & Date", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_push" }, "tmb": { @@ -7051,6 +7050,7 @@ "switch_as_x", "tag", "threshold", + "time_date", "tod", "uptime", "utility_meter", diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e49acc71d07..5020c5c4271 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -382,7 +382,7 @@ class EntityComponent(Generic[_EntityT]): if scan_interval is None: scan_interval = self.scan_interval - return EntityPlatform( + entity_platform = EntityPlatform( hass=self.hass, logger=self.logger, domain=self.domain, @@ -391,6 +391,8 @@ class EntityComponent(Generic[_EntityT]): scan_interval=scan_interval, entity_namespace=entity_namespace, ) + entity_platform.async_prepare() + return entity_platform async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e1c05c21828..b9336a62e6e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -145,10 +145,6 @@ class EntityPlatform: # which powers entity_component.add_entities self.parallel_updates_created = platform is None - hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( - self.platform_name, [] - ).append(self) - self.domain_entities: dict[str, Entity] = hass.data.setdefault( DATA_DOMAIN_ENTITIES, {} ).setdefault(domain, {}) @@ -310,44 +306,8 @@ class EntityPlatform: logger = self.logger hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - async def get_translations( - language: str, category: str, integration: str - ) -> dict[str, Any]: - """Get entity translations.""" - try: - return await translation.async_get_translations( - hass, language, category, {integration} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - self.component_translations = await get_translations( - hass.config.language, "entity_component", self.domain - ) - self.platform_translations = await get_translations( - hass.config.language, "entity", self.platform_name - ) - if object_id_language == hass.config.language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await get_translations( - object_id_language, "entity", self.platform_name - ) + await self.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -430,6 +390,48 @@ class EntityPlatform: finally: warn_task.cancel() + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + + async def get_translations( + language: str, category: str, integration: str + ) -> dict[str, Any]: + """Get entity translations.""" + try: + return await translation.async_get_translations( + hass, language, category, {integration} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + self.component_translations = await get_translations( + hass.config.language, "entity_component", self.domain + ) + self.platform_translations = await get_translations( + hass.config.language, "entity", self.platform_name + ) + if object_id_language == hass.config.language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await get_translations( + object_id_language, "entity", self.platform_name + ) + def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -783,6 +785,13 @@ class EntityPlatform: self._async_unsub_polling() self._async_unsub_polling = None + @callback + def async_prepare(self) -> None: + """Register the entity platform in DATA_ENTITY_PLATFORM.""" + self.hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( + self.platform_name, [] + ).append(self) + async def async_destroy(self) -> None: """Destroy an entity platform. diff --git a/tests/components/time_date/__init__.py b/tests/components/time_date/__init__.py index 22734c19bbb..9817271a8d9 100644 --- a/tests/components/time_date/__init__.py +++ b/tests/components/time_date/__init__.py @@ -1 +1,30 @@ """Tests for the time_date component.""" + +from homeassistant.components.time_date.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DISPLAY_OPTIONS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def load_int( + hass: HomeAssistant, display_option: str | None = None +) -> MockConfigEntry: + """Set up the Time & Date integration in Home Assistant.""" + if display_option is None: + display_option = "time" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={CONF_DISPLAY_OPTIONS: display_option}, + entry_id=f"1234567890_{display_option}", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/time_date/conftest.py b/tests/components/time_date/conftest.py new file mode 100644 index 00000000000..af732f978b4 --- /dev/null +++ b/tests/components/time_date/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Time & Date integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.time_date.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py new file mode 100644 index 00000000000..228a34b65b4 --- /dev/null +++ b/tests/components/time_date/test_config_flow.py @@ -0,0 +1,138 @@ +"""Test the Time & Date config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": "time"}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_does_not_allow_beat( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with pytest.raises(vol.Invalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["beat"]}, + ) + + +async def test_single_instance(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: "time"} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": "time"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_timezone_not_set(hass: HomeAssistant) -> None: + """Test time zone not set.""" + hass.config.time_zone = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": "time"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timezone_not_exist"} + + +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + freezer.move_to("2024-01-02 20:14:11.672") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["preview"] == "time_date" + + await client.send_json_auto_id( + { + "type": "time_date/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"display_options": "time"}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + } + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:15", + } + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/time_date/test_init.py b/tests/components/time_date/test_init.py new file mode 100644 index 00000000000..cd7c5044201 --- /dev/null +++ b/tests/components/time_date/test_init.py @@ -0,0 +1,18 @@ +"""The tests for the Time & Date component.""" + +from homeassistant.core import HomeAssistant + +from . import load_int + + +async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: + """Test setting up and removing a config entry.""" + entry = await load_int(hass) + + state = hass.states.get("sensor.time") + assert state is not None + + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.time") is None diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index e8741a43427..d7e87b3a471 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -5,17 +5,15 @@ from unittest.mock import ANY, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.time_date.const import DOMAIN -import homeassistant.components.time_date.sensor as time_date +from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event, issue_registry as ir from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from . import load_int -ALL_DISPLAY_OPTIONS = list(time_date.OPTION_TYPES.keys()) -CONFIG = {"sensor": {"platform": "time_date", "display_options": ALL_DISPLAY_OPTIONS}} +from tests.common import async_fire_time_changed @patch("homeassistant.components.time_date.sensor.async_track_point_in_utc_time") @@ -54,12 +52,9 @@ async def test_intervals( ) -> None: """Test timing intervals of sensors when time zone is UTC.""" hass.config.set_time_zone("UTC") - config = {"sensor": {"platform": "time_date", "display_options": [display_option]}} - freezer.move_to(start_time) - await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await load_int(hass, display_option) mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) @@ -70,8 +65,8 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.state == "00:54" @@ -130,8 +125,8 @@ async def test_states_non_default_timezone( now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.state == "20:54" @@ -262,9 +257,7 @@ async def test_timezone_intervals( hass.config.set_time_zone(time_zone) freezer.move_to(start_time) - config = {"sensor": {"platform": "time_date", "display_options": ["date"]}} - await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await load_int(hass, "date") mock_track_interval.assert_called_once() next_time = mock_track_interval.mock_calls[0][1][2] @@ -274,8 +267,8 @@ async def test_timezone_intervals( async def test_icons(hass: HomeAssistant) -> None: """Test attributes of sensors.""" - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.attributes["icon"] == "mdi:clock" @@ -313,9 +306,14 @@ async def test_deprecation_warning( expected_issues: list[str], ) -> None: """Test deprecation warning for swatch beat.""" - config = {"sensor": {"platform": "time_date", "display_options": display_options}} + config = { + "sensor": { + "platform": "time_date", + "display_options": display_options, + } + } - await async_setup_component(hass, "sensor", config) + assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() warnings = [record for record in caplog.records if record.levelname == "WARNING"] From 2e19829d8869339b4f96e49a07b8c671c47cf586 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 23 Jan 2024 13:03:16 +0100 Subject: [PATCH 0949/1544] Use new config entry update/abort handler in co2signal (#108715) --- homeassistant/components/co2signal/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index dfa1e25d7d8..2b2aca0b229 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -152,16 +152,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: if self._reauth_entry: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data={ CONF_API_KEY: data[CONF_API_KEY], }, ) - await self.hass.config_entries.async_reload( - self._reauth_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") return self.async_create_entry( title=get_extra_name(data) or "CO2 Signal", From 5dbcdfc6fb02ef27f3b1de664256b08d84322943 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 23 Jan 2024 13:05:40 +0100 Subject: [PATCH 0950/1544] Bump python-homeassistant-analytics to 0.6.0 (#108713) --- homeassistant/components/analytics_insights/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index 0dfb1396a72..d33bb23b1b7 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.5.0"] + "requirements": ["python-homeassistant-analytics==0.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca1075a9bf5..b734ac8b853 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.5.0 +python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard python-homewizard-energy==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0eabe72941..111a4a7d8ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ python-ecobee-api==0.2.17 python-fullykiosk==0.0.12 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.5.0 +python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard python-homewizard-energy==4.1.0 From 701404fa0b765eb009fc5f7260f7201927477f01 Mon Sep 17 00:00:00 2001 From: Peter Hall Date: Wed, 24 Jan 2024 00:31:32 +1100 Subject: [PATCH 0951/1544] Add ZHA entities for snzb06p (#107379) * Updating zha component to add entities for snzb06p Sonoff snzb06p presence detector needs some custom entities. * Updating ZCL_INIT_ATTRS for sonoff specific attrs * updating cluster name due to change in quirk --- .../zha/core/cluster_handlers/helpers.py | 7 ++++++ .../cluster_handlers/manufacturerspecific.py | 8 +++++++ .../zha/core/cluster_handlers/measurement.py | 6 ++++- homeassistant/components/zha/number.py | 19 ++++++++++++++++ homeassistant/components/zha/select.py | 21 ++++++++++++++++++ homeassistant/components/zha/sensor.py | 22 +++++++++++++++++++ homeassistant/components/zha/strings.json | 9 ++++++++ 7 files changed, 91 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/helpers.py b/homeassistant/components/zha/core/cluster_handlers/helpers.py index 17bc5763977..f4444f3995c 100644 --- a/homeassistant/components/zha/core/cluster_handlers/helpers.py +++ b/homeassistant/components/zha/core/cluster_handlers/helpers.py @@ -13,3 +13,10 @@ def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: "SML003", "SML004", ) + + +def is_sonoff_presence_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Sonoff sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "SONOFF", + ) and cluster_handler.cluster.endpoint.model in ("SNZB-06P",) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 57f1e2ee304..2acf6b7e5e4 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -423,3 +423,11 @@ class IkeaRemote(ClusterHandler): ) class XiaomiVibrationAQ1ClusterHandler(MultistateInput): """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC11) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC11) +class SonoffPresenceSenor(ClusterHandler): + """SonoffPresenceSensor cluster handler.""" + + ZCL_INIT_ATTRS = {"last_illumination_state": True} diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index bd483920842..5249c196864 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -14,7 +14,7 @@ from ..const import ( REPORT_CONFIG_MIN_INT, ) from . import AttrReportConfig, ClusterHandler -from .helpers import is_hue_motion_sensor +from .helpers import is_hue_motion_sensor, is_sonoff_presence_sensor if TYPE_CHECKING: from ..endpoint import Endpoint @@ -69,6 +69,10 @@ class OccupancySensing(ClusterHandler): if is_hue_motion_sensor(self): self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["sensitivity"] = True + if is_sonoff_presence_sensor(self): + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["ultrasonic_o_to_u_delay"] = True + self.ZCL_INIT_ATTRS["ultrasonic_u_to_o_threshold"] = True @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 24964d7a154..c3c4c0b604a 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,6 +20,7 @@ from .core.const import ( CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -966,3 +967,21 @@ class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): + """Configuration of Sonoff sensor presence detection timeout.""" + + _unique_id_suffix = "presence_detection_timeout" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: int = 15 + _attr_native_max_value: int = 60 + _attribute_name = "ultrasonic_o_to_u_delay" + _attr_translation_key: str = "presence_detection_timeout" + + _attr_mode: NumberMode = NumberMode.BOX + _attr_icon: str = "mdi:timer-edit" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 1c13779209d..5c32ca44dee 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -25,6 +25,7 @@ from .core.const import ( CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -652,3 +653,23 @@ class AqaraThermostatPreset(ZCLEnumSelectEntity): _attribute_name = "preset" _enum = AqaraThermostatPresetMode _attr_translation_key: str = "preset" + + +class SonoffPresenceDetectionSensitivityEnum(types.enum8): + """Enum for detection sensitivity select entity.""" + + Low = 0x01 + Medium = 0x02 + High = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} +) +class SonoffPresenceDetectionSensitivity(ZCLEnumSelectEntity): + """Entity to set the detection sensitivity of the Sonoff SNZB-06P.""" + + _unique_id_suffix = "detection_sensitivity" + _attribute_name = "ultrasonic_u_to_o_threshold" + _enum = SonoffPresenceDetectionSensitivityEnum + _attr_translation_key: str = "detection_sensitivity" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 9de4bcf75f5..b4531dc3f68 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1214,3 +1214,25 @@ class AqaraSmokeDensityDbm(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_icon: str = "mdi:google-circles-communities" _attr_suggested_display_precision: int = 3 + + +class SonoffIlluminationStates(types.enum8): + """Enum for displaying last Illumination state.""" + + Dark = 0x00 + Light = 0x01 + + +@MULTI_MATCH(cluster_handler_names="sonoff_manufacturer", models={"SNZB-06P"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorIlluminationStatus(Sensor): + """Sensor that displays the illumination status the last time peresence was detected.""" + + _attribute_name = "last_illumination_state" + _unique_id_suffix = "last_illumination" + _attr_translation_key: str = "last_illumination_state" + _attr_icon: str = "mdi:theme-light-dark" + + def formatter(self, value: int) -> int | float | None: + """Numeric pass-through formatter.""" + return SonoffIlluminationStates(value).name diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index a4a53b3c1b4..a47e83fcf4b 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -727,6 +727,9 @@ }, "local_temperature_calibration": { "name": "Local temperature offset" + }, + "presence_detection_timeout": { + "name": "Presence detection timeout" } }, "select": { @@ -792,6 +795,9 @@ }, "decoupled_mode": { "name": "Decoupled mode" + }, + "detection_sensitivity": { + "name": "Detection Sensitivity" } }, "sensor": { @@ -869,6 +875,9 @@ }, "smoke_density": { "name": "Smoke density" + }, + "last_illumination_state": { + "name": "Last illumination state" } }, "switch": { From e3a73c12bc5919e87d0641465211787b7c81e624 Mon Sep 17 00:00:00 2001 From: Dave Leaver Date: Wed, 24 Jan 2024 02:49:47 +1300 Subject: [PATCH 0952/1544] Add airtouch5 (#98136) Co-authored-by: Robert Resch --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/airtouch5/__init__.py | 50 +++ homeassistant/components/airtouch5/climate.py | 371 ++++++++++++++++++ .../components/airtouch5/config_flow.py | 46 +++ homeassistant/components/airtouch5/const.py | 6 + homeassistant/components/airtouch5/entity.py | 40 ++ .../components/airtouch5/manifest.json | 10 + .../components/airtouch5/strings.json | 32 ++ 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/airtouch5/__init__.py | 1 + tests/components/airtouch5/conftest.py | 14 + .../components/airtouch5/test_config_flow.py | 62 +++ 18 files changed, 661 insertions(+) create mode 100644 homeassistant/components/airtouch5/__init__.py create mode 100644 homeassistant/components/airtouch5/climate.py create mode 100644 homeassistant/components/airtouch5/config_flow.py create mode 100644 homeassistant/components/airtouch5/const.py create mode 100644 homeassistant/components/airtouch5/entity.py create mode 100644 homeassistant/components/airtouch5/manifest.json create mode 100644 homeassistant/components/airtouch5/strings.json create mode 100644 tests/components/airtouch5/__init__.py create mode 100644 tests/components/airtouch5/conftest.py create mode 100644 tests/components/airtouch5/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d0ce82dd735..9da67cad748 100644 --- a/.coveragerc +++ b/.coveragerc @@ -46,6 +46,9 @@ omit = homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/coordinator.py + homeassistant/components/airtouch5/__init__.py + homeassistant/components/airtouch5/climate.py + homeassistant/components/airtouch5/entity.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py diff --git a/.strict-typing b/.strict-typing index 8ffb02024c9..be0089a4333 100644 --- a/.strict-typing +++ b/.strict-typing @@ -53,6 +53,7 @@ homeassistant.components.airnow.* homeassistant.components.airq.* homeassistant.components.airthings.* homeassistant.components.airthings_ble.* +homeassistant.components.airtouch5.* homeassistant.components.airvisual.* homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* diff --git a/CODEOWNERS b/CODEOWNERS index 8ad0c7e5273..1378e0f776a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,8 @@ build.json @home-assistant/supervisor /tests/components/airthings_ble/ @vincegio @LaStrada /homeassistant/components/airtouch4/ @samsinnamon /tests/components/airtouch4/ @samsinnamon +/homeassistant/components/airtouch5/ @danzel +/tests/components/airtouch5/ @danzel /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya diff --git a/homeassistant/components/airtouch5/__init__.py b/homeassistant/components/airtouch5/__init__.py new file mode 100644 index 00000000000..6ec32eaa021 --- /dev/null +++ b/homeassistant/components/airtouch5/__init__.py @@ -0,0 +1,50 @@ +"""The Airtouch 5 integration.""" +from __future__ import annotations + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airtouch 5 from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + # Create API instance + host = entry.data[CONF_HOST] + client = Airtouch5SimpleClient(host) + + # Connect to the API + try: + await client.connect_and_stay_connected() + except TimeoutError as t: + raise ConfigEntryNotReady() from t + + # Store an API object for your platforms to access + hass.data[DOMAIN][entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + client: Airtouch5SimpleClient = hass.data[DOMAIN][entry.entry_id] + await client.disconnect() + client.ac_status_callbacks.clear() + client.connection_state_callbacks.clear() + client.data_packet_callbacks.clear() + client.zone_status_callbacks.clear() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py new file mode 100644 index 00000000000..829915ce6d1 --- /dev/null +++ b/homeassistant/components/airtouch5/climate.py @@ -0,0 +1,371 @@ +"""AirTouch 5 component to control AirTouch 5 Climate Devices.""" +import logging +from typing import Any + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient +from airtouch5py.packets.ac_ability import AcAbility +from airtouch5py.packets.ac_control import ( + AcControl, + SetAcFanSpeed, + SetAcMode, + SetpointControl, + SetPowerSetting, +) +from airtouch5py.packets.ac_status import AcFanSpeed, AcMode, AcPowerState, AcStatus +from airtouch5py.packets.zone_control import ( + ZoneControlZone, + ZoneSettingPower, + ZoneSettingValue, +) +from airtouch5py.packets.zone_name import ZoneName +from airtouch5py.packets.zone_status import ZonePowerState, ZoneStatusZone + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_BOOST, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FAN_INTELLIGENT_AUTO, FAN_TURBO +from .entity import Airtouch5Entity + +_LOGGER = logging.getLogger(__name__) + +AC_MODE_TO_HVAC_MODE = { + AcMode.AUTO: HVACMode.AUTO, + AcMode.AUTO_COOL: HVACMode.AUTO, + AcMode.AUTO_HEAT: HVACMode.AUTO, + AcMode.COOL: HVACMode.COOL, + AcMode.DRY: HVACMode.DRY, + AcMode.FAN: HVACMode.FAN_ONLY, + AcMode.HEAT: HVACMode.HEAT, +} +HVAC_MODE_TO_SET_AC_MODE = { + HVACMode.AUTO: SetAcMode.SET_TO_AUTO, + HVACMode.COOL: SetAcMode.SET_TO_COOL, + HVACMode.DRY: SetAcMode.SET_TO_DRY, + HVACMode.FAN_ONLY: SetAcMode.SET_TO_FAN, + HVACMode.HEAT: SetAcMode.SET_TO_HEAT, +} + + +AC_FAN_SPEED_TO_FAN_SPEED = { + AcFanSpeed.AUTO: FAN_AUTO, + AcFanSpeed.QUIET: FAN_DIFFUSE, + AcFanSpeed.LOW: FAN_LOW, + AcFanSpeed.MEDIUM: FAN_MEDIUM, + AcFanSpeed.HIGH: FAN_HIGH, + AcFanSpeed.POWERFUL: FAN_FOCUS, + AcFanSpeed.TURBO: FAN_TURBO, + AcFanSpeed.INTELLIGENT_AUTO_1: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_2: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_3: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_4: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_5: FAN_INTELLIGENT_AUTO, + AcFanSpeed.INTELLIGENT_AUTO_6: FAN_INTELLIGENT_AUTO, +} +FAN_MODE_TO_SET_AC_FAN_SPEED = { + FAN_AUTO: SetAcFanSpeed.SET_TO_AUTO, + FAN_DIFFUSE: SetAcFanSpeed.SET_TO_QUIET, + FAN_LOW: SetAcFanSpeed.SET_TO_LOW, + FAN_MEDIUM: SetAcFanSpeed.SET_TO_MEDIUM, + FAN_HIGH: SetAcFanSpeed.SET_TO_HIGH, + FAN_FOCUS: SetAcFanSpeed.SET_TO_POWERFUL, + FAN_TURBO: SetAcFanSpeed.SET_TO_TURBO, + FAN_INTELLIGENT_AUTO: SetAcFanSpeed.SET_TO_INTELLIGENT_AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airtouch 5 Climate entities.""" + client: Airtouch5SimpleClient = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ClimateEntity] = [] + + # Add each AC (and remember what zones they apply to). + # Each zone is controlled by a single AC + zone_to_ac: dict[int, AcAbility] = {} + for ac in client.ac: + for i in range(ac.start_zone_number, ac.start_zone_number + ac.zone_count): + zone_to_ac[i] = ac + entities.append(Airtouch5AC(client, ac)) + + # Add each zone + for zone in client.zones: + entities.append(Airtouch5Zone(client, zone, zone_to_ac[zone.zone_number])) + + async_add_entities(entities) + + +class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): + """Base class for Airtouch5 Climate Entities.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 1 + _attr_name = None + + +class Airtouch5AC(Airtouch5ClimateEntity): + """Representation of the AC unit. Used to control the overall HVAC Mode.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None: + """Initialise the Climate Entity.""" + super().__init__(client) + self._ability = ability + self._attr_unique_id = f"ac_{ability.ac_number}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"ac_{ability.ac_number}")}, + name=f"AC {ability.ac_number}", + manufacturer="Polyaire", + model="AirTouch 5", + ) + self._attr_hvac_modes = [HVACMode.OFF] + if ability.supports_mode_auto: + self._attr_hvac_modes.append(HVACMode.AUTO) + if ability.supports_mode_cool: + self._attr_hvac_modes.append(HVACMode.COOL) + if ability.supports_mode_dry: + self._attr_hvac_modes.append(HVACMode.DRY) + if ability.supports_mode_fan: + self._attr_hvac_modes.append(HVACMode.FAN_ONLY) + if ability.supports_mode_heat: + self._attr_hvac_modes.append(HVACMode.HEAT) + + self._attr_fan_modes = [] + if ability.supports_fan_speed_quiet: + self._attr_fan_modes.append(FAN_DIFFUSE) + if ability.supports_fan_speed_low: + self._attr_fan_modes.append(FAN_LOW) + if ability.supports_fan_speed_medium: + self._attr_fan_modes.append(FAN_MEDIUM) + if ability.supports_fan_speed_high: + self._attr_fan_modes.append(FAN_HIGH) + if ability.supports_fan_speed_powerful: + self._attr_fan_modes.append(FAN_FOCUS) + if ability.supports_fan_speed_turbo: + self._attr_fan_modes.append(FAN_TURBO) + if ability.supports_fan_speed_auto: + self._attr_fan_modes.append(FAN_AUTO) + if ability.supports_fan_speed_intelligent_auto: + self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO) + + # We can have different setpoints for heat cool, we expose the lowest low and highest high + self._attr_min_temp = min( + ability.min_cool_set_point, ability.min_heat_set_point + ) + self._attr_max_temp = max( + ability.max_cool_set_point, ability.max_heat_set_point + ) + + @callback + def _async_update_attrs(self, data: dict[int, AcStatus]) -> None: + if self._ability.ac_number not in data: + return + status = data[self._ability.ac_number] + + self._attr_current_temperature = status.temperature + self._attr_target_temperature = status.ac_setpoint + if status.ac_power_state in [AcPowerState.OFF, AcPowerState.AWAY_OFF]: + self._attr_hvac_mode = HVACMode.OFF + else: + self._attr_hvac_mode = AC_MODE_TO_HVAC_MODE[status.ac_mode] + self._attr_fan_mode = AC_FAN_SPEED_TO_FAN_SPEED[status.ac_fan_speed] + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + await super().async_added_to_hass() + self._client.ac_status_callbacks.append(self._async_update_attrs) + self._async_update_attrs(self._client.latest_ac_status) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + await super().async_will_remove_from_hass() + self._client.ac_status_callbacks.remove(self._async_update_attrs) + + async def _control( + self, + *, + power: SetPowerSetting = SetPowerSetting.KEEP_POWER_SETTING, + ac_mode: SetAcMode = SetAcMode.KEEP_AC_MODE, + fan: SetAcFanSpeed = SetAcFanSpeed.KEEP_AC_FAN_SPEED, + setpoint: SetpointControl = SetpointControl.KEEP_SETPOINT_VALUE, + temp: int = 0, + ) -> None: + control = AcControl( + power, + self._ability.ac_number, + ac_mode, + fan, + setpoint, + temp, + ) + packet = self._client.data_packet_factory.ac_control([control]) + await self._client.send_packet(packet) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new operation mode.""" + set_power_setting: SetPowerSetting + set_ac_mode: SetAcMode + + if hvac_mode == HVACMode.OFF: + set_power_setting = SetPowerSetting.SET_TO_OFF + set_ac_mode = SetAcMode.KEEP_AC_MODE + else: + set_power_setting = SetPowerSetting.SET_TO_ON + if hvac_mode not in HVAC_MODE_TO_SET_AC_MODE: + raise ValueError(f"Unsupported hvac mode: {hvac_mode}") + set_ac_mode = HVAC_MODE_TO_SET_AC_MODE[hvac_mode] + + await self._control(power=set_power_setting, ac_mode=set_ac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new fan mode.""" + if fan_mode not in FAN_MODE_TO_SET_AC_FAN_SPEED: + raise ValueError(f"Unsupported fan mode: {fan_mode}") + fan_speed = FAN_MODE_TO_SET_AC_FAN_SPEED[fan_mode] + await self._control(fan=fan_speed) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + _LOGGER.debug("Argument `temperature` is missing in set_temperature") + return + + await self._control(temp=temp) + + +class Airtouch5Zone(Airtouch5ClimateEntity): + """Representation of a Zone. Used to control the AC effect in the zone.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] + _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + + def __init__( + self, client: Airtouch5SimpleClient, name: ZoneName, ac: AcAbility + ) -> None: + """Initialise the Climate Entity.""" + super().__init__(client) + self._name = name + + self._attr_unique_id = f"zone_{name.zone_number}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"zone_{name.zone_number}")}, + name=name.zone_name, + manufacturer="Polyaire", + model="AirTouch 5", + ) + # We can have different setpoints for heat and cool, we expose the lowest low and highest high + self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point) + self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point) + + @callback + def _async_update_attrs(self, data: dict[int, ZoneStatusZone]) -> None: + if self._name.zone_number not in data: + return + status = data[self._name.zone_number] + self._attr_current_temperature = status.temperature + self._attr_target_temperature = status.set_point + + if status.zone_power_state == ZonePowerState.OFF: + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = PRESET_NONE + elif status.zone_power_state == ZonePowerState.ON: + self._attr_hvac_mode = HVACMode.FAN_ONLY + self._attr_preset_mode = PRESET_NONE + elif status.zone_power_state == ZonePowerState.TURBO: + self._attr_hvac_mode = HVACMode.FAN_ONLY + self._attr_preset_mode = PRESET_BOOST + else: + self._attr_hvac_mode = None + + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + await super().async_added_to_hass() + self._client.zone_status_callbacks.append(self._async_update_attrs) + self._async_update_attrs(self._client.latest_zone_status) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener after this object has been initialized.""" + await super().async_will_remove_from_hass() + self._client.zone_status_callbacks.remove(self._async_update_attrs) + + async def _control( + self, + *, + zsv: ZoneSettingValue = ZoneSettingValue.KEEP_SETTING_VALUE, + power: ZoneSettingPower = ZoneSettingPower.KEEP_POWER_STATE, + value: float = 0, + ) -> None: + control = ZoneControlZone(self._name.zone_number, zsv, power, value) + packet = self._client.data_packet_factory.zone_control([control]) + await self._client.send_packet(packet) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new operation mode.""" + power: ZoneSettingPower + + if hvac_mode is HVACMode.OFF: + power = ZoneSettingPower.SET_TO_OFF + elif self._attr_preset_mode is PRESET_BOOST: + power = ZoneSettingPower.SET_TO_TURBO + else: + power = ZoneSettingPower.SET_TO_ON + + await self._control(power=power) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Enable or disable Turbo. Done this way as we can't have a turbo HVACMode.""" + power: ZoneSettingPower + if preset_mode == PRESET_BOOST: + power = ZoneSettingPower.SET_TO_TURBO + else: + power = ZoneSettingPower.SET_TO_ON + + await self._control(power=power) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + _LOGGER.debug("Argument `temperature` is missing in set_temperature") + return + + await self._control( + zsv=ZoneSettingValue.SET_TARGET_SETPOINT, + value=float(temp), + ) + + async def async_turn_on(self) -> None: + """Turn the zone on.""" + await self.async_set_hvac_mode(HVACMode.FAN_ONLY) + + async def async_turn_off(self) -> None: + """Turn the zone off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py new file mode 100644 index 00000000000..e5df2844653 --- /dev/null +++ b/homeassistant/components/airtouch5/config_flow.py @@ -0,0 +1,46 @@ +"""Config flow for Airtouch 5 integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airtouch 5.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] | None = None + if user_input is not None: + client = Airtouch5SimpleClient(user_input[CONF_HOST]) + try: + await client.test_connection() + except Exception: # pylint: disable=broad-exception-caught + errors = {"base": "cannot_connect"} + else: + await self.async_set_unique_id(user_input[CONF_HOST]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airtouch5/const.py b/homeassistant/components/airtouch5/const.py new file mode 100644 index 00000000000..e98db04aaa3 --- /dev/null +++ b/homeassistant/components/airtouch5/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airtouch 5 integration.""" + +DOMAIN = "airtouch5" + +FAN_TURBO = "turbo" +FAN_INTELLIGENT_AUTO = "intelligent_auto" diff --git a/homeassistant/components/airtouch5/entity.py b/homeassistant/components/airtouch5/entity.py new file mode 100644 index 00000000000..a6ac76b5187 --- /dev/null +++ b/homeassistant/components/airtouch5/entity.py @@ -0,0 +1,40 @@ +"""Base class for Airtouch5 entities.""" +from airtouch5py.airtouch5_client import Airtouch5ConnectionStateChange +from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class Airtouch5Entity(Entity): + """Base class for Airtouch5 entities.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_translation_key = DOMAIN + + def __init__(self, client: Airtouch5SimpleClient) -> None: + """Initialise the Entity.""" + self._client = client + self._attr_available = True + + @callback + def _receive_connection_callback( + self, state: Airtouch5ConnectionStateChange + ) -> None: + self._attr_available = state is Airtouch5ConnectionStateChange.CONNECTED + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add data updated listener after this object has been initialized.""" + self._client.connection_state_callbacks.append( + self._receive_connection_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove data updated listener when entity is removed from homeassistant.""" + self._client.connection_state_callbacks.remove( + self._receive_connection_callback + ) diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json new file mode 100644 index 00000000000..0d4cbc32761 --- /dev/null +++ b/homeassistant/components/airtouch5/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airtouch5", + "name": "AirTouch 5", + "codeowners": ["@danzel"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airtouch5", + "iot_class": "local_push", + "loggers": ["airtouch5py"], + "requirements": ["airtouch5py==0.2.8"] +} diff --git a/homeassistant/components/airtouch5/strings.json b/homeassistant/components/airtouch5/strings.json new file mode 100644 index 00000000000..6a91fa85fa5 --- /dev/null +++ b/homeassistant/components/airtouch5/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "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_device%]" + } + }, + "entity": { + "climate": { + "airtouch5": { + "state_attributes": { + "fan_mode": { + "state": { + "turbo": "Turbo", + "intelligent_auto": "Intelligent Auto" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 61edf91b154..6d999eaa2c0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -33,6 +33,7 @@ FLOWS = { "airthings", "airthings_ble", "airtouch4", + "airtouch5", "airvisual", "airvisual_pro", "airzone", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cee10c5ff51..27882e7e162 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -128,6 +128,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "airtouch5": { + "name": "AirTouch 5", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "airvisual": { "name": "AirVisual", "integrations": { diff --git a/mypy.ini b/mypy.ini index 68e40b51c50..6136a7b0d6f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -290,6 +290,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airtouch5.*] +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.airvisual.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index b734ac8b853..2628afba075 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -415,6 +415,9 @@ airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 +# homeassistant.components.airtouch5 +airtouch5py==0.2.8 + # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 111a4a7d8ab..109c9c758ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -388,6 +388,9 @@ airthings-cloud==0.2.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 +# homeassistant.components.airtouch5 +airtouch5py==0.2.8 + # homeassistant.components.amberelectric amberelectric==1.0.4 diff --git a/tests/components/airtouch5/__init__.py b/tests/components/airtouch5/__init__.py new file mode 100644 index 00000000000..2b76786e7e5 --- /dev/null +++ b/tests/components/airtouch5/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airtouch 5 integration.""" diff --git a/tests/components/airtouch5/conftest.py b/tests/components/airtouch5/conftest.py new file mode 100644 index 00000000000..836ce81301a --- /dev/null +++ b/tests/components/airtouch5/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Airtouch 5 tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airtouch5.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airtouch5/test_config_flow.py b/tests/components/airtouch5/test_config_flow.py new file mode 100644 index 00000000000..4f608fd4788 --- /dev/null +++ b/tests/components/airtouch5/test_config_flow.py @@ -0,0 +1,62 @@ +"""Test the Airtouch 5 config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.airtouch5.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + host = "1.1.1.1" + + with patch( + "airtouch5py.airtouch5_simple_client.Airtouch5SimpleClient.test_connection", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": host, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == host + assert result2["data"] == { + "host": host, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airtouch5py.airtouch5_simple_client.Airtouch5SimpleClient.test_connection", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} From 9bff039d17bc1c2ebbd8cec6bac6865726a270a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 Jan 2024 15:13:42 +0100 Subject: [PATCH 0953/1544] Add set_conversation_response script action (#108233) * Add set_conversation_response script action * Update homeassistant/components/conversation/trigger.py Co-authored-by: Martin Hjelmare * Revert accidental change * Add test * Ignore mypy * Remove incorrect callback decorator * Update homeassistant/helpers/script.py * Add test with templated set_conversation_response --------- Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen --- .../components/automation/__init__.py | 13 +- .../components/conversation/trigger.py | 13 +- homeassistant/const.py | 1 + homeassistant/helpers/config_validation.py | 18 ++ homeassistant/helpers/script.py | 24 ++- homeassistant/helpers/trigger.py | 2 +- tests/components/conversation/test_trigger.py | 31 ++++ tests/helpers/test_script.py | 159 ++++++++++++++++++ 8 files changed, 248 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 05f732565e8..b5faeefdbe4 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -72,6 +72,7 @@ from homeassistant.helpers.script import ( CONF_MAX, CONF_MAX_EXCEEDED, Script, + ScriptRunResult, script_stack_cv, ) from homeassistant.helpers.script_variables import ScriptVariables @@ -359,7 +360,7 @@ class BaseAutomationEntity(ToggleEntity, ABC): run_variables: dict[str, Any], context: Context | None = None, skip_condition: bool = False, - ) -> None: + ) -> ScriptRunResult | None: """Trigger automation.""" @@ -581,7 +582,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): run_variables: dict[str, Any], context: Context | None = None, skip_condition: bool = False, - ) -> None: + ) -> ScriptRunResult | None: """Trigger automation. This method is a coroutine. @@ -617,7 +618,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): except TemplateError as err: self._logger.error("Error rendering variables: %s", err) automation_trace.set_error(err) - return + return None # Prepare tracing the automation automation_trace.set_trace(trace_get()) @@ -644,7 +645,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): trace_get(clear=False), ) script_execution_set("failed_conditions") - return + return None self.async_set_context(trigger_context) event_data = { @@ -666,7 +667,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): try: with trace_path("action"): - await self.action_script.async_run( + return await self.action_script.async_run( variables, trigger_context, started_action ) except ServiceNotFound as err: @@ -697,6 +698,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self._logger.exception("While executing automation %s", self.entity_id) automation_trace.set_error(err) + return None + async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 30cc9a0d5d0..d38bb69f3e1 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -7,10 +7,11 @@ from hassil.recognize import PUNCTUATION, RecognizeResult import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType from . import HOME_ASSISTANT_AGENT, _get_agent_manager from .const import DOMAIN @@ -60,7 +61,6 @@ async def async_attach_trigger( job = HassJob(action) - @callback async def call_action(sentence: str, result: RecognizeResult) -> str | None: """Call action with right context.""" @@ -91,7 +91,12 @@ async def async_attach_trigger( job, {"trigger": trigger_input}, ): - await future + automation_result = await future + if isinstance( + automation_result, ScriptRunResult + ) and automation_result.conversation_response not in (None, UNDEFINED): + # mypy does not understand the type narrowing, unclear why + return automation_result.conversation_response # type: ignore[return-value] return "Done" diff --git a/homeassistant/const.py b/homeassistant/const.py index 86e4e4bcda1..35cd8a5e23a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -251,6 +251,7 @@ CONF_SERVICE: Final = "service" CONF_SERVICE_DATA: Final = "data" CONF_SERVICE_DATA_TEMPLATE: Final = "data_template" CONF_SERVICE_TEMPLATE: Final = "service_template" +CONF_SET_CONVERSATION_RESPONSE: Final = "set_conversation_response" CONF_SHOW_ON_MAP: Final = "show_on_map" CONF_SLAVE: Final = "slave" CONF_SOURCE: Final = "source" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e4b62dd679d..497a00e40b2 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -67,6 +67,7 @@ from homeassistant.const import ( CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, + CONF_SET_CONVERSATION_RESPONSE, CONF_STATE, CONF_STOP, CONF_TARGET, @@ -1267,6 +1268,9 @@ def make_entity_service_schema( ) +SCRIPT_CONVERSATION_RESPONSE_SCHEMA = vol.Any(template, None) + + SCRIPT_VARIABLES_SCHEMA = vol.All( vol.Schema({str: template_complex}), # pylint: disable-next=unnecessary-lambda @@ -1742,6 +1746,15 @@ _SCRIPT_SET_SCHEMA = vol.Schema( } ) +_SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA = vol.Schema( + { + **SCRIPT_ACTION_BASE_SCHEMA, + vol.Required( + CONF_SET_CONVERSATION_RESPONSE + ): SCRIPT_CONVERSATION_RESPONSE_SCHEMA, + } +) + _SCRIPT_STOP_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, @@ -1794,6 +1807,7 @@ SCRIPT_ACTION_VARIABLES = "variables" SCRIPT_ACTION_STOP = "stop" SCRIPT_ACTION_IF = "if" SCRIPT_ACTION_PARALLEL = "parallel" +SCRIPT_ACTION_SET_CONVERSATION_RESPONSE = "set_conversation_response" def determine_script_action(action: dict[str, Any]) -> str: @@ -1840,6 +1854,9 @@ def determine_script_action(action: dict[str, Any]) -> str: if CONF_PARALLEL in action: return SCRIPT_ACTION_PARALLEL + if CONF_SET_CONVERSATION_RESPONSE in action: + return SCRIPT_ACTION_SET_CONVERSATION_RESPONSE + raise ValueError("Unable to determine action") @@ -1858,6 +1875,7 @@ ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, SCRIPT_ACTION_IF: _SCRIPT_IF_SCHEMA, SCRIPT_ACTION_PARALLEL: _SCRIPT_PARALLEL_SCHEMA, + SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA, } diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 823a5c171f4..b391dcd5397 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -52,6 +52,7 @@ from homeassistant.const import ( CONF_SERVICE, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, + CONF_SET_CONVERSATION_RESPONSE, CONF_STOP, CONF_TARGET, CONF_THEN, @@ -98,7 +99,7 @@ from .trace import ( trace_update_result, ) from .trigger import async_initialize_triggers, async_validate_trigger_config -from .typing import ConfigType +from .typing import UNDEFINED, ConfigType, UndefinedType # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs @@ -259,6 +260,7 @@ STATIC_VALIDATION_ACTION_TYPES = ( cv.SCRIPT_ACTION_ACTIVATE_SCENE, cv.SCRIPT_ACTION_VARIABLES, cv.SCRIPT_ACTION_STOP, + cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE, ) @@ -385,6 +387,7 @@ class _ScriptRun: self._step = -1 self._stop = asyncio.Event() self._stopped = asyncio.Event() + self._conversation_response: str | None | UndefinedType = UNDEFINED def _changed(self) -> None: if not self._stop.is_set(): @@ -450,7 +453,7 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(response, self._variables) + return ScriptRunResult(self._conversation_response, response, self._variables) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -1031,6 +1034,18 @@ class _ScriptRun: self._hass, self._variables, render_as_defaults=False ) + async def _async_set_conversation_response_step(self): + """Set conversation response.""" + self._step_log("setting conversation response") + resp: template.Template | None = self._action[CONF_SET_CONVERSATION_RESPONSE] + if resp is None: + self._conversation_response = None + else: + self._conversation_response = resp.async_render( + variables=self._variables, parse_result=False + ) + trace_set_result(conversation_response=self._conversation_response) + async def _async_stop_step(self): """Stop script execution.""" stop = self._action[CONF_STOP] @@ -1075,11 +1090,13 @@ class _ScriptRun: async def _async_run_script(self, script: Script) -> None: """Execute a script.""" - await self._async_run_long_action( + result = await self._async_run_long_action( self._hass.async_create_task( script.async_run(self._variables, self._context) ) ) + if result and result.conversation_response is not UNDEFINED: + self._conversation_response = result.conversation_response class _QueuedScriptRun(_ScriptRun): @@ -1202,6 +1219,7 @@ class _IfData(TypedDict): class ScriptRunResult: """Container with the result of a script run.""" + conversation_response: str | None | UndefinedType service_response: ServiceResponse variables: dict diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a4391061899..c9ca76cdf72 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -73,7 +73,7 @@ class TriggerActionType(Protocol): self, run_variables: dict[str, Any], context: Context | None = None, - ) -> None: + ) -> Any: """Define action callback type.""" diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 69a93b4a7c9..e40c7554fdd 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -68,6 +68,37 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None } +async def test_response(hass: HomeAssistant, setup_comp) -> None: + """Test the firing of events.""" + response = "I'm sorry, Dave. I'm afraid I can't do that" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Open the pod bay door Hal"], + }, + "action": { + "set_conversation_response": response, + }, + } + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "Open the pod bay door Hal", + }, + blocking=True, + return_response=True, + ) + assert service_response["response"]["speech"]["plain"]["speech"] == response + + async def test_same_trigger_multiple_sentences( hass: HomeAssistant, calls, setup_comp ) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index a4361d28e74..501a5caebac 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -41,6 +41,7 @@ from homeassistant.helpers import ( trace, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -4601,6 +4602,9 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_PARALLEL: { "parallel": [templated_device_action("parallel_event")], }, + cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: { + "set_conversation_response": "Hello world" + }, } expected_templates = { cv.SCRIPT_ACTION_CHECK_CONDITION: None, @@ -5357,3 +5361,158 @@ async def test_condition_not_shorthand( "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) + + +async def test_conversation_response( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting conversation response.""" + sequence = cv.SCRIPT_SCHEMA([{"set_conversation_response": "Testing 123"}]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response == "Testing 123" + + assert_action_trace( + { + "0": [{"result": {"conversation_response": "Testing 123"}}], + } + ) + + +async def test_conversation_response_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test a templated conversation response.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"variables": {"my_var": "234"}}, + {"set_conversation_response": '{{ "Testing " + my_var }}'}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response == "Testing 234" + + assert_action_trace( + { + "0": [{"variables": {"my_var": "234"}}], + "1": [{"result": {"conversation_response": "Testing 234"}}], + } + ) + + +async def test_conversation_response_not_set( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test not setting conversation response.""" + sequence = cv.SCRIPT_SCHEMA([]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response is UNDEFINED + + assert_action_trace({}) + + +async def test_conversation_response_unset( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test clearing conversation response.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + {"set_conversation_response": None}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + assert result.conversation_response is None + + assert_action_trace( + { + "0": [{"result": {"conversation_response": "Testing 123"}}], + "1": [{"result": {"conversation_response": None}}], + } + ) + + +@pytest.mark.parametrize( + ("var", "if_result", "choice", "response"), + [(1, True, "then", "If: Then"), (2, False, "else", "If: Else")], +) +async def test_conversation_response_subscript_if( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, + response: str, +) -> None: + """Test setting conversation response in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": {"set_conversation_response": "If: Then"}, + "else": {"set_conversation_response": "If: Else"}, + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.conversation_response == response + + expected_trace = { + "0": [{"result": {"conversation_response": "Testing 123"}}], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + f"1/{choice}/0": [{"result": {"conversation_response": response}}], + } + assert_action_trace(expected_trace) + + +@pytest.mark.parametrize( + ("var", "if_result", "choice"), [(1, True, "then"), (2, False, "else")] +) +async def test_conversation_response_not_set_subscript_if( + hass: HomeAssistant, + var: int, + if_result: bool, + choice: str, +) -> None: + """Test not setting conversation response in a subscript.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"set_conversation_response": "Testing 123"}, + { + "if": { + "condition": "template", + "value_template": "{{ var == 1 }}", + }, + "then": [], + "else": [], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + run_vars = MappingProxyType({"var": var}) + result = await script_obj.async_run(run_vars, context=Context()) + assert result.conversation_response == "Testing 123" + + expected_trace = { + "0": [{"result": {"conversation_response": "Testing 123"}}], + "1": [{"result": {"choice": choice}}], + "1/if": [{"result": {"result": if_result}}], + "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], + } + assert_action_trace(expected_trace) From 074d59f849895e332716b3ad1094cd517d1876bb Mon Sep 17 00:00:00 2001 From: Michal Ziemski Date: Tue, 23 Jan 2024 15:14:41 +0100 Subject: [PATCH 0954/1544] Update openerz-api to 0.3.0 (#108575) --- homeassistant/components/openerz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openerz/manifest.json b/homeassistant/components/openerz/manifest.json index 181e0bd870a..c7a5a202568 100644 --- a/homeassistant/components/openerz/manifest.json +++ b/homeassistant/components/openerz/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/openerz", "iot_class": "cloud_polling", "loggers": ["openerz_api"], - "requirements": ["openerz-api==0.2.0"] + "requirements": ["openerz-api==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2628afba075..229e61e8c72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1432,7 +1432,7 @@ openai==1.3.8 # opencv-python-headless==4.6.0.66 # homeassistant.components.openerz -openerz-api==0.2.0 +openerz-api==0.3.0 # homeassistant.components.openevse openevsewifi==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 109c9c758ba..46cdb704b4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ open-meteo==0.3.1 openai==1.3.8 # homeassistant.components.openerz -openerz-api==0.2.0 +openerz-api==0.3.0 # homeassistant.components.openhome openhomedevice==2.2.0 From f3b1f47d3492e7d5e22859603f563994bb61322a Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 23 Jan 2024 09:57:55 -0500 Subject: [PATCH 0955/1544] Return PRESET_NONE in Honeywell (#108599) * Return PRESET_NONE * format preset_hold * Address Hold in tests * Add translations --- homeassistant/components/honeywell/climate.py | 5 +++-- homeassistant/components/honeywell/strings.json | 13 +++++++++++++ .../honeywell/snapshots/test_climate.ambr | 4 ++-- tests/components/honeywell/test_climate.py | 3 +-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 2f06dd1cfbe..803ca1da1aa 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -51,7 +51,7 @@ ATTR_FAN_ACTION = "fan_action" ATTR_PERMANENT_HOLD = "permanent_hold" -PRESET_HOLD = "Hold" +PRESET_HOLD = "hold" HEATING_MODES = {"heat", "emheat", "auto"} COOLING_MODES = {"cool", "auto"} @@ -142,6 +142,7 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = "honeywell" def __init__( self, @@ -303,7 +304,7 @@ class HoneywellUSThermostat(ClimateEntity): if self._is_permanent_hold(): return PRESET_HOLD - return None + return PRESET_NONE @property def is_aux_heat(self) -> bool | None: diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index b0cd2a52c1b..6f855828e01 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -40,6 +40,19 @@ "outdoor_humidity": { "name": "Outdoor humidity" } + }, + "climate": { + "honeywell": { + "state_attributes": { + "preset_mode": { + "state": { + "hold": "Hold", + "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]" + } + } + } + } } } } diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index 4f7d8fe1308..d589cbfbc9e 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -24,11 +24,11 @@ 'min_humidity': 30, 'min_temp': -13.9, 'permanent_hold': False, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ 'none', 'away', - 'Hold', + 'hold', ]), 'supported_features': , 'target_temp_high': None, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 9c73e88c3df..743689da43d 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL +from homeassistant.components.honeywell.climate import PRESET_HOLD, RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -46,7 +46,6 @@ from . import init_integration, reset_mock from tests.common import async_fire_time_changed FAN_ACTION = "fan_action" -PRESET_HOLD = "Hold" async def test_no_thermostat_options( From 13887793a72ddc16ff91e0e47c489dfae071ee3e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 23 Jan 2024 16:18:03 +0100 Subject: [PATCH 0956/1544] Remove home_plus_control and mark as virtual integration supported by Netatmo (#107587) * Mark home_plus_control a virtual integration using Netatmo * Apply code review suggestion Co-authored-by: Robert Resch --------- Co-authored-by: Robert Resch --- .coveragerc | 2 - CODEOWNERS | 2 - .../components/home_plus_control/__init__.py | 209 +------- .../components/home_plus_control/api.py | 58 --- .../home_plus_control/config_flow.py | 30 -- .../components/home_plus_control/const.py | 45 -- .../components/home_plus_control/helpers.py | 53 -- .../home_plus_control/manifest.json | 9 +- .../components/home_plus_control/strings.json | 30 -- .../components/home_plus_control/switch.py | 131 ----- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 5 +- requirements_all.txt | 3 - requirements_test_all.txt | 3 - .../components/home_plus_control/__init__.py | 1 - .../components/home_plus_control/conftest.py | 103 ---- .../home_plus_control/test_config_flow.py | 203 -------- .../components/home_plus_control/test_init.py | 72 --- .../home_plus_control/test_switch.py | 463 ------------------ 19 files changed, 5 insertions(+), 1418 deletions(-) delete mode 100644 homeassistant/components/home_plus_control/api.py delete mode 100644 homeassistant/components/home_plus_control/config_flow.py delete mode 100644 homeassistant/components/home_plus_control/const.py delete mode 100644 homeassistant/components/home_plus_control/helpers.py delete mode 100644 homeassistant/components/home_plus_control/strings.json delete mode 100644 homeassistant/components/home_plus_control/switch.py delete mode 100644 tests/components/home_plus_control/__init__.py delete mode 100644 tests/components/home_plus_control/conftest.py delete mode 100644 tests/components/home_plus_control/test_config_flow.py delete mode 100644 tests/components/home_plus_control/test_init.py delete mode 100644 tests/components/home_plus_control/test_switch.py diff --git a/.coveragerc b/.coveragerc index 9da67cad748..550eb050ee7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -515,8 +515,6 @@ omit = homeassistant/components/home_connect/light.py homeassistant/components/home_connect/sensor.py homeassistant/components/home_connect/switch.py - homeassistant/components/home_plus_control/api.py - homeassistant/components/home_plus_control/switch.py homeassistant/components/homematic/__init__.py homeassistant/components/homematic/binary_sensor.py homeassistant/components/homematic/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 1378e0f776a..3cdccece944 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -543,8 +543,6 @@ build.json @home-assistant/supervisor /tests/components/holiday/ @jrieger @gjohansson-ST /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub -/homeassistant/components/home_plus_control/ @chemaaa -/tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 2ed37480705..e917ab6f2c9 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -1,208 +1 @@ -"""The Legrand Home+ Control integration.""" -import asyncio -from datetime import timedelta -import logging - -from homepluscontrol.homeplusapi import HomePlusControlApiError -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - dispatcher, -) -from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from . import config_flow, helpers -from .api import HomePlusControlAsyncApi -from .const import ( - API, - CONF_SUBSCRIPTION_KEY, - DATA_COORDINATOR, - DISPATCHER_REMOVERS, - DOMAIN, - ENTITY_UIDS, - SIGNAL_ADD_ENTITIES, -) - -# Configuration schema for component in configuration.yaml -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Required(CONF_SUBSCRIPTION_KEY): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -# The Legrand Home+ Control platform is currently limited to "switch" entities -PLATFORMS = [Platform.SWITCH] - -_LOGGER = logging.getLogger(__name__) - -_ISSUE_MOVE_TO_NETATMO = "move_to_netatmo" - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Legrand Home+ Control component from configuration.yaml.""" - hass.data[DOMAIN] = {} - - if DOMAIN not in config: - return True - - async_create_issue( - hass, - DOMAIN, - _ISSUE_MOVE_TO_NETATMO, - is_fixable=False, - is_persistent=False, - breaks_in_ha_version="2023.12.0", # Netatmo decided to shutdown the api in december - severity=IssueSeverity.WARNING, - translation_key=_ISSUE_MOVE_TO_NETATMO, - translation_placeholders={ - "url": "https://www.home-assistant.io/integrations/netatmo/" - }, - ) - - # Register the implementation from the config information - config_flow.HomePlusControlFlowHandler.async_register_implementation( - hass, - helpers.HomePlusControlOAuth2Implementation(hass, config[DOMAIN]), - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Legrand Home+ Control from a config entry.""" - hass_entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) - - async_create_issue( - hass, - DOMAIN, - _ISSUE_MOVE_TO_NETATMO, - is_fixable=False, - is_persistent=False, - breaks_in_ha_version="2023.12.0", # Netatmo decided to shutdown the api in december - severity=IssueSeverity.WARNING, - translation_key=_ISSUE_MOVE_TO_NETATMO, - translation_placeholders={ - "url": "https://www.home-assistant.io/integrations/netatmo/" - }, - ) - - # Retrieve the registered implementation - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - - # Using an aiohttp-based API lib, so rely on async framework - # Add the API object to the domain's data in HA - api = hass_entry_data[API] = HomePlusControlAsyncApi(hass, entry, implementation) - - # Set of entity unique identifiers of this integration - uids: set[str] = set() - hass_entry_data[ENTITY_UIDS] = uids - - # Integration dispatchers - hass_entry_data[DISPATCHER_REMOVERS] = [] - - device_registry = async_get_device_registry(hass) - - # Register the Data Coordinator with the integration - async def async_update_data(): - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - return await api.async_get_modules() - except HomePlusControlApiError as err: - raise UpdateFailed( - f"Error communicating with API: {err} [{type(err)}]" - ) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="home_plus_control_module", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=300), - ) - hass_entry_data[DATA_COORDINATOR] = coordinator - - @callback - def _async_update_entities(): - """Process entities and add or remove them based after an update.""" - if not (module_data := coordinator.data): - return - - # Remove obsolete entities from Home Assistant - entity_uids_to_remove = uids - set(module_data) - for uid in entity_uids_to_remove: - uids.remove(uid) - device = device_registry.async_get_device(identifiers={(DOMAIN, uid)}) - device_registry.async_remove_device(device.id) - - # Send out signal for new entity addition to Home Assistant - new_entity_uids = set(module_data) - uids - if new_entity_uids: - uids.update(new_entity_uids) - dispatcher.async_dispatcher_send( - hass, - SIGNAL_ADD_ENTITIES, - new_entity_uids, - coordinator, - ) - - entry.async_on_unload(coordinator.async_add_listener(_async_update_entities)) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Only refresh the coordinator after all platforms are loaded. - await coordinator.async_refresh() - - return True - - -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload the Legrand Home+ Control config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - # Unsubscribe the config_entry signal dispatcher connections - dispatcher_removers = hass.data[DOMAIN][config_entry.entry_id].pop( - "dispatcher_removers" - ) - for remover in dispatcher_removers: - remover() - - # And finally unload the domain config entry data - hass.data[DOMAIN].pop(config_entry.entry_id) - - async_delete_issue(hass, DOMAIN, _ISSUE_MOVE_TO_NETATMO) - - return unload_ok +"""Virtual integration: Legrand Home+ Control.""" diff --git a/homeassistant/components/home_plus_control/api.py b/homeassistant/components/home_plus_control/api.py deleted file mode 100644 index 9f092b28920..00000000000 --- a/homeassistant/components/home_plus_control/api.py +++ /dev/null @@ -1,58 +0,0 @@ -"""API for Legrand Home+ Control bound to Home Assistant OAuth.""" -from homepluscontrol.homeplusapi import HomePlusControlAPI - -from homeassistant import config_entries, core -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow - -from .const import DEFAULT_UPDATE_INTERVALS -from .helpers import HomePlusControlOAuth2Implementation - - -class HomePlusControlAsyncApi(HomePlusControlAPI): - """Legrand Home+ Control object that interacts with the OAuth2-based API of the provider. - - This API is bound the HomeAssistant Config Entry that corresponds to this component. - - Attributes:. - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this API. - implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA and - token refresh. - _oauth_session (OAuth2Session): OAuth2Session object within implementation. - """ - - def __init__( - self, - hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, - ) -> None: - """Initialize the HomePlusControlAsyncApi object. - - Initialize the authenticated API for the Legrand Home+ Control component. - - Args:. - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this API. - implementation (AbstractOAuth2Implementation): OAuth2 implementation that handles AA - and token refresh. - """ - self._oauth_session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - - assert isinstance(implementation, HomePlusControlOAuth2Implementation) - - # Create the API authenticated client - external library - super().__init__( - subscription_key=implementation.subscription_key, - oauth_client=aiohttp_client.async_get_clientsession(hass), - update_intervals=DEFAULT_UPDATE_INTERVALS, - ) - - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() - - return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/home_plus_control/config_flow.py b/homeassistant/components/home_plus_control/config_flow.py deleted file mode 100644 index bf99da7ab73..00000000000 --- a/homeassistant/components/home_plus_control/config_flow.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Config flow for Legrand Home+ Control.""" -import logging - -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import DOMAIN - - -class HomePlusControlFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): - """Config flow to handle Home+ Control OAuth2 authentication.""" - - DOMAIN = DOMAIN - - # Pick the Cloud Poll class - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_step_user(self, user_input=None): - """Handle a flow start initiated by the user.""" - await self.async_set_unique_id(DOMAIN) - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - return await super().async_step_user(user_input) diff --git a/homeassistant/components/home_plus_control/const.py b/homeassistant/components/home_plus_control/const.py deleted file mode 100644 index 0ebae0bef20..00000000000 --- a/homeassistant/components/home_plus_control/const.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Constants for the Legrand Home+ Control integration.""" -API = "api" -CONF_SUBSCRIPTION_KEY = "subscription_key" -CONF_PLANT_UPDATE_INTERVAL = "plant_update_interval" -CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL = "plant_topology_update_interval" -CONF_MODULE_STATUS_UPDATE_INTERVAL = "module_status_update_interval" - -DATA_COORDINATOR = "coordinator" -DOMAIN = "home_plus_control" -ENTITY_UIDS = "entity_unique_ids" -DISPATCHER_REMOVERS = "dispatcher_removers" - -# Legrand Model Identifiers - https://developer.legrand.com/documentation/product-cluster-list/# -HW_TYPE = { - "NLC": "NLC - Cable Outlet", - "NLF": "NLF - On-Off Dimmer Switch w/o Neutral", - "NLP": "NLP - Socket (Connected) Outlet", - "NLPM": "NLPM - Mobile Socket Outlet", - "NLM": "NLM - Micromodule Switch", - "NLV": "NLV - Shutter Switch with Neutral", - "NLLV": "NLLV - Shutter Switch with Level Control", - "NLL": "NLL - On-Off Toggle Switch with Neutral", - "NLT": "NLT - Remote Switch", - "NLD": "NLD - Double Gangs On-Off Remote Switch", -} - -# Legrand OAuth2 URIs -OAUTH2_AUTHORIZE = "https://partners-login.eliotbylegrand.com/authorize" -OAUTH2_TOKEN = "https://partners-login.eliotbylegrand.com/token" - -# The Legrand Home+ Control API has very limited request quotas - at the time of writing, it is -# limited to 500 calls per day (resets at 00:00) - so we want to keep updates to a minimum. -DEFAULT_UPDATE_INTERVALS = { - # Seconds between API checks for plant information updates. This is expected to change very - # little over time because a user's plants (homes) should rarely change. - CONF_PLANT_UPDATE_INTERVAL: 7200, # 120 minutes - # Seconds between API checks for plant topology updates. This is expected to change little - # over time because the modules in the user's plant should be relatively stable. - CONF_PLANT_TOPOLOGY_UPDATE_INTERVAL: 3600, # 60 minutes - # Seconds between API checks for module status updates. This can change frequently so we - # check often - CONF_MODULE_STATUS_UPDATE_INTERVAL: 300, # 5 minutes -} - -SIGNAL_ADD_ENTITIES = "home_plus_control_add_entities_signal" diff --git a/homeassistant/components/home_plus_control/helpers.py b/homeassistant/components/home_plus_control/helpers.py deleted file mode 100644 index f5687a23c66..00000000000 --- a/homeassistant/components/home_plus_control/helpers.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Helper classes and functions for the Legrand Home+ Control integration.""" -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import CONF_SUBSCRIPTION_KEY, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN - - -class HomePlusControlOAuth2Implementation( - config_entry_oauth2_flow.LocalOAuth2Implementation -): - """OAuth2 implementation that extends the HomeAssistant local implementation. - - It provides the name of the integration and adds support for the subscription key. - - Attributes: - hass (HomeAssistant): HomeAssistant core object. - client_id (str): Client identifier assigned by the API provider when registering an app. - client_secret (str): Client secret assigned by the API provider when registering an app. - subscription_key (str): Subscription key obtained from the API provider. - authorize_url (str): Authorization URL initiate authentication flow. - token_url (str): URL to retrieve access/refresh tokens. - name (str): Name of the implementation (appears in the HomeAssistant GUI). - """ - - def __init__( - self, - hass: HomeAssistant, - config_data: dict, - ) -> None: - """HomePlusControlOAuth2Implementation Constructor. - - Initialize the authentication implementation for the Legrand Home+ Control API. - - Args: - hass (HomeAssistant): HomeAssistant core object. - config_data (dict): Configuration data that complies with the config Schema - of this component. - """ - super().__init__( - hass=hass, - domain=DOMAIN, - client_id=config_data[CONF_CLIENT_ID], - client_secret=config_data[CONF_CLIENT_SECRET], - authorize_url=OAUTH2_AUTHORIZE, - token_url=OAUTH2_TOKEN, - ) - self.subscription_key = config_data[CONF_SUBSCRIPTION_KEY] - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Home+ Control" diff --git a/homeassistant/components/home_plus_control/manifest.json b/homeassistant/components/home_plus_control/manifest.json index f225c23fedf..78a3633ca8d 100644 --- a/homeassistant/components/home_plus_control/manifest.json +++ b/homeassistant/components/home_plus_control/manifest.json @@ -1,11 +1,6 @@ { "domain": "home_plus_control", "name": "Legrand Home+ Control", - "codeowners": ["@chemaaa"], - "config_flow": true, - "dependencies": ["auth"], - "documentation": "https://www.home-assistant.io/integrations/home_plus_control", - "iot_class": "cloud_polling", - "loggers": ["homepluscontrol"], - "requirements": ["homepluscontrol==0.0.5"] + "integration_type": "virtual", + "supported_by": "netatmo" } diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json deleted file mode 100644 index 13a7102827c..00000000000 --- a/homeassistant/components/home_plus_control/strings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", - "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - } - }, - "issues": { - "move_to_netatmo": { - "title": "Legrand Home+ Control deprecation", - "description": "Home Assistant has been informed that the platform the Legrand Home+ Control integration is using, will be shutting down upcoming December.\n\nOnce that happens, it means this integration is no longer functional. We advise you to remove this integration and switch to the [Netatmo]({url}) integration, which provides a replacement for controlling your Legrand Home+ Control devices." - } - } -} diff --git a/homeassistant/components/home_plus_control/switch.py b/homeassistant/components/home_plus_control/switch.py deleted file mode 100644 index ef2c1447bf4..00000000000 --- a/homeassistant/components/home_plus_control/switch.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Legrand Home+ Control Switch Entity Module that uses the HomeAssistant DataUpdateCoordinator.""" -from functools import partial -from typing import Any - -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import dispatcher -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DISPATCHER_REMOVERS, DOMAIN, HW_TYPE, SIGNAL_ADD_ENTITIES - - -@callback -def add_switch_entities(new_unique_ids, coordinator, add_entities): - """Add switch entities to the platform. - - Args: - new_unique_ids (set): Unique identifiers of entities to be added to Home Assistant. - coordinator (DataUpdateCoordinator): Data coordinator of this platform. - add_entities (function): Method called to add entities to Home Assistant. - """ - new_entities = [] - for uid in new_unique_ids: - new_ent = HomeControlSwitchEntity(coordinator, uid) - new_entities.append(new_ent) - add_entities(new_entities) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Legrand Home+ Control Switch platform in HomeAssistant. - - Args: - hass (HomeAssistant): HomeAssistant core object. - config_entry (ConfigEntry): ConfigEntry object that configures this platform. - async_add_entities (function): Function called to add entities of this platform. - """ - partial_add_switch_entities = partial( - add_switch_entities, add_entities=async_add_entities - ) - # Connect the dispatcher for the switch platform - hass.data[DOMAIN][config_entry.entry_id][DISPATCHER_REMOVERS].append( - dispatcher.async_dispatcher_connect( - hass, SIGNAL_ADD_ENTITIES, partial_add_switch_entities - ) - ) - - -class HomeControlSwitchEntity(CoordinatorEntity, SwitchEntity): - """Entity that represents a Legrand Home+ Control switch. - - It extends the HomeAssistant-provided classes of the CoordinatorEntity and the SwitchEntity. - - The CoordinatorEntity class provides: - should_poll - async_update - async_added_to_hass - - The SwitchEntity class provides the functionality of a ToggleEntity and additional power - consumption methods and state attributes. - """ - - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, coordinator, idx): - """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - self.idx = idx - self.module = self.coordinator.data[self.idx] - - @property - def unique_id(self): - """ID (unique) of the device.""" - return self.idx - - @property - def device_info(self) -> DeviceInfo: - """Device information.""" - return DeviceInfo( - identifiers={ - # Unique identifiers within the domain - (DOMAIN, self.unique_id) - }, - manufacturer="Legrand", - model=HW_TYPE.get(self.module.hw_type), - name=self.module.name, - sw_version=self.module.fw, - ) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - if self.module.device == "plug": - return SwitchDeviceClass.OUTLET - return SwitchDeviceClass.SWITCH - - @property - def available(self) -> bool: - """Return if entity is available. - - This is the case when the coordinator is able to update the data successfully - AND the switch entity is reachable. - - This method overrides the one of the CoordinatorEntity - """ - return self.coordinator.last_update_success and self.module.reachable - - @property - def is_on(self): - """Return entity state.""" - return self.module.status == "on" - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the light on.""" - # Do the turning on. - await self.module.turn_on() - # Update the data - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self.module.turn_off() - # Update the data - await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6d999eaa2c0..17d4198628a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -214,7 +214,6 @@ FLOWS = { "hlk_sw16", "holiday", "home_connect", - "home_plus_control", "homeassistant_sky_connect", "homekit", "homekit_controller", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 27882e7e162..4a37eff5a77 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2494,9 +2494,8 @@ }, "home_plus_control": { "name": "Legrand Home+ Control", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" + "integration_type": "virtual", + "supported_by": "netatmo" }, "homematic": { "name": "Homematic", diff --git a/requirements_all.txt b/requirements_all.txt index 229e61e8c72..fcaa4dfbefd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1064,9 +1064,6 @@ homeconnect==0.7.2 # homeassistant.components.homematicip_cloud homematicip==1.0.16 -# homeassistant.components.home_plus_control -homepluscontrol==0.0.5 - # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46cdb704b4f..ab476af5688 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -857,9 +857,6 @@ homeconnect==0.7.2 # homeassistant.components.homematicip_cloud homematicip==1.0.16 -# homeassistant.components.home_plus_control -homepluscontrol==0.0.5 - # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/home_plus_control/__init__.py b/tests/components/home_plus_control/__init__.py deleted file mode 100644 index a9caba13e32..00000000000 --- a/tests/components/home_plus_control/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Legrand Home+ Control integration.""" diff --git a/tests/components/home_plus_control/conftest.py b/tests/components/home_plus_control/conftest.py deleted file mode 100644 index 6ac856a3227..00000000000 --- a/tests/components/home_plus_control/conftest.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Test setup and fixtures for component Home+ Control by Legrand.""" -from homepluscontrol.homeplusinteractivemodule import HomePlusInteractiveModule -from homepluscontrol.homeplusplant import HomePlusPlant -import pytest - -from homeassistant.components.home_plus_control.const import DOMAIN - -from tests.common import MockConfigEntry - -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -SUBSCRIPTION_KEY = "12345678901234567890123456789012" - - -@pytest.fixture -def mock_config_entry(): - """Return a fake config entry. - - This is a minimal entry to setup the integration and to ensure that the - OAuth access token will not expire. - """ - return MockConfigEntry( - domain=DOMAIN, - title="Home+ Control", - data={ - "auth_implementation": "home_plus_control", - "token": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 9999999999, - "expires_at": 9999999999.99999999, - "expires_on": 9999999999, - }, - }, - source="test", - options={}, - unique_id=DOMAIN, - entry_id="home_plus_control_entry_id", - ) - - -@pytest.fixture -def mock_modules(): - """Return the full set of mock modules.""" - plant = HomePlusPlant( - id="123456789009876543210", name="My Home", country="ES", oauth_client=None - ) - modules = { - "0000000987654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000987654321fedcba", - name="Kitchen Wall Outlet", - hw_type="NLP", - device="plug", - fw="42", - reachable=True, - ), - "0000000887654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000887654321fedcba", - name="Bedroom Wall Outlet", - hw_type="NLP", - device="light", - fw="42", - reachable=True, - ), - "0000000787654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000787654321fedcba", - name="Living Room Ceiling Light", - hw_type="NLF", - device="light", - fw="46", - reachable=True, - ), - "0000000687654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000687654321fedcba", - name="Dining Room Ceiling Light", - hw_type="NLF", - device="light", - fw="46", - reachable=True, - ), - "0000000587654321fedcba": HomePlusInteractiveModule( - plant, - id="0000000587654321fedcba", - name="Dining Room Wall Outlet", - hw_type="NLP", - device="plug", - fw="42", - reachable=True, - ), - } - - # Set lights off and plugs on - for mod_stat in modules.values(): - mod_stat.status = "on" - if mod_stat.device == "light": - mod_stat.status = "off" - - return modules diff --git a/tests/components/home_plus_control/test_config_flow.py b/tests/components/home_plus_control/test_config_flow.py deleted file mode 100644 index 19d12b946e8..00000000000 --- a/tests/components/home_plus_control/test_config_flow.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Test the Legrand Home+ Control config flow.""" -from http import HTTPStatus -from unittest.mock import patch - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -) -> None: - """Check full flow.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "auth" - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - with patch( - "homeassistant.components.home_plus_control.async_setup_entry", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Home+ Control" - config_data = result["data"] - assert config_data["token"]["refresh_token"] == "mock-refresh-token" - assert config_data["token"]["access_token"] == "mock-access-token" - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 - - -async def test_abort_if_entry_in_progress( - hass: HomeAssistant, current_request_with_host: None -) -> None: - """Check flow abort when an entry is already in progress.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - - # Start one flow - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - # Attempt to start another flow - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "already_in_progress" - - -async def test_abort_if_entry_exists( - hass: HomeAssistant, current_request_with_host: None -) -> None: - """Check flow abort when an entry already exists.""" - existing_entry = MockConfigEntry(domain=DOMAIN) - existing_entry.add_to_hass(hass) - - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - "http": {}, - }, - ) - - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_abort_if_invalid_token( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -) -> None: - """Check flow abort when the token has an invalid value.""" - assert await setup.async_setup_component( - hass, - "home_plus_control", - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - result = await hass.config_entries.flow.async_init( - "home_plus_control", context={"source": config_entries.SOURCE_USER} - ) - - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "auth" - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": "non-integer", - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "oauth_error" diff --git a/tests/components/home_plus_control/test_init.py b/tests/components/home_plus_control/test_init.py deleted file mode 100644 index 962ae416aa5..00000000000 --- a/tests/components/home_plus_control/test_init.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test the Legrand Home+ Control integration.""" -from unittest.mock import patch - -from homeassistant import config_entries, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, -) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - - -async def test_loading(hass: HomeAssistant, mock_config_entry) -> None: - """Test component loading.""" - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value={}, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - - assert len(mock_check.mock_calls) == 1 - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - -async def test_loading_with_no_config(hass: HomeAssistant, mock_config_entry) -> None: - """Test component loading failure when it has not configuration.""" - mock_config_entry.add_to_hass(hass) - await setup.async_setup_component(hass, DOMAIN, {}) - # Component setup fails because the oauth2 implementation could not be registered - assert mock_config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR - - -async def test_unloading(hass: HomeAssistant, mock_config_entry) -> None: - """Test component unloading.""" - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value={}, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - - assert len(mock_check.mock_calls) == 1 - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # We now unload the entry - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py deleted file mode 100644 index d41977d57a9..00000000000 --- a/tests/components/home_plus_control/test_switch.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Test the Legrand Home+ Control switch platform.""" -import datetime as dt -from unittest.mock import patch - -from homepluscontrol.homeplusapi import HomePlusControlApiError - -from homeassistant import config_entries, setup -from homeassistant.components.home_plus_control.const import ( - CONF_SUBSCRIPTION_KEY, - DOMAIN, -) -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from .conftest import CLIENT_ID, CLIENT_SECRET, SUBSCRIPTION_KEY - -from tests.common import async_fire_time_changed - - -def entity_assertions( - hass, - num_exp_entities, - num_exp_devices=None, - expected_entities=None, - expected_devices=None, -): - """Assert number of entities and devices.""" - entity_reg = er.async_get(hass) - device_reg = dr.async_get(hass) - - if num_exp_devices is None: - num_exp_devices = num_exp_entities - - assert len(entity_reg.entities) == num_exp_entities - assert len(device_reg.devices) == num_exp_devices - - if expected_entities is not None: - for exp_entity_id, present in expected_entities.items(): - assert bool(entity_reg.async_get(exp_entity_id)) == present - - if expected_devices is not None: - for exp_device_id, present in expected_devices.items(): - assert bool(device_reg.async_get(exp_device_id)) == present - - -def one_entity_state(hass, device_uid): - """Assert the presence of an entity and return its state.""" - entity_reg = er.async_get(hass) - device_reg = dr.async_get(hass) - - device_id = device_reg.async_get_device(identifiers={(DOMAIN, device_uid)}).id - entity_entries = er.async_entries_for_device(entity_reg, device_id) - - assert len(entity_entries) == 1 - entity_entry = entity_entries[0] - return hass.states.get(entity_entry.entity_id).state - - -async def test_plant_update( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test entity and device loading.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - -async def test_plant_topology_reduction_change( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an entity leaving the plant topology.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - 5 mock entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Now we refresh the topology with one entity less - mock_modules.pop("0000000987654321fedcba") - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check for plant, topology and module status - this time only 4 left - entity_assertions( - hass, - num_exp_entities=4, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": False, - }, - ) - - -async def test_plant_topology_increase_change( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an entity entering the plant topology.""" - # Remove one module initially - new_module = mock_modules.pop("0000000987654321fedcba") - - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - we have 4 entities to start with - entity_assertions( - hass, - num_exp_entities=4, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": False, - }, - ) - - # Now we refresh the topology with one entity more - mock_modules["0000000987654321fedcba"] = new_module - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - -async def test_module_status_unavailable( - hass: HomeAssistant, mock_config_entry, mock_modules -) -> None: - """Test a module becoming unreachable in the plant.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Check the entities and devices - 5 mock entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Confirm the availability of this particular entity - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_ON - - # Now we refresh the topology with the module being unreachable - mock_modules["0000000987654321fedcba"].reachable = False - - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - await hass.async_block_till_done() - # The entity is present, but not available - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE - - -async def test_module_status_available( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test a module becoming reachable in the plant.""" - # Set the module initially unreachable - mock_modules["0000000987654321fedcba"].reachable = False - - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # This particular entity is not available - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE - - # Now we refresh the topology with the module being reachable - mock_modules["0000000987654321fedcba"].reachable = True - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities remain the same - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # Now the entity is available - test_entity_uid = "0000000987654321fedcba" - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_ON - - -async def test_initial_api_error( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an API error on initial call.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - side_effect=HomePlusControlApiError, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # The component has been loaded - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # Check the entities and devices - None have been configured - entity_assertions(hass, num_exp_entities=0) - - -async def test_update_with_api_error( - hass: HomeAssistant, - mock_config_entry, - mock_modules, -) -> None: - """Test an API timeout when updating the module data.""" - # Load the entry - mock_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - ) as mock_check: - await setup.async_setup_component( - hass, - DOMAIN, - { - "home_plus_control": { - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - CONF_SUBSCRIPTION_KEY: SUBSCRIPTION_KEY, - }, - }, - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # The component has been loaded - assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED - - # Check the entities and devices - all entities should be there - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - for test_entity_uid in mock_modules: - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state in (STATE_ON, STATE_OFF) - - # Attempt to update the data, but API update fails - with patch( - "homeassistant.components.home_plus_control.api.HomePlusControlAsyncApi.async_get_modules", - return_value=mock_modules, - side_effect=HomePlusControlApiError, - ) as mock_check: - async_fire_time_changed( - hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) - ) - await hass.async_block_till_done() - assert len(mock_check.mock_calls) == 1 - - # Assert the devices and entities - all should still be present - entity_assertions( - hass, - num_exp_entities=5, - expected_entities={ - "switch.dining_room_wall_outlet": True, - "switch.kitchen_wall_outlet": True, - }, - ) - - # This entity has not returned a status, so appears as unavailable - for test_entity_uid in mock_modules: - test_entity_state = one_entity_state(hass, test_entity_uid) - assert test_entity_state == STATE_UNAVAILABLE From 592794566eaa8837d83d58174ca9ae4dea2a18a9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 23 Jan 2024 10:27:41 -0500 Subject: [PATCH 0957/1544] Bump AIOSomecomort to 0.0.25 (#107815) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index c4ddba49357..d0f0c8281f7 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.24"] + "requirements": ["AIOSomecomfort==0.0.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index fcaa4dfbefd..ce637ba1368 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.7 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.24 +AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab476af5688..d91588f330b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.4.7 AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell -AIOSomecomfort==0.0.24 +AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 From ad14ebe7e911955e3d91917338c88c5d04161c33 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 23 Jan 2024 11:15:37 -0500 Subject: [PATCH 0958/1544] Use new config entry update/abort handler in Honeywell (#108726) Use update_reload helper in Honeywell --- homeassistant/components/honeywell/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index dab8353c773..43d08ee2294 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -66,15 +66,13 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.entry, data={ **self.entry.data, **user_input, }, ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", From 069c2b7e38181c348fb243f0ebecbc4e65efba97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 Jan 2024 19:53:09 +0100 Subject: [PATCH 0959/1544] Improve tests of script trace (#108717) * Improve tests of script trace * Update tests after rebase * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Apply suggestions from code review * Adjust --------- Co-authored-by: Martin Hjelmare --- tests/helpers/test_script.py | 143 ++++++++++++++++++++++++++++------- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 501a5caebac..d769d89af69 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -7,7 +7,7 @@ import logging import operator from types import MappingProxyType from unittest import mock -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch from freezegun import freeze_time import pytest @@ -80,6 +80,9 @@ def compare_result_item(key, actual, expected, path): assert actual == expected +ANY_CONTEXT = {"context": ANY} + + def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -102,9 +105,11 @@ def assert_element(trace_element, expected_element, path): else: assert trace_element._error is None - # Don't check variables when script starts + # Ignore the context variable in the first step, take care to not mutate if trace_element.path == "0": - return + expected_element = dict(expected_element) + variables = expected_element.setdefault("variables", {}) + expected_element["variables"] = variables | ANY_CONTEXT if "variables" in expected_element: assert expected_element["variables"] == trace_element._variables @@ -235,7 +240,8 @@ async def test_firing_event_template(hass: HomeAssistant) -> None: "list": ["yes", "yesyes"], "list2": ["yes", "yesyes"], }, - } + }, + "variables": {"is_world": "yes"}, } ], } @@ -327,7 +333,8 @@ async def test_calling_service_template(hass: HomeAssistant) -> None: "target": {}, }, "running_script": False, - } + }, + "variables": {"is_world": "yes"}, } ], } @@ -485,7 +492,8 @@ async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: "target": {}, }, "running_script": False, - } + }, + "variables": {"hello_var": "hello"}, } ], } @@ -918,7 +926,12 @@ async def test_wait_basic(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": {"wait": {"completed": True, "remaining": None}}, + } + ], } ) else: @@ -931,7 +944,8 @@ async def test_wait_basic(hass: HomeAssistant, action_type) -> None: "trigger": {"description": "state of switch.test"}, "remaining": None, } - } + }, + "variables": {"wait": {"remaining": None}}, } ], } @@ -1014,13 +1028,23 @@ async def test_wait_basic_times_out(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], } ) else: assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -1128,14 +1152,24 @@ async def test_cancel_wait(hass: HomeAssistant, action_type) -> None: if action_type == "template": assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], }, expected_script_execution="cancelled", ) else: assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], }, expected_script_execution="cancelled", ) @@ -1288,17 +1322,20 @@ async def test_wait_continue_on_timeout( assert len(events) == n_events if action_type == "template": - variable_wait = {"wait": {"completed": False, "remaining": 0.0}} + result_wait = {"wait": {"completed": False, "remaining": 0.0}} + variable_wait = dict(result_wait) else: - variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} + result_wait = {"wait": {"trigger": None, "remaining": 0.0}} + variable_wait = dict(result_wait) expected_trace = { - "0": [{"result": variable_wait, "variables": variable_wait}], + "0": [{"result": result_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error_type"] = asyncio.TimeoutError expected_script_execution = "aborted" else: + expected_trace["0"][0]["variables"] = variable_wait expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) @@ -1329,7 +1366,15 @@ async def test_wait_template_variables_in(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": { + "data": "switch.test", + "wait": {"completed": True, "remaining": None}, + }, + } + ], } ) @@ -1360,7 +1405,12 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": True, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": True, "remaining": None}}, + "variables": {"wait": {"completed": True, "remaining": None}}, + } + ], } ) @@ -1394,7 +1444,12 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [{"result": {"wait": {"completed": False, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"completed": False, "remaining": None}}, + "variables": {"wait": {"completed": False, "remaining": None}}, + } + ], } ) @@ -1498,7 +1553,12 @@ async def test_wait_for_trigger_bad( assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -1534,7 +1594,12 @@ async def test_wait_for_trigger_generated_exception( assert_action_trace( { - "0": [{"result": {"wait": {"trigger": None, "remaining": None}}}], + "0": [ + { + "result": {"wait": {"trigger": None, "remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": None}}, + } + ], } ) @@ -2589,7 +2654,7 @@ async def test_repeat_var_in_condition(hass: HomeAssistant, condition) -> None: @pytest.mark.parametrize( ("variables", "first_last", "inside_x"), [ - (None, {"repeat": None, "x": None}, None), + (MappingProxyType({}), {"repeat": None, "x": None}, None), (MappingProxyType({"x": 1}), {"repeat": None, "x": 1}, 1), ], ) @@ -2692,7 +2757,12 @@ async def test_repeat_nested( {"repeat": {"first": False, "index": 2, "last": True}}, ] expected_trace = { - "0": [{"result": {"event": "test_event", "event_data": event_data1}}], + "0": [ + { + "result": {"event": "test_event", "event_data": event_data1}, + "variables": variables, + } + ], "1": [{}], "1/repeat/sequence/0": [ { @@ -2839,7 +2909,14 @@ async def test_choose( if var == 3: expected_choice = "default" - expected_trace = {"0": [{"result": {"choice": expected_choice}}]} + expected_trace = { + "0": [ + { + "result": {"choice": expected_choice}, + "variables": {"var": var}, + } + ] + } if var >= 1: expected_trace["0/choose/0"] = [{"result": {"result": var == 1}}] expected_trace["0/choose/0/conditions/0"] = [ @@ -3061,7 +3138,7 @@ async def test_if( assert f"Test Name: If at step 1: Executing step if {choice}" in caplog.text expected_trace = { - "0": [{"result": {"choice": choice}}], + "0": [{"result": {"choice": choice}, "variables": {"var": var}}], "0/if": [{"result": {"result": if_result}}], "0/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"0/{choice}/0": [ @@ -3254,7 +3331,7 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - ) expected_trace = { - "0": [{"result": {}}], + "0": [{"result": {}, "variables": {"what": "world"}}], "0/parallel/0/sequence/0": [ { "result": { @@ -3352,7 +3429,7 @@ async def test_parallel_loop( assert events_loop2[2].data["hello2"] == "loop2_c" expected_trace = { - "0": [{"result": {}}], + "0": [{"result": {}, "variables": {"what": "world"}}], "0/parallel/0/sequence/0": [{"result": {}}], "0/parallel/1/sequence/0": [ { @@ -5471,7 +5548,12 @@ async def test_conversation_response_subscript_if( assert result.conversation_response == response expected_trace = { - "0": [{"result": {"conversation_response": "Testing 123"}}], + "0": [ + { + "result": {"conversation_response": "Testing 123"}, + "variables": {"var": var}, + } + ], "1": [{"result": {"choice": choice}}], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], @@ -5510,7 +5592,12 @@ async def test_conversation_response_not_set_subscript_if( assert result.conversation_response == "Testing 123" expected_trace = { - "0": [{"result": {"conversation_response": "Testing 123"}}], + "0": [ + { + "result": {"conversation_response": "Testing 123"}, + "variables": {"var": var}, + } + ], "1": [{"result": {"choice": choice}}], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], From bfd9bd3ff2aebd8221e6219c4f6b2be54b49680a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 23 Jan 2024 21:12:07 +0100 Subject: [PATCH 0960/1544] Bump pymodbus to v2.6.3 (#108736) --- homeassistant/components/modbus/manifest.json | 2 +- homeassistant/components/modbus/modbus.py | 8 ++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 93a3f22c97d..194eb56757e 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.4"] + "requirements": ["pymodbus==3.6.3"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 95c0cd45332..71631352d52 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -7,12 +7,8 @@ from collections.abc import Callable import logging from typing import Any -from pymodbus.client import ( - ModbusBaseClient, - ModbusSerialClient, - ModbusTcpClient, - ModbusUdpClient, -) +from pymodbus.client import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.client.base import ModbusBaseClient from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer diff --git a/requirements_all.txt b/requirements_all.txt index ce637ba1368..fa636569c70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1937,7 +1937,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.4 +pymodbus==3.6.3 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d91588f330b..76c09f44f76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1482,7 +1482,7 @@ pymeteoclimatic==0.1.0 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.4 +pymodbus==3.6.3 # homeassistant.components.monoprice pymonoprice==0.4 From 37f5c75752f237f80297db27ce095247f09e1495 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 23 Jan 2024 21:17:18 +0100 Subject: [PATCH 0961/1544] Add sensors to Ecovacs (#108686) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecovacs/__init__.py | 1 + .../components/ecovacs/binary_sensor.py | 13 +- .../components/ecovacs/controller.py | 19 - homeassistant/components/ecovacs/entity.py | 29 +- homeassistant/components/ecovacs/icons.json | 38 ++ homeassistant/components/ecovacs/sensor.py | 256 ++++++++ homeassistant/components/ecovacs/strings.json | 43 ++ homeassistant/components/ecovacs/util.py | 27 + homeassistant/components/ecovacs/vacuum.py | 2 +- tests/components/ecovacs/conftest.py | 16 +- .../ecovacs/snapshots/test_sensor.ambr | 585 ++++++++++++++++++ tests/components/ecovacs/test_sensor.py | 115 ++++ tests/components/ecovacs/util.py | 11 +- 13 files changed, 1099 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/ecovacs/sensor.py create mode 100644 tests/components/ecovacs/snapshots/test_sensor.ambr create mode 100644 tests/components/ecovacs/test_sensor.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 6f07b61de4a..22572d47580 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.SENSOR, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index ea22c9de432..e0c7e89d7c2 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -17,13 +17,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .controller import EcovacsController -from .entity import EcovacsDescriptionEntity, EcovacsEntityDescription, EventT +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .util import get_supported_entitites @dataclass(kw_only=True, frozen=True) class EcovacsBinarySensorEntityDescription( BinarySensorEntityDescription, - EcovacsEntityDescription, + EcovacsCapabilityEntityDescription, Generic[EventT], ): """Class describing Deebot binary sensor entity.""" @@ -49,15 +50,13 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - EcovacsBinarySensor, ENTITY_DESCRIPTIONS, async_add_entities + async_add_entities( + get_supported_entitites(controller, EcovacsBinarySensor, ENTITY_DESCRIPTIONS) ) class EcovacsBinarySensor( - EcovacsDescriptionEntity[ - CapabilityEvent[EventT], EcovacsBinarySensorEntityDescription - ], + EcovacsDescriptionEntity[CapabilityEvent[EventT]], BinarySensorEntity, ): """Ecovacs binary sensor.""" diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 78b05a8a7d1..645c5b9bc19 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -23,9 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import EcovacsDescriptionEntity, EcovacsEntityDescription from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) @@ -88,23 +86,6 @@ class EcovacsController: _LOGGER.debug("Controller initialize complete") - def register_platform_add_entities( - self, - entity_class: type[EcovacsDescriptionEntity], - descriptions: tuple[EcovacsEntityDescription, ...], - async_add_entities: AddEntitiesCallback, - ) -> None: - """Create entities from descriptions and add them.""" - new_entites: list[EcovacsDescriptionEntity] = [] - - for device in self.devices: - for description in descriptions: - if capability := description.capability_fn(device.capabilities): - new_entites.append(entity_class(device, capability, description)) - - if new_entites: - async_add_entities(new_entites) - async def teardown(self) -> None: """Disconnect controller.""" for device in self.devices: diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 3a2bb03aabb..20de6914700 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -16,26 +16,13 @@ from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN -_EntityDescriptionT = TypeVar("_EntityDescriptionT", bound=EntityDescription) CapabilityT = TypeVar("CapabilityT") EventT = TypeVar("EventT", bound=Event) -@dataclass(kw_only=True, frozen=True) -class EcovacsEntityDescription( - EntityDescription, - Generic[CapabilityT], -): - """Ecovacs entity description.""" - - capability_fn: Callable[[Capabilities], CapabilityT | None] - - -class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): +class EcovacsEntity(Entity, Generic[CapabilityT]): """Ecovacs entity.""" - entity_description: _EntityDescriptionT - _attr_should_poll = False _attr_has_entity_name = True _always_available: bool = False @@ -106,16 +93,26 @@ class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): self._device.events.request_refresh(event_type) -class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT, _EntityDescriptionT]): +class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT]): """Ecovacs entity.""" def __init__( self, device: Device, capability: CapabilityT, - entity_description: _EntityDescriptionT, + entity_description: EntityDescription, **kwargs: Any, ) -> None: """Initialize entity.""" self.entity_description = entity_description super().__init__(device, capability, **kwargs) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsCapabilityEntityDescription( + EntityDescription, + Generic[CapabilityT], +): + """Ecovacs entity description.""" + + capability_fn: Callable[[Capabilities], CapabilityT | None] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 74c27776f64..50c03ad2bd2 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -7,6 +7,44 @@ "on": "mdi:water" } } + }, + "sensor": { + "error": { + "default": "mdi:alert-circle" + }, + "lifespan_brush": { + "default": "mdi:broom" + }, + "lifespan_filter": { + "default": "mdi:air-filter" + }, + "lifespan_side_brush": { + "default": "mdi:broom" + }, + "network_ip": { + "default": "mdi:ip-network-outline" + }, + "network_rssi": { + "default": "mdi:signal-variant" + }, + "network_ssid": { + "default": "mdi:wifi" + }, + "stats_area": { + "default": "mdi:floor-plan" + }, + "stats_time": { + "default": "mdi:timer-outline" + }, + "total_stats_area": { + "default": "mdi:floor-plan" + }, + "total_stats_time": { + "default": "mdi:timer-outline" + }, + "total_stats_cleanings": { + "default": "mdi:counter" + } } } } diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py new file mode 100644 index 00000000000..48c1fbbcecc --- /dev/null +++ b/homeassistant/components/ecovacs/sensor.py @@ -0,0 +1,256 @@ +"""Ecovacs sensor module.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan +from deebot_client.events import ( + BatteryEvent, + ErrorEvent, + Event, + LifeSpan, + LifeSpanEvent, + NetworkInfoEvent, + StatsEvent, + TotalStatsEvent, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + AREA_SQUARE_METERS, + ATTR_BATTERY_LEVEL, + CONF_DESCRIPTION, + PERCENTAGE, + EntityCategory, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, + EventT, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSensorEntityDescription( + EcovacsCapabilityEntityDescription, + SensorEntityDescription, + Generic[EventT], +): + """Ecovacs sensor entity description.""" + + value_fn: Callable[[EventT], StateType] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( + # Stats + EcovacsSensorEntityDescription[StatsEvent]( + key="stats_area", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.area, + translation_key="stats_area", + native_unit_of_measurement=AREA_SQUARE_METERS, + ), + EcovacsSensorEntityDescription[StatsEvent]( + key="stats_time", + capability_fn=lambda caps: caps.stats.clean, + value_fn=lambda e: e.time, + translation_key="stats_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + # TotalStats + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.area, + key="total_stats_area", + translation_key="total_stats_area", + native_unit_of_measurement=AREA_SQUARE_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.time, + key="total_stats_time", + translation_key="total_stats_time", + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[TotalStatsEvent]( + capability_fn=lambda caps: caps.stats.total, + value_fn=lambda e: e.cleanings, + key="total_stats_cleanings", + translation_key="total_stats_cleanings", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + EcovacsSensorEntityDescription[BatteryEvent]( + capability_fn=lambda caps: caps.battery, + value_fn=lambda e: e.value, + key=ATTR_BATTERY_LEVEL, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.ip, + key="network_ip", + translation_key="network_ip", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.rssi, + key="network_rssi", + translation_key="network_rssi", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + EcovacsSensorEntityDescription[NetworkInfoEvent]( + capability_fn=lambda caps: caps.network, + value_fn=lambda e: e.ssid, + key="network_ssid", + translation_key="network_ssid", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +@dataclass(kw_only=True, frozen=True) +class EcovacsLifespanSensorEntityDescription(SensorEntityDescription): + """Ecovacs lifespan sensor entity description.""" + + component: LifeSpan + value_fn: Callable[[LifeSpanEvent], int | float] + + +LIFESPAN_ENTITY_DESCRIPTIONS = tuple( + EcovacsLifespanSensorEntityDescription( + component=component, + value_fn=lambda e: e.percent, + key=f"lifespan_{component.name.lower()}", + translation_key=f"lifespan_{component.name.lower()}", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + for component in ( + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, + ) +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsSensor, ENTITY_DESCRIPTIONS + ) + for device in controller.devices: + lifespan_capability = device.capabilities.life_span + for description in LIFESPAN_ENTITY_DESCRIPTIONS: + if description.component in lifespan_capability.types: + entities.append( + EcovacsLifespanSensor(device, lifespan_capability, description) + ) + + if capability := device.capabilities.error: + entities.append(EcovacsErrorSensor(device, capability)) + + async_add_entities(entities) + + +class EcovacsSensor( + EcovacsDescriptionEntity[CapabilityEvent], + SensorEntity, +): + """Ecovacs sensor.""" + + entity_description: EcovacsSensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: Event) -> None: + value = self.entity_description.value_fn(event) + if value is None: + return + + self._attr_native_value = value + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + +class EcovacsLifespanSensor( + EcovacsDescriptionEntity[CapabilityLifeSpan], + SensorEntity, +): + """Lifespan sensor.""" + + entity_description: EcovacsLifespanSensorEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: LifeSpanEvent) -> None: + if event.type == self.entity_description.component: + self._attr_native_value = self.entity_description.value_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + +class EcovacsErrorSensor( + EcovacsEntity[CapabilityEvent[ErrorEvent]], + SensorEntity, +): + """Error sensor.""" + + _always_available = True + _unrecorded_attributes = frozenset({CONF_DESCRIPTION}) + entity_description: SensorEntityDescription = SensorEntityDescription( + key="error", + translation_key="error", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: ErrorEvent) -> None: + self._attr_native_value = event.code + self._attr_extra_state_attributes = {CONF_DESCRIPTION: event.description} + + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 6e4c97be360..7497e97e795 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -24,6 +24,49 @@ "name": "Mop attached" } }, + "sensor": { + "error": { + "name": "Error", + "state_attributes": { + "description": { + "name": "Description" + } + } + }, + "lifespan_brush": { + "name": "Brush lifespan" + }, + "lifespan_filter": { + "name": "Filter lifespan" + }, + "lifespan_side_brush": { + "name": "Side brush lifespan" + }, + "network_ip": { + "name": "IP address" + }, + "network_rssi": { + "name": "Wi-Fi RSSI" + }, + "network_ssid": { + "name": "Wi-Fi SSID" + }, + "stats_area": { + "name": "Area cleaned" + }, + "stats_time": { + "name": "Time cleaned" + }, + "total_stats_area": { + "name": "Total area cleaned" + }, + "total_stats_cleanings": { + "name": "Total cleanings" + }, + "total_stats_time": { + "name": "Total time cleaned" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index d16214346ab..28750d4f9de 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -1,7 +1,18 @@ """Ecovacs util functions.""" +from __future__ import annotations import random import string +from typing import TYPE_CHECKING + +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) + +if TYPE_CHECKING: + from .controller import EcovacsController def get_client_device_id() -> str: @@ -9,3 +20,19 @@ def get_client_device_id() -> str: return "".join( random.choice(string.ascii_uppercase + string.digits) for _ in range(8) ) + + +def get_supported_entitites( + controller: EcovacsController, + entity_class: type[EcovacsDescriptionEntity], + descriptions: tuple[EcovacsCapabilityEntityDescription, ...], +) -> list[EcovacsEntity]: + """Return all supported entities for all devices.""" + entities: list[EcovacsEntity] = [] + + for device in controller.devices: + for description in descriptions: + if capability := description.capability_fn(device.capabilities): + entities.append(entity_class(device, capability, description)) + + return entities diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index a4927ab1e9f..debd751bb79 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -210,7 +210,7 @@ _ATTR_ROOMS = "rooms" class EcovacsVacuum( - EcovacsEntity[Capabilities, StateVacuumEntityDescription], + EcovacsEntity[Capabilities], StateVacuumEntity, ): """Ecovacs vacuum.""" diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 38ae8ea54ae..65b214e6b9c 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -38,13 +38,13 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def device_classes() -> list[str]: - """Device classes, which should be returned by the get_devices api call.""" - return ["yna5x1"] +def device_fixture() -> str: + """Device class, which should be returned by the get_devices api call.""" + return "yna5x1" @pytest.fixture -def mock_authenticator(device_classes: list[str]) -> Generator[Mock, None, None]: +def mock_authenticator(device_fixture: str) -> Generator[Mock, None, None]: """Mock the authenticator.""" with patch( "homeassistant.components.ecovacs.controller.Authenticator", @@ -56,11 +56,9 @@ def mock_authenticator(device_classes: list[str]) -> Generator[Mock, None, None] authenticator = mock.return_value authenticator.authenticate.return_value = Credentials("token", "user_id", 0) - devices = [] - for device_class in device_classes: - devices.append( - load_json_object_fixture(f"devices/{device_class}/device.json", DOMAIN) - ) + devices = [ + load_json_object_fixture(f"devices/{device_fixture}/device.json", DOMAIN) + ] def post_authenticated( path: str, diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ab0de50ea09 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -0,0 +1,585 @@ +# serializer version: 1 +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_area', + 'unique_id': 'E1234567890000000001_stats_area', + 'unit_of_measurement': 'm²', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Area cleaned', + 'unit_of_measurement': 'm²', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_area_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000001_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Ozmo 950 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_battery', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_brush', + 'unique_id': 'E1234567890000000001_lifespan_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_error:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': 'E1234567890000000001_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_error:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'description': 'NoError: Robot is operational', + 'friendly_name': 'Ozmo 950 Error', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_error', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_filter_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_filter', + 'unique_id': 'E1234567890000000001_lifespan_filter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Filter lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_filter_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '56', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_ip_address:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ip', + 'unique_id': 'E1234567890000000001_network_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_ip_address:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 IP address', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_ip_address', + 'last_changed': , + 'last_updated': , + 'state': '192.168.0.10', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brush_lifespan:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifespan_side_brush', + 'unique_id': 'E1234567890000000001_lifespan_side_brush', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Side brush lifespan', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_side_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_time_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Time cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stats_time', + 'unique_id': 'E1234567890000000001_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Time cleaned', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_time_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_area_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total area cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_area', + 'unique_id': 'E1234567890000000001_total_stats_area', + 'unit_of_measurement': 'm²', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Total area cleaned', + 'state_class': , + 'unit_of_measurement': 'm²', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_area_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleanings:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_cleanings', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleanings', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_cleanings', + 'unique_id': 'E1234567890000000001_total_stats_cleanings', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleanings:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Total cleanings', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_cleanings', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_time_cleaned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total time cleaned', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': 'E1234567890000000001_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Total time cleaned', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_time_cleaned', + 'last_changed': , + 'last_updated': , + 'state': '144000', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_rssi', + 'unique_id': 'E1234567890000000001_network_rssi', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Wi-Fi RSSI', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_wi_fi_rssi', + 'last_changed': , + 'last_updated': , + 'state': '-62', + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'network_ssid', + 'unique_id': 'E1234567890000000001_network_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'Testnetwork', + }) +# --- diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py new file mode 100644 index 00000000000..35dc0dbbe53 --- /dev/null +++ b/tests/components/ecovacs/test_sensor.py @@ -0,0 +1,115 @@ +"""Tests for Ecovacs sensors.""" + +from deebot_client.event_bus import EventBus +from deebot_client.events import ( + BatteryEvent, + ErrorEvent, + LifeSpan, + LifeSpanEvent, + NetworkInfoEvent, + StatsEvent, + TotalStatsEvent, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(StatsEvent(10, 300, "spotArea")) + event_bus.notify(TotalStatsEvent(60, 144000, 123)) + event_bus.notify(BatteryEvent(100)) + event_bus.notify(BatteryEvent(100)) + event_bus.notify( + NetworkInfoEvent("192.168.0.10", "Testnetwork", -62, "AA:BB:CC:DD:EE:FF") + ) + event_bus.notify(LifeSpanEvent(LifeSpan.BRUSH, 80, 60 * 60)) + event_bus.notify(LifeSpanEvent(LifeSpan.FILTER, 56, 40 * 60)) + event_bus.notify(LifeSpanEvent(LifeSpan.SIDE_BRUSH, 40, 20 * 60)) + event_bus.notify(ErrorEvent(0, "NoError: Robot is operational")) + await block_till_done(hass, event_bus) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "sensor.ozmo_950_area_cleaned", + "sensor.ozmo_950_battery", + "sensor.ozmo_950_brush_lifespan", + "sensor.ozmo_950_error", + "sensor.ozmo_950_filter_lifespan", + "sensor.ozmo_950_ip_address", + "sensor.ozmo_950_side_brush_lifespan", + "sensor.ozmo_950_time_cleaned", + "sensor.ozmo_950_total_area_cleaned", + "sensor.ozmo_950_total_cleanings", + "sensor.ozmo_950_total_time_cleaned", + "sensor.ozmo_950_wi_fi_rssi", + "sensor.ozmo_950_wi_fi_ssid", + ], + ), + ], +) +async def test_sensors( + hass: HomeAssistant, + controller: EcovacsController, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_ids: list[str], +) -> None: + """Test that sensor entity snapshots match.""" + assert entity_ids == sorted(hass.states.async_entity_ids(Platform.SENSOR)) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + await notify_events(hass, controller.devices[0].events) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "sensor.ozmo_950_error", + "sensor.ozmo_950_ip_address", + "sensor.ozmo_950_wi_fi_rssi", + "sensor.ozmo_950_wi_fi_ssid", + ], + ), + ], +) +async def test_disabled_by_default_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default sensors.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/util.py b/tests/components/ecovacs/util.py index ba697226ae2..73762128202 100644 --- a/tests/components/ecovacs/util.py +++ b/tests/components/ecovacs/util.py @@ -1,6 +1,4 @@ """Ecovacs test util.""" - - import asyncio from deebot_client.event_bus import EventBus @@ -9,10 +7,15 @@ from deebot_client.events import Event from homeassistant.core import HomeAssistant +async def block_till_done(hass: HomeAssistant, event_bus: EventBus) -> None: + """Block till done.""" + await asyncio.gather(*event_bus._tasks) + await hass.async_block_till_done() + + async def notify_and_wait( hass: HomeAssistant, event_bus: EventBus, event: Event ) -> None: """Block till done.""" event_bus.notify(event) - await asyncio.gather(*event_bus._tasks) - await hass.async_block_till_done() + await block_till_done(hass, event_bus) From a6807b8a7f88476ea9d6c8b13e38be83272ed210 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:20:15 +0100 Subject: [PATCH 0962/1544] Improve vizio typing (#108042) --- homeassistant/components/vizio/__init__.py | 4 ++-- homeassistant/components/vizio/config_flow.py | 12 ++++++------ homeassistant/components/vizio/media_player.py | 13 ++++++------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 0f5b3bc967c..af9e649a8b0 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_APPS not in hass.data[DOMAIN] and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): - store: Store = Store(hass, 1, DOMAIN) + store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) coordinator = VizioAppsDataUpdateCoordinator(hass, store) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator @@ -100,7 +100,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant, store: Store) -> None: + def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: """Initialize.""" super().__init__( hass, diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 014cd3cab0f..792407d2545 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -188,8 +188,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize config flow.""" self._user_schema = None self._must_show_form: bool | None = None - self._ch_type = None - self._pairing_token = None + self._ch_type: str | None = None + self._pairing_token: str | None = None self._data: dict[str, Any] | None = None self._apps: dict[str, list] = {} @@ -208,7 +208,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: # Store current values in case setup fails and user needs to edit @@ -294,8 +294,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if await self.hass.async_add_executor_job( _host_is_same, entry.data[CONF_HOST], import_config[CONF_HOST] ): - updated_options = {} - updated_data = {} + updated_options: dict[str, Any] = {} + updated_data: dict[str, Any] = {} remove_apps = False if entry.data[CONF_HOST] != import_config[CONF_HOST]: @@ -393,10 +393,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Ask user for PIN to complete pairing process. """ errors: dict[str, str] = {} + assert self._data # Start pairing process if it hasn't already started if not self._ch_type and not self._pairing_token: - assert self._data dev = VizioAsync( DEVICE_ID, self._data[CONF_HOST], diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 057fd33e8dc..e3de3caa99d 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from pyvizio import VizioAsync +from pyvizio import AppConfig, VizioAsync from pyvizio.api.apps import find_app_name from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP @@ -144,9 +144,8 @@ class VizioDevice(MediaPlayerEntity): self._apps_coordinator = apps_coordinator self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._current_input = None - self._current_app_config = None - self._attr_app_name = None + self._current_input: str | None = None + self._current_app_config: AppConfig | None = None self._available_inputs: list[str] = [] self._available_apps: list[str] = [] self._all_apps = apps_coordinator.data if apps_coordinator else None @@ -377,7 +376,7 @@ class VizioDevice(MediaPlayerEntity): return self._available_inputs @property - def app_id(self) -> str | None: + def app_id(self): """Return the ID of the current app if it is unknown by pyvizio.""" if self._current_app_config and self.source == UNKNOWN_APP: return { @@ -388,9 +387,9 @@ class VizioDevice(MediaPlayerEntity): return None - async def async_select_sound_mode(self, sound_mode): + async def async_select_sound_mode(self, sound_mode: str) -> None: """Select sound mode.""" - if sound_mode in self._attr_sound_mode_list: + if sound_mode in (self._attr_sound_mode_list or ()): await self._device.set_setting( VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, From d8f16c14ab3b593e0cbfd0a1445f2448413afb3a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 23 Jan 2024 21:50:25 +0100 Subject: [PATCH 0963/1544] Get modbus coverage back to 100% (#108734) Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/modbus/__init__.py | 8 +--- .../components/modbus/base_platform.py | 4 -- homeassistant/components/modbus/validators.py | 40 +++++++++++-------- tests/components/modbus/test_climate.py | 30 ++++++++++++++ tests/components/modbus/test_init.py | 7 ++++ tests/components/modbus/test_sensor.py | 12 ++++++ 6 files changed, 75 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cc1b3c74356..734546a34bc 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -133,12 +133,10 @@ from .const import ( # noqa: F401 ) from .modbus import ModbusHub, async_modbus_setup from .validators import ( - duplicate_entity_validator, + check_config, duplicate_fan_mode_validator, - duplicate_modbus_validator, nan_validator, number_validator, - scan_interval_validator, struct_validator, ) @@ -417,12 +415,10 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( cv.ensure_list, - scan_interval_validator, - duplicate_entity_validator, - duplicate_modbus_validator, [ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), ], + check_config, ), }, extra=vol.ALLOW_EXTRA, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index d3ec06bbdd7..af9b83f8b85 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -96,10 +96,6 @@ class BasePlatform(Entity): "url": "https://www.home-assistant.io/integrations/modbus", }, ) - _LOGGER.warning( - "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" - ) - _LOGGER.warning( "`lazy_error_count`: is deprecated and will be removed in version 2024.7" ) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 5e2129bd90a..e108231c5e6 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -203,6 +203,23 @@ def nan_validator(value: Any) -> int: raise vol.Invalid(f"invalid number {value}") from err +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + warn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(warn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config + + def scan_interval_validator(config: dict) -> dict: """Control scan_interval.""" for hub in config: @@ -306,7 +323,7 @@ def duplicate_entity_validator(config: dict) -> dict: return config -def duplicate_modbus_validator(config: list) -> list: +def duplicate_modbus_validator(config: dict) -> dict: """Control modbus connection for duplicates.""" hosts: set[str] = set() names: set[str] = set() @@ -334,18 +351,9 @@ def duplicate_modbus_validator(config: list) -> list: return config -def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: - """Control modbus climate fan mode values for duplicates.""" - fan_modes: set[int] = set() - errors = [] - for key, value in config[CONF_FAN_MODE_VALUES].items(): - if value in fan_modes: - wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" - _LOGGER.warning(wrn) - errors.append(key) - else: - fan_modes.add(value) - - for key in reversed(errors): - del config[CONF_FAN_MODE_VALUES][key] - return config +def check_config(config: dict) -> dict: + """Do final config check.""" + config2 = duplicate_modbus_validator(config) + config3 = scan_interval_validator(config2) + config4 = duplicate_entity_validator(config3) + return config4 diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 325b68869e0..b6855d7be18 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -309,6 +309,36 @@ async def test_temperature_climate( assert hass.states.get(ENTITY_ID).state == expected +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 1, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_DATA_TYPE: DataType.INT32, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("register_words", "expected"), + [ + ( + None, + "unavailable", + ), + ], +) +async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) -> None: + """Run test for given config.""" + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index df415807119..da46979526f 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -56,6 +56,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RETRIES, CONF_RETRY_ON_EMPTY, CONF_SLAVE_COUNT, CONF_STOPBITS, @@ -610,6 +611,12 @@ async def test_duplicate_entity_validator_with_climate(do_config) -> None: CONF_PORT: TEST_PORT_TCP, CONF_CLOSE_COMM_ON_ERROR: True, }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_RETRIES: 3, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 8fb7f9fd951..c9e943b06a7 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, + CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -173,6 +174,17 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT32, + CONF_VIRTUAL_COUNT: 5, + CONF_LAZY_ERROR: 3, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: From 823f26805459a37c0cbe07617a7f5a56cd5d7330 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 Jan 2024 22:58:28 +0100 Subject: [PATCH 0964/1544] Randomize thread network names (#108302) * Randomize thread network names * Use PAN ID as network name suffix * Apply suggestions from code review Co-authored-by: Stefan Agner * Update tests * Format code * Change format of network name again --------- Co-authored-by: Stefan Agner --- homeassistant/components/otbr/config_flow.py | 10 ++++++++-- homeassistant/components/otbr/util.py | 12 ++++++++++++ homeassistant/components/otbr/websocket_api.py | 13 +++++++++++-- tests/components/otbr/test_config_flow.py | 12 +++++++++--- tests/components/otbr/test_websocket_api.py | 9 +++++++-- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 35772c00a89..b96e276af8b 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -28,7 +28,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN -from .util import get_allowed_channel +from .util import ( + compose_default_network_name, + generate_random_pan_id, + get_allowed_channel, +) _LOGGER = logging.getLogger(__name__) @@ -85,10 +89,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug( "not importing TLV with channel %s", thread_dataset_channel ) + pan_id = generate_random_pan_id() await api.create_active_dataset( python_otbr_api.ActiveDataSet( channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, - network_name="home-assistant", + network_name=compose_default_network_name(pan_id), + pan_id=pan_id, ) ) await api.set_enabled(True) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 85e97209a44..9c47df5eaf7 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Coroutine import dataclasses from functools import wraps import logging +import random from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api @@ -48,6 +49,17 @@ INSECURE_PASSPHRASES = ( ) +def compose_default_network_name(pan_id: int) -> str: + """Generate a default network name.""" + return f"ha-thread-{pan_id:04x}" + + +def generate_random_pan_id() -> int: + """Generate a random PAN ID.""" + # PAN ID is 2 bytes, 0xffff is reserved for broadcast + return random.randint(0, 0xFFFE) + + def _handle_otbr_error( func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 0693bc3a325..163152a4bff 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -16,7 +16,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN -from .util import OTBRData, get_allowed_channel, update_issues +from .util import ( + OTBRData, + compose_default_network_name, + generate_random_pan_id, + get_allowed_channel, + update_issues, +) @callback @@ -99,10 +105,13 @@ async def websocket_create_network( connection.send_error(msg["id"], "factory_reset_failed", str(exc)) return + pan_id = generate_random_pan_id() try: await data.create_active_dataset( python_otbr_api.ActiveDataSet( - channel=channel, network_name="home-assistant" + channel=channel, + network_name=compose_default_network_name(pan_id), + pan_id=pan_id, ) ) except HomeAssistantError as exc: diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index deb8672b961..1a0216825b4 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -121,9 +121,11 @@ async def test_user_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" @@ -425,9 +427,11 @@ async def test_hassio_discovery_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" @@ -532,9 +536,11 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + pan_id = aioclient_mock.mock_calls[-2][2]["PanId"] assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, - "NetworkName": "home-assistant", + "NetworkName": f"ha-thread-{pan_id:04x}", + "PanId": pan_id, } assert aioclient_mock.mock_calls[-1][0] == "PUT" diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 8288e7e9f70..c9f5327534a 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -105,7 +105,10 @@ async def test_create_network( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ) as get_active_dataset_tlvs_mock, patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add: + ) as mock_add, patch( + "homeassistant.components.otbr.util.random.randint", + return_value=0x1234, + ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -113,7 +116,9 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") + python_otbr_api.models.ActiveDataSet( + channel=15, network_name="ha-thread-1234", pan_id=0x1234 + ) ) factory_reset_mock.assert_called_once_with() assert len(set_enabled_mock.mock_calls) == 2 From c725238c209392fede2c65e67214e5b29637cb16 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 23 Jan 2024 23:36:44 +0100 Subject: [PATCH 0965/1544] Fix alexa fails reporting the state in specific cases (#108743) * Fix alexa fails reporting the state in specific cases * More cases --- .../components/alexa/capabilities.py | 32 +++++++++++-------- homeassistant/components/alexa/entities.py | 5 +-- homeassistant/components/alexa/handlers.py | 8 ++--- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index ab3bd8591fd..d30f3f7376d 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -860,8 +860,8 @@ class AlexaInputController(AlexaCapability): def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" - source_list: list[Any] = self.entity.attributes.get( - media_player.ATTR_INPUT_SOURCE_LIST, [] + source_list: list[Any] = ( + self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] ) return AlexaInputController.get_valid_inputs(source_list) @@ -1196,7 +1196,7 @@ class AlexaThermostatController(AlexaCapability): return None supported_modes: list[str] = [] - hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [] for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1422,18 +1422,22 @@ class AlexaModeController(AlexaCapability): # Humidifier mode if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": - mode = self.entity.attributes.get(humidifier.ATTR_MODE, None) - if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): + mode = self.entity.attributes.get(humidifier.ATTR_MODE) + modes: list[str] = ( + self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or [] + ) + if mode in modes: return f"{humidifier.ATTR_MODE}.{mode}" # Water heater operation mode if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": operation_mode = self.entity.attributes.get( - water_heater.ATTR_OPERATION_MODE, None + water_heater.ATTR_OPERATION_MODE ) - if operation_mode in self.entity.attributes.get( - water_heater.ATTR_OPERATION_LIST, [] - ): + operation_modes: list[str] = ( + self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or [] + ) + if operation_mode in operation_modes: return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" # Cover Position @@ -1492,7 +1496,7 @@ class AlexaModeController(AlexaCapability): self._resource = AlexaModeResource( [AlexaGlobalCatalog.SETTING_PRESET], False ) - preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES, []) + preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES) or [] for preset_mode in preset_modes: self._resource.add_mode( f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] @@ -1508,7 +1512,7 @@ class AlexaModeController(AlexaCapability): # Humidifier modes if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) - modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []) + modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or [] for mode in modes: self._resource.add_mode(f"{humidifier.ATTR_MODE}.{mode}", [mode]) # Humidifiers or Fans with a single mode completely break Alexa discovery, @@ -1522,8 +1526,8 @@ class AlexaModeController(AlexaCapability): # Water heater operation modes if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) - operation_modes = self.entity.attributes.get( - water_heater.ATTR_OPERATION_LIST, [] + operation_modes = ( + self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or [] ) for operation_mode in operation_modes: self._resource.add_mode( @@ -2368,7 +2372,7 @@ class AlexaEqualizerController(AlexaCapability): """Return the sound modes supported in the configurations object.""" configurations = None supported_sound_modes = self.get_valid_inputs( - self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST, []) + self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] ) if supported_sound_modes: configurations = {"modes": {"supported": supported_sound_modes}} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d0e265b8454..70679f8dafb 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -478,7 +478,7 @@ class ClimateCapabilities(AlexaEntity): if ( self.entity.domain == climate.DOMAIN and climate.HVACMode.OFF - in self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) or self.entity.domain == water_heater.DOMAIN and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): @@ -742,7 +742,8 @@ class MediaPlayerCapabilities(AlexaEntity): and domain != "denonavr" ): inputs = AlexaEqualizerController.get_valid_inputs( - self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST, []) + self.entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) + or [] ) if len(inputs) > 0: yield AlexaEqualizerController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 68702bc0533..463693f7da6 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -570,7 +570,7 @@ async def async_api_select_input( # Attempt to map the ALL UPPERCASE payload name to a source. # Strips trailing 1 to match single input devices. - source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST, []) + source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST) or [] for source in source_list: formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") @@ -987,7 +987,7 @@ async def async_api_set_thermostat_mode( ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) if ha_preset: - presets = entity.attributes.get(climate.ATTR_PRESET_MODES, []) + presets = entity.attributes.get(climate.ATTR_PRESET_MODES) or [] if ha_preset not in presets: msg = f"The requested thermostat mode {ha_preset} is not supported" @@ -997,7 +997,7 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_PRESET_MODE] = ha_preset elif mode == "CUSTOM": - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, []) + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] custom_mode = directive.payload["thermostatMode"]["customName"] custom_mode = next( (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), @@ -1013,7 +1013,7 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_HVAC_MODE] = custom_mode else: - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, []) + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] ha_modes: dict[str, str] = { k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode } From d8a1c58b120726c4513bab7613696fccb6bc32f9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 23 Jan 2024 19:31:57 -0600 Subject: [PATCH 0966/1544] Fix intent loading and incorporate unmatched entities more (#108423) * Incorporate unmatched entities more * Don't list targets when match is incomplete * Add test for out of range --- .../components/conversation/__init__.py | 104 +++++++---- .../components/conversation/default_agent.py | 161 ++++++++++-------- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 108 +++++++++++- .../conversation/test_default_agent.py | 21 +++ tests/components/conversation/test_init.py | 121 ++++++++++--- 9 files changed, 382 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9caabc6e8d4..bf18d740821 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -9,7 +9,12 @@ import re from typing import Any, Literal from aiohttp import web -from hassil.recognize import RecognizeResult +from hassil.recognize import ( + MISSING_ENTITY, + RecognizeResult, + UnmatchedRangeEntity, + UnmatchedTextEntity, +) import voluptuous as vol from homeassistant import core @@ -317,37 +322,55 @@ async def websocket_hass_agent_debug( ] # Return results for each sentence in the same order as the input. - connection.send_result( - msg["id"], - { - "results": [ - { - "intent": { - "name": result.intent.name, - }, - "slots": { # direct access to values - entity_key: entity.value - for entity_key, entity in result.entities.items() - }, - "details": { - entity_key: { - "name": entity.name, - "value": entity.value, - "text": entity.text, - } - for entity_key, entity in result.entities.items() - }, - "targets": { - state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, result) - }, + result_dicts: list[dict[str, Any] | None] = [] + for result in results: + if result is None: + # Indicate that a recognition failure occurred + result_dicts.append(None) + continue + + successful_match = not result.unmatched_entities + result_dict = { + # Name of the matching intent (or the closest) + "intent": { + "name": result.intent.name, + }, + # Slot values that would be received by the intent + "slots": { # direct access to values + entity_key: entity.value + for entity_key, entity in result.entities.items() + }, + # Extra slot details, such as the originally matched text + "details": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, } - if result is not None - else None - for result in results - ] - }, - ) + for entity_key, entity in result.entities.items() + }, + # Entities/areas/etc. that would be targeted + "targets": {}, + # True if match was successful + "match": successful_match, + # Text of the sentence template that matched (or was closest) + "sentence_template": "", + # When match is incomplete, this will contain the best slot guesses + "unmatched_slots": _get_unmatched_slots(result), + } + + if successful_match: + result_dict["targets"] = { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets(hass, result) + } + + if result.intent_sentence is not None: + result_dict["sentence_template"] = result.intent_sentence.text + + result_dicts.append(result_dict) + + connection.send_result(msg["id"], {"results": result_dicts}) def _get_debug_targets( @@ -393,6 +416,25 @@ def _get_debug_targets( yield state, is_matched +def _get_unmatched_slots( + result: RecognizeResult, +) -> dict[str, str | int]: + """Return a dict of unmatched text/range slot entities.""" + unmatched_slots: dict[str, str | int] = {} + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text == MISSING_ENTITY: + # Don't report since these are just missing context + # slots. + continue + + unmatched_slots[entity.name] = entity.text + elif isinstance(entity, UnmatchedRangeEntity): + unmatched_slots[entity.name] = entity.value + + return unmatched_slots + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3f36e98f85a..3207cde405f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass import functools +import itertools import logging from pathlib import Path import re @@ -20,6 +21,7 @@ from hassil.intents import ( WildcardSlotList, ) from hassil.recognize import ( + MISSING_ENTITY, RecognizeResult, UnmatchedEntity, UnmatchedTextEntity, @@ -75,7 +77,7 @@ class LanguageIntents: intents_dict: dict[str, Any] intent_responses: dict[str, Any] error_responses: dict[str, Any] - loaded_components: set[str] + language_variant: str | None @dataclass(slots=True) @@ -181,9 +183,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents = self._lang_intents.get(language) # Reload intents if missing or new components - if lang_intents is None or ( - lang_intents.loaded_components - self.hass.config.components - ): + if lang_intents is None: # Load intents in executor lang_intents = await self.async_get_or_load_intents(language) @@ -357,6 +357,13 @@ class DefaultAgent(AbstractConversationAgent): intent_context=intent_context, allow_unmatched_entities=True, ): + # Remove missing entities that couldn't be filled from context + for entity_key, entity in list(result.unmatched_entities.items()): + if isinstance(entity, UnmatchedTextEntity) and ( + entity.text == MISSING_ENTITY + ): + result.unmatched_entities.pop(entity_key) + if maybe_result is None: # First result maybe_result = result @@ -364,8 +371,11 @@ class DefaultAgent(AbstractConversationAgent): # Fewer unmatched entities maybe_result = result elif len(result.unmatched_entities) == len(maybe_result.unmatched_entities): - if result.text_chunks_matched > maybe_result.text_chunks_matched: - # More literal text chunks matched + if (result.text_chunks_matched > maybe_result.text_chunks_matched) or ( + (result.text_chunks_matched == maybe_result.text_chunks_matched) + and ("name" in result.unmatched_entities) # prefer entities + ): + # More literal text chunks matched, but prefer entities to areas, etc. maybe_result = result if (maybe_result is not None) and maybe_result.unmatched_entities: @@ -484,84 +494,93 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: intents_dict: dict[str, Any] = {} - loaded_components: set[str] = set() + language_variant: str | None = None else: intents_dict = lang_intents.intents_dict - loaded_components = lang_intents.loaded_components + language_variant = lang_intents.language_variant - # en-US, en_US, en, ... - language_variations = list(_get_language_variations(language)) + domains_langs = get_domains_and_languages() - # Check if any new components have been loaded - intents_changed = False - for component in hass_components: - if component in loaded_components: - continue + if not language_variant: + # Choose a language variant upfront and commit to it for custom + # sentences, etc. + all_language_variants = { + lang.lower(): lang for lang in itertools.chain(*domains_langs.values()) + } - # Don't check component again - loaded_components.add(component) - - # Check for intents for this component with the target language. - # Try en-US, en, etc. - for language_variation in language_variations: - component_intents = get_intents( - component, language_variation, json_load=json_load - ) - if component_intents: - # Merge sentences into existing dictionary - merge_dict(intents_dict, component_intents) - - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded intents component=%s, language=%s (%s)", - component, - language, - language_variation, - ) + # en-US, en_US, en, ... + for maybe_variant in _get_language_variations(language): + matching_variant = all_language_variants.get(maybe_variant.lower()) + if matching_variant: + language_variant = matching_variant break + if not language_variant: + _LOGGER.warning( + "Unable to find supported language variant for %s", language + ) + return None + + # Load intents for all domains supported by this language variant + for domain in domains_langs: + domain_intents = get_intents( + domain, language_variant, json_load=json_load + ) + + if not domain_intents: + continue + + # Merge sentences into existing dictionary + merge_dict(intents_dict, domain_intents) + + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded intents domain=%s, language=%s (%s)", + domain, + language, + language_variant, + ) + # Check for custom sentences in /custom_sentences// if lang_intents is None: # Only load custom sentences once, otherwise they will be re-loaded # when components change. - for language_variation in language_variations: - custom_sentences_dir = Path( - self.hass.config.path("custom_sentences", language_variation) - ) - if custom_sentences_dir.is_dir(): - for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): - with custom_sentences_path.open( - encoding="utf-8" - ) as custom_sentences_file: - # Merge custom sentences - if isinstance( - custom_sentences_yaml := yaml.safe_load( - custom_sentences_file - ), - dict, - ): - merge_dict(intents_dict, custom_sentences_yaml) - else: - _LOGGER.warning( - "Custom sentences file does not match expected format path=%s", - custom_sentences_file.name, - ) + custom_sentences_dir = Path( + self.hass.config.path("custom_sentences", language_variant) + ) + if custom_sentences_dir.is_dir(): + for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): + with custom_sentences_path.open( + encoding="utf-8" + ) as custom_sentences_file: + # Merge custom sentences + if isinstance( + custom_sentences_yaml := yaml.safe_load( + custom_sentences_file + ), + dict, + ): + merge_dict(intents_dict, custom_sentences_yaml) + else: + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded custom sentences language=%s (%s), path=%s", - language, - language_variation, - custom_sentences_path, - ) - - # Stop after first matched language variation - break + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded custom sentences language=%s (%s), path=%s", + language, + language_variant, + custom_sentences_path, + ) # Load sentences from HA config for default language only - if self._config_intents and (language == self.hass.config.language): + if self._config_intents and ( + self.hass.config.language in (language, language_variant) + ): merge_dict( intents_dict, { @@ -598,7 +617,7 @@ class DefaultAgent(AbstractConversationAgent): intents_dict, intent_responses, error_responses, - loaded_components, + language_variant, ) self._lang_intents[language] = lang_intents else: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5de11d7a41a..2d4a9af346d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.2", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.5.3", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4b84c9a81c5..eb8befd1781 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.4.0 hass-nabucasa==0.75.1 -hassil==1.5.2 +hassil==1.5.3 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240112.0 home-assistant-intents==2024.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index fa636569c70..2ba9def2cf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1019,7 +1019,7 @@ hass-nabucasa==0.75.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.2 +hassil==1.5.3 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76c09f44f76..974bd73385a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ habluetooth==2.4.0 hass-nabucasa==0.75.1 # homeassistant.components.conversation -hassil==1.5.2 +hassil==1.5.3 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index b68f2fb8701..1d03bf89ad6 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -539,7 +539,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named kitchen', + 'speech': 'No device or entity named kitchen light', }), }), }), @@ -679,7 +679,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named late added', + 'speech': 'No device or entity named late added light', }), }), }), @@ -759,7 +759,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named kitchen', + 'speech': 'No device or entity named kitchen light', }), }), }), @@ -779,7 +779,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named my cool', + 'speech': 'No device or entity named my cool light', }), }), }), @@ -919,7 +919,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named kitchen', + 'speech': 'No device or entity named kitchen light', }), }), }), @@ -969,7 +969,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named renamed', + 'speech': 'No device or entity named renamed light', }), }), }), @@ -1403,6 +1403,8 @@ 'intent': dict({ 'name': 'HassTurnOn', }), + 'match': True, + 'sentence_template': ' on ( | [in ])', 'slots': dict({ 'name': 'my cool light', }), @@ -1411,6 +1413,8 @@ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ @@ -1423,6 +1427,8 @@ 'intent': dict({ 'name': 'HassTurnOff', }), + 'match': True, + 'sentence_template': '[] ( | [in ]) [to] off', 'slots': dict({ 'name': 'my cool light', }), @@ -1431,6 +1437,8 @@ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ @@ -1448,6 +1456,8 @@ 'intent': dict({ 'name': 'HassTurnOn', }), + 'match': True, + 'sentence_template': ' on [all] in ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -1457,6 +1467,8 @@ 'matched': True, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ @@ -1479,6 +1491,8 @@ 'intent': dict({ 'name': 'HassGetState', }), + 'match': True, + 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', @@ -1489,23 +1503,30 @@ 'matched': False, }), }), + 'unmatched_slots': dict({ + }), }), dict({ 'details': dict({ 'domain': dict({ 'name': 'domain', 'text': '', - 'value': 'script', + 'value': 'scene', }), }), 'intent': dict({ 'name': 'HassTurnOn', }), + 'match': False, + 'sentence_template': '[activate|] [scene] [on]', 'slots': dict({ - 'domain': 'script', + 'domain': 'scene', }), 'targets': dict({ }), + 'unmatched_slots': dict({ + 'name': 'this will not match anything', + }), }), ]), }) @@ -1519,3 +1540,74 @@ }), }) # --- +# name: test_ws_hass_agent_debug_null_result + dict({ + 'results': list([ + None, + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'brightness': dict({ + 'name': 'brightness', + 'text': '100%', + 'value': 100, + }), + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'test light', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': True, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'brightness': 100, + 'name': 'test light', + }), + 'targets': dict({ + 'light.demo_1234': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range.1 + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'test light', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': False, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'name': 'test light', + }), + 'targets': dict({ + }), + 'unmatched_slots': dict({ + 'brightness': 1001, + }), + }), + ]), + }) +# --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 2e815edf1e1..1bd8b5263e5 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -577,3 +577,24 @@ async def test_empty_aliases( names = slot_lists["name"] assert len(names.values) == 1 assert names.values[0].value_out == "kitchen light" + + +async def test_all_domains_loaded( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test that sentences for all domains are always loaded.""" + + # light domain is not loaded + assert "light" not in hass.config.components + + result = await conversation.async_converse( + hass, "set brightness of test light to 100%", None, Context(), None + ) + + # Invalid target vs. no intent recognized + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "No device or entity named test light" + ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index b3167d979d5..94ce0932964 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -913,32 +913,6 @@ async def test_language_region(hass: HomeAssistant, init_components) -> None: assert call.data == {"entity_id": ["light.kitchen"]} -async def test_reload_on_new_component(hass: HomeAssistant) -> None: - """Test intents being reloaded when a new component is loaded.""" - language = hass.config.language - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) - - # Load intents - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) - await agent.async_prepare() - - lang_intents = agent._lang_intents.get(language) - assert lang_intents is not None - loaded_components = set(lang_intents.loaded_components) - - # Load another component - assert await async_setup_component(hass, "light", {}) - - # Intents should reload - await agent.async_prepare() - lang_intents = agent._lang_intents.get(language) - assert lang_intents is not None - - assert {"light"} == (lang_intents.loaded_components - loaded_components) - - async def test_non_default_response(hass: HomeAssistant, init_components) -> None: """Test intent response that is not the default.""" hass.states.async_set("cover.front_door", "closed") @@ -1206,7 +1180,7 @@ async def test_ws_hass_agent_debug( "turn my cool light off", "turn on all lights in the kitchen", "how many lights are on in the kitchen?", - "this will not match anything", # null in results + "this will not match anything", # unmatched in results ], } ) @@ -1219,3 +1193,96 @@ async def test_ws_hass_agent_debug( # Light state should not have been changed assert len(on_calls) == 0 assert len(off_calls) == 0 + + +async def test_ws_hass_agent_debug_null_result( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a null result.""" + client = await hass_ws_client(hass) + + async def async_recognize(self, user_input, *args, **kwargs): + if user_input.text == "bad sentence": + return None + + return await self.async_recognize(user_input, *args, **kwargs) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize", + async_recognize, + ): + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "bad sentence", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + assert msg["result"]["results"] == [None] + + +async def test_ws_hass_agent_debug_out_of_range( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with an out of range entity.""" + test_light = entity_registry.async_get_or_create("light", "demo", "1234") + hass.states.async_set( + test_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "test light"} + ) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 100%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert results[0]["match"] + + # Brightness is out of range + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 1001%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert not results[0]["match"] + + # Name matched, but brightness didn't + assert results[0]["slots"] == {"name": "test light"} + assert results[0]["unmatched_slots"] == {"brightness": 1001} From cffd95a015dad372b7316b1731ae77d1fb2958e0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 23 Jan 2024 19:37:25 -0600 Subject: [PATCH 0967/1544] Pause Wyoming satellite on mute (#108322) Pause satellite on mute --- .../components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/satellite.py | 34 +++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 7 ++++ tests/components/wyoming/test_wake_word.py | 3 ++ 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 430e46fd890..14cf9f77683 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.5.0"], + "requirements": ["wyoming==1.5.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 8e7586534f5..0cb2796b9f0 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -12,7 +12,7 @@ from wyoming.client import AsyncTcpClient from wyoming.error import Error from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline -from wyoming.satellite import RunSatellite +from wyoming.satellite import PauseSatellite, RunSatellite from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection @@ -76,6 +76,7 @@ class WyomingSatellite: try: # Check if satellite has been muted while self.device.is_muted: + _LOGGER.debug("Satellite is muted") await self.on_muted() if not self.is_running: # Satellite was stopped while waiting to be unmuted @@ -86,15 +87,23 @@ class WyomingSatellite: except asyncio.CancelledError: raise # don't restart except Exception: # pylint: disable=broad-exception-caught + # Ensure sensor is off (before restart) + self.device.set_is_active(False) + + # Wait to restart await self.on_restart() finally: - # Ensure sensor is off + # Ensure sensor is off (before stop) self.device.set_is_active(False) await self.on_stopped() def stop(self) -> None: """Signal satellite task to stop running.""" + # Tell satellite to stop running + self._send_pause() + + # Stop task loop self.is_running = False # Unblock waiting for unmuted @@ -103,7 +112,7 @@ class WyomingSatellite: async def on_restart(self) -> None: """Block until pipeline loop will be restarted.""" _LOGGER.warning( - "Unexpected error running satellite. Restarting in %s second(s)", + "Satellite has been disconnected. Reconnecting in %s second(s)", _RECONNECT_SECONDS, ) await asyncio.sleep(_RESTART_SECONDS) @@ -126,12 +135,23 @@ class WyomingSatellite: # ------------------------------------------------------------------------- + def _send_pause(self) -> None: + """Send a pause message to satellite.""" + if self._client is not None: + self.hass.async_create_background_task( + self._client.write_event(PauseSatellite().event()), + "pause satellite", + ) + def _muted_changed(self) -> None: """Run when device muted status changes.""" if self.device.is_muted: # Cancel any running pipeline self._audio_queue.put_nowait(None) + # Send pause event so satellite can react immediately + self._send_pause() + self._muted_changed_event.set() self._muted_changed_event.clear() @@ -149,16 +169,18 @@ class WyomingSatellite: async def _connect_and_loop(self) -> None: """Connect to satellite and run pipelines until an error occurs.""" - self.device.set_is_active(False) - while self.is_running and (not self.device.is_muted): try: await self._connect() break except ConnectionError: + self._client = None # client is not valid + await self.on_reconnect() - assert self._client is not None + if self._client is None: + return + _LOGGER.debug("Connected to satellite") if (not self.is_running) or self.device.is_muted: diff --git a/requirements_all.txt b/requirements_all.txt index 2ba9def2cf5..f8093a92aaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2825,7 +2825,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.5.0 +wyoming==1.5.2 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 974bd73385a..8059ffd271d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2148,7 +2148,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.5.0 +wyoming==1.5.2 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 268ebef1d06..2adc9a21b6f 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -35,8 +35,10 @@ STT_INFO = Info( installed=True, attribution=TEST_ATTR, languages=["en-US"], + version=None, ) ], + version=None, ) ] ) @@ -55,8 +57,10 @@ TTS_INFO = Info( attribution=TEST_ATTR, languages=["en-US"], speakers=[TtsVoiceSpeaker(name="Test Speaker")], + version=None, ) ], + version=None, ) ] ) @@ -74,8 +78,10 @@ WAKE_WORD_INFO = Info( installed=True, attribution=TEST_ATTR, languages=["en-US"], + version=None, ) ], + version=None, ) ] ) @@ -86,6 +92,7 @@ SATELLITE_INFO = Info( installed=True, attribution=TEST_ATTR, area="Office", + version=None, ) ) EMPTY_INFO = Info() diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index 36a6daf0452..1ab869b1b0a 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -188,6 +188,7 @@ async def test_dynamic_wake_word_info( installed=True, attribution=TEST_ATTR, languages=[], + version=None, ), WakeModel( name="ww2", @@ -195,8 +196,10 @@ async def test_dynamic_wake_word_info( installed=True, attribution=TEST_ATTR, languages=[], + version=None, ), ], + version=None, ) ] ) From 87898b748765d2c9e9fddf32af67dc205edfedd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 16:04:43 -1000 Subject: [PATCH 0968/1544] Add 3C52A1 oui to tplink for tapo l5 devices (#108750) --- homeassistant/components/tplink/manifest.json | 4 ++++ homeassistant/generated/dhcp.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 587b46bd96d..4873e6db081 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -176,6 +176,10 @@ "hostname": "l5*", "macaddress": "5CE931*" }, + { + "hostname": "l5*", + "macaddress": "3C52A1*" + }, { "hostname": "l5*", "macaddress": "5C628B*" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index dc428f639a7..a087c8ac483 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -813,6 +813,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "l5*", "macaddress": "5CE931*", }, + { + "domain": "tplink", + "hostname": "l5*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "l5*", From 22eed5419efb979ee378a9dd64de29b025985ccf Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:04:59 -0500 Subject: [PATCH 0969/1544] Reduce log level of ZHA endpoint handler init (#108749) * Reduce the log level of endpoint handler init failure to debug * Reduce log level in unit test --- homeassistant/components/zha/core/endpoint.py | 6 +++--- tests/components/zha/test_cluster_handlers.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 4dbfccf6f25..eb91ec96c59 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -199,11 +199,11 @@ class Endpoint: results = await gather(*tasks, return_exceptions=True) for cluster_handler, outcome in zip(cluster_handlers, results): if isinstance(outcome, Exception): - cluster_handler.warning( + cluster_handler.debug( "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome ) - continue - cluster_handler.debug("'%s' stage succeeded", func_name) + else: + cluster_handler.debug("'%s' stage succeeded", func_name) def async_new_entity( self, diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 39f201e668e..46efe306b91 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -591,8 +591,8 @@ async def test_ep_cluster_handlers_configure(cluster_handler) -> None: assert ch.async_configure.call_count == 1 assert ch.async_configure.await_count == 1 - assert ch_3.warning.call_count == 2 - assert ch_5.warning.call_count == 2 + assert ch_3.debug.call_count == 2 + assert ch_5.debug.call_count == 2 async def test_poll_control_configure(poll_control_ch) -> None: From c7db8a0bee3c9f568a5c3ad1946c8ba69a2c8228 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 24 Jan 2024 08:23:39 +0100 Subject: [PATCH 0970/1544] Add translation placeholders for TPLink power strip (#108710) --- homeassistant/components/tplink/entity.py | 2 +- homeassistant/components/tplink/sensor.py | 21 ++++++++++++++------ homeassistant/components/tplink/strings.json | 15 ++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 577e1995d4a..987ac455ae1 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -40,7 +40,7 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): """Initialize the switch.""" super().__init__(coordinator) self.device: SmartDevice = device - self._attr_unique_id = self.device.device_id + self._attr_unique_id = device.device_id self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, identifiers={(DOMAIN, str(device.device_id))}, diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index e5f7ae332ec..a3bb35840b2 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -108,11 +108,13 @@ def async_emeter_from_device( def _async_sensors_for_device( - device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + has_parent: bool = False, ) -> list[SmartPlugSensor]: """Generate the sensors for the device.""" return [ - SmartPlugSensor(device, coordinator, description) + SmartPlugSensor(device, coordinator, description, has_parent) for description in ENERGY_SENSORS if async_emeter_from_device(device, description) is not None ] @@ -136,7 +138,7 @@ async def async_setup_entry( # Historically we only add the children if the device is a strip for idx, child in enumerate(parent.children): entities.extend( - _async_sensors_for_device(child, children_coordinators[idx]) + _async_sensors_for_device(child, children_coordinators[idx], True) ) else: entities.extend(_async_sensors_for_device(parent, parent_coordinator)) @@ -154,13 +156,20 @@ class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator, description: TPLinkSensorEntityDescription, + has_parent: bool = False, ) -> None: """Initialize the switch.""" super().__init__(device, coordinator) self.entity_description = description - self._attr_unique_id = ( - f"{legacy_device_id(self.device)}_{self.entity_description.key}" - ) + self._attr_unique_id = f"{legacy_device_id(device)}_{description.key}" + if has_parent: + assert device.alias + self._attr_translation_placeholders = {"device_name": device.alias} + if description.translation_key: + self._attr_translation_key = f"{description.translation_key}_child" + else: + assert description.device_class + self._attr_translation_key = f"{description.device_class.value}_child" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 3c4711d1632..4aa4a3856bd 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -67,6 +67,21 @@ }, "today_consumption": { "name": "Today's consumption" + }, + "current_consumption_child": { + "name": "{device_name} current consumption" + }, + "total_consumption_child": { + "name": "{device_name} total consumption" + }, + "today_consumption_child": { + "name": "{device_name} today's consumption" + }, + "current_child": { + "name": "{device_name} current" + }, + "voltage_child": { + "name": "{device_name} voltage" } }, "switch": { From f7b0a15aa59f82f40420da3e44b2271baba0186c Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 24 Jan 2024 08:31:23 +0100 Subject: [PATCH 0971/1544] Bumb python-homewizard-energy to 4.2.1 (#108738) --- .../components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 70 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 949dda2a8aa..e2aaceccf2d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==4.1.0"], + "requirements": ["python-homewizard-energy==4.2.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f8093a92aaf..9c8a4c42b3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2202,7 +2202,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.1.0 +python-homewizard-energy==4.2.1 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8059ffd271d..363929ffba4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1678,7 +1678,7 @@ python-fullykiosk==0.0.12 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.1.0 +python-homewizard-energy==4.2.1 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 01094ec2698..bddac0a79dc 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -3,20 +3,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': -4, 'active_current_l2_a': 2, 'active_current_l3_a': 0, 'active_frequency_hz': 50, 'active_liter_lpm': 12.345, 'active_power_average_w': 123.0, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': -123, 'active_power_l2_w': 456, 'active_power_l3_w': 123.456, 'active_power_w': -123, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': 2, 'active_voltage_l1_v': 230.111, 'active_voltage_l2_v': 230.222, 'active_voltage_l3_v': 230.333, + 'active_voltage_v': None, 'any_power_fail_count': 4, 'external_devices': None, 'gas_timestamp': '2021-03-14T11:22:33', @@ -72,20 +86,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': 1457.277, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': 1457.277, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -145,20 +173,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': 0, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': None, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': None, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -212,20 +254,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': -1058.296, 'active_power_l2_w': None, 'active_power_l3_w': None, 'active_power_w': -1058.296, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -281,20 +337,34 @@ dict({ 'data': dict({ 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': None, + 'active_current_a': None, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, 'active_frequency_hz': None, 'active_liter_lpm': None, 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, 'active_power_l1_w': -1058.296, 'active_power_l2_w': 158.102, 'active_power_l3_w': 0.0, 'active_power_w': -900.194, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': None, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, + 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, From 8d1665df1625b26977b8dafa2bdd7a9741bb20e7 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Jan 2024 08:44:10 +0100 Subject: [PATCH 0972/1544] Use fixed state icon for climate domain (#108723) --- homeassistant/components/climate/icons.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index c9e0319924e..69da8c401fb 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -2,15 +2,6 @@ "entity_component": { "_": { "default": "mdi:thermostat", - "state": { - "auto": "mdi:thermostat-auto", - "cool": "mdi:snowflake", - "dry": "mdi:water-percent", - "fan_mode": "mdi:fan", - "heat": "mdi:fire", - "heat_cool": "mdi:sun-snowflake-variant", - "off": "mdi:power" - }, "state_attributes": { "fan_mode": { "default": "mdi:circle-medium", From 00c49134014b7c570502df48d35b2c018d0b5eb4 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Jan 2024 08:44:45 +0100 Subject: [PATCH 0973/1544] Add fan attributes icon translations (#108722) --- homeassistant/components/fan/icons.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json index 512427c9508..ebc4988e87f 100644 --- a/homeassistant/components/fan/icons.json +++ b/homeassistant/components/fan/icons.json @@ -4,6 +4,14 @@ "default": "mdi:fan", "state": { "off": "mdi:fan-off" + }, + "state_attributes": { + "direction": { + "default": "mdi:rotate-right", + "state": { + "reverse": "mdi:rotate-left" + } + } } } }, From 80e66c12b87bd1324358bd99dc340c7ecec90153 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Jan 2024 08:45:30 +0100 Subject: [PATCH 0974/1544] Add humidifier attributes icon translations (#108718) --- .../components/humidifier/icons.json | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/humidifier/icons.json b/homeassistant/components/humidifier/icons.json index 54a01ebaae5..2c67f759195 100644 --- a/homeassistant/components/humidifier/icons.json +++ b/homeassistant/components/humidifier/icons.json @@ -4,6 +4,31 @@ "default": "mdi:air-humidifier", "state": { "off": "mdi:air-humidifier-off" + }, + "state_attributes": { + "action": { + "default": "mdi:circle-medium", + "state": { + "drying": "mdi:arrow-down-bold", + "humidifying": "mdi:arrow-up-bold", + "idle": "mdi:clock-outline", + "off": "mdi:power" + } + }, + "mode": { + "default": "mdi:circle-medium", + "state": { + "auto": "mdi:refresh-auto", + "away": "mdi:account-arrow-right", + "baby": "mdi:baby-carriage", + "boost": "mdi:rocket-launch", + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "home": "mdi:home", + "normal": "mdi:water-percent", + "sleep": "mdi:power-sleep" + } + } } } }, From 21f646c5a7d0d7bc39b833d7ed97bd3dcb710b0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 22:08:20 -1000 Subject: [PATCH 0975/1544] Add LeaOne integration (#108617) --- CODEOWNERS | 2 + homeassistant/components/leaone/__init__.py | 49 ++++++ .../components/leaone/config_flow.py | 57 +++++++ homeassistant/components/leaone/const.py | 3 + homeassistant/components/leaone/device.py | 15 ++ homeassistant/components/leaone/manifest.json | 10 ++ homeassistant/components/leaone/sensor.py | 152 ++++++++++++++++++ homeassistant/components/leaone/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/leaone/__init__.py | 39 +++++ tests/components/leaone/conftest.py | 8 + tests/components/leaone/test_config_flow.py | 94 +++++++++++ tests/components/leaone/test_sensor.py | 54 +++++++ 16 files changed, 517 insertions(+) create mode 100644 homeassistant/components/leaone/__init__.py create mode 100644 homeassistant/components/leaone/config_flow.py create mode 100644 homeassistant/components/leaone/const.py create mode 100644 homeassistant/components/leaone/device.py create mode 100644 homeassistant/components/leaone/manifest.json create mode 100644 homeassistant/components/leaone/sensor.py create mode 100644 homeassistant/components/leaone/strings.json create mode 100644 tests/components/leaone/__init__.py create mode 100644 tests/components/leaone/conftest.py create mode 100644 tests/components/leaone/test_config_flow.py create mode 100644 tests/components/leaone/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 3cdccece944..4ab6493751a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -710,6 +710,8 @@ build.json @home-assistant/supervisor /tests/components/lcn/ @alengwenus /homeassistant/components/ld2410_ble/ @930913 /tests/components/ld2410_ble/ @930913 +/homeassistant/components/leaone/ @bdraco +/tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco /homeassistant/components/lg_netcast/ @Drafteed diff --git a/homeassistant/components/leaone/__init__.py b/homeassistant/components/leaone/__init__.py new file mode 100644 index 00000000000..9f8bac34d55 --- /dev/null +++ b/homeassistant/components/leaone/__init__.py @@ -0,0 +1,49 @@ +"""The Leaone integration.""" +from __future__ import annotations + +import logging + +from leaone_ble import LeaoneBluetoothDeviceData + +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Leaone BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = LeaoneBluetoothDeviceData() + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + coordinator.async_start() + ) # only start after all platforms have had a chance to subscribe + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/leaone/config_flow.py b/homeassistant/components/leaone/config_flow.py new file mode 100644 index 00000000000..5bbf2917332 --- /dev/null +++ b/homeassistant/components/leaone/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for Leaone integration.""" +from __future__ import annotations + +from typing import Any + +from leaone_ble import LeaoneBluetoothDeviceData as DeviceData +import voluptuous as vol + +from homeassistant.components.bluetooth import async_discovered_service_info +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class LeaoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for leaone.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_devices: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = DeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/leaone/const.py b/homeassistant/components/leaone/const.py new file mode 100644 index 00000000000..6556e66e4b4 --- /dev/null +++ b/homeassistant/components/leaone/const.py @@ -0,0 +1,3 @@ +"""Constants for the Leaone integration.""" + +DOMAIN = "leaone" diff --git a/homeassistant/components/leaone/device.py b/homeassistant/components/leaone/device.py new file mode 100644 index 00000000000..a745873b693 --- /dev/null +++ b/homeassistant/components/leaone/device.py @@ -0,0 +1,15 @@ +"""Support for Leaone devices.""" +from __future__ import annotations + +from leaone_ble import DeviceKey + +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothEntityKey, +) + + +def device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) diff --git a/homeassistant/components/leaone/manifest.json b/homeassistant/components/leaone/manifest.json new file mode 100644 index 00000000000..97ac8a06e97 --- /dev/null +++ b/homeassistant/components/leaone/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "leaone", + "name": "LeaOne", + "codeowners": ["@bdraco"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/leaone", + "iot_class": "local_push", + "requirements": ["leaone-ble==0.1.0"] +} diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py new file mode 100644 index 00000000000..a614e63231a --- /dev/null +++ b/homeassistant/components/leaone/sensor.py @@ -0,0 +1,152 @@ +"""Support for Leaone sensors.""" +from __future__ import annotations + +from leaone_ble import DeviceClass as LeaoneSensorDeviceClass, SensorUpdate, Units + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfMass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +from .const import DOMAIN +from .device import device_key_to_bluetooth_entity_key + +SENSOR_DESCRIPTIONS = { + ( + LeaoneSensorDeviceClass.MASS_NON_STABILIZED, + Units.MASS_KILOGRAMS, + ): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.MASS_NON_STABILIZED}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + (LeaoneSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + (LeaoneSensorDeviceClass.IMPEDANCE, Units.OHM): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.IMPEDANCE}_{Units.OHM}", + icon="mdi:omega", + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ( + LeaoneSensorDeviceClass.SIGNAL_STRENGTH, + Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ): SensorEntityDescription( + key=f"{LeaoneSensorDeviceClass.SIGNAL_STRENGTH}_{Units.SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ( + LeaoneSensorDeviceClass.PACKET_ID, + None, + ): SensorEntityDescription( + key=str(LeaoneSensorDeviceClass.PACKET_ID), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +} + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + (description.device_class, description.native_unit_of_measurement) + ] + for device_key, description in sensor_update.entity_descriptions.items() + if description.device_class and description.native_unit_of_measurement + }, + entity_data={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Leaone BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + LeaoneBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) + + +class LeaoneBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + SensorEntity, +): + """Representation of a Leaone sensor.""" + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json new file mode 100644 index 00000000000..6391c754dec --- /dev/null +++ b/homeassistant/components/leaone/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 17d4198628a..278ae748c8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -270,6 +270,7 @@ FLOWS = { "launch_library", "laundrify", "ld2410_ble", + "leaone", "led_ble", "lg_soundbar", "lidarr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4a37eff5a77..7c15e58e37f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3075,6 +3075,12 @@ "config_flow": true, "iot_class": "local_push" }, + "leaone": { + "name": "LeaOne", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "led_ble": { "name": "LED BLE", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 9c8a4c42b3c..ee04e7fbcca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1183,6 +1183,9 @@ laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 +# homeassistant.components.leaone +leaone-ble==0.1.0 + # homeassistant.components.led_ble led-ble==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 363929ffba4..4a609f50961 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -943,6 +943,9 @@ laundrify-aio==1.1.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 +# homeassistant.components.leaone +leaone-ble==0.1.0 + # homeassistant.components.led_ble led-ble==1.0.1 diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py new file mode 100644 index 00000000000..c54e07ccd87 --- /dev/null +++ b/tests/components/leaone/__init__.py @@ -0,0 +1,39 @@ +"""Tests for the Leaone integration.""" + + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +SCALE_SERVICE_INFO = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94"}, + service_uuids=[], + service_data={}, + source="local", +) +SCALE_SERVICE_INFO_2 = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={ + 57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94", + 63424: b"\x06\xa4\x13\x80\x00\x021_Z\\R\xd3\x94", + }, + service_uuids=[], + service_data={}, + source="local", +) +SCALE_SERVICE_INFO_3 = BluetoothServiceInfo( + name="", + address="5F:5A:5C:52:D3:94", + rssi=-63, + manufacturer_data={ + 57280: b"\x06\xa4\x00\x00\x00\x020_Z\\R\xd3\x94", + 63424: b"\x06\xa4\x13\x80\x00\x021_Z\\R\xd3\x94", + 6592: b"\x06\x8e\x00\x00\x00\x020_Z\\R\xd3\x94", + }, + service_uuids=[], + service_data={}, + source="local", +) diff --git a/tests/components/leaone/conftest.py b/tests/components/leaone/conftest.py new file mode 100644 index 00000000000..2f89e80f893 --- /dev/null +++ b/tests/components/leaone/conftest.py @@ -0,0 +1,8 @@ +"""Leaone session fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/leaone/test_config_flow.py b/tests/components/leaone/test_config_flow.py new file mode 100644 index 00000000000..b7e4abdcf6b --- /dev/null +++ b/tests/components/leaone/test_config_flow.py @@ -0,0 +1,94 @@ +"""Test the Leaone config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.leaone.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import SCALE_SERVICE_INFO + +from tests.common import MockConfigEntry + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant) -> None: + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + with patch("homeassistant.components.leaone.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "5F:5A:5C:52:D3:94"}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "TZC4 D394" + assert result2["data"] == {} + assert result2["result"].unique_id == "5F:5A:5C:52:D3:94" + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.leaone.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "5F:5A:5C:52:D3:94"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup( + hass: HomeAssistant, +) -> None: + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.leaone.config_flow.async_discovered_service_info", + return_value=[SCALE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/leaone/test_sensor.py b/tests/components/leaone/test_sensor.py new file mode 100644 index 00000000000..ccf520a7eb7 --- /dev/null +++ b/tests/components/leaone/test_sensor.py @@ -0,0 +1,54 @@ +"""Test the Leaone sensors.""" + +from homeassistant.components.leaone.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from . import SCALE_SERVICE_INFO, SCALE_SERVICE_INFO_2, SCALE_SERVICE_INFO_3 + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5F:5A:5C:52:D3:94", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO) + await hass.async_block_till_done() + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO_2) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + mass_sensor = hass.states.get("sensor.tzc4_d394_mass") + mass_sensor_attrs = mass_sensor.attributes + assert mass_sensor.state == "77.11" + assert mass_sensor_attrs[ATTR_FRIENDLY_NAME] == "TZC4 D394 Mass" + assert mass_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + mass_sensor = hass.states.get("sensor.tzc4_d394_non_stabilized_mass") + mass_sensor_attrs = mass_sensor.attributes + assert mass_sensor.state == "77.11" + assert mass_sensor_attrs[ATTR_FRIENDLY_NAME] == "TZC4 D394 Non Stabilized Mass" + assert mass_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + inject_bluetooth_service_info(hass, SCALE_SERVICE_INFO_3) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 07449659a5b05d8d0cb5706c7c01e3c682887405 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Wed, 24 Jan 2024 09:20:54 +0100 Subject: [PATCH 0976/1544] Bump lupupy to 0.3.2 (#108756) bump lupupy to 0.3.2 Co-authored-by: suaveolent --- homeassistant/components/lupusec/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index e73feef55a1..13a5ac62fee 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.3.1"] + "requirements": ["lupupy==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee04e7fbcca..2b33f8e75b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1235,7 +1235,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.3.1 +lupupy==0.3.2 # homeassistant.components.lw12wifi lw12==0.9.2 From 1cb15a398c4f54aeadfc7ed53eb25c0010e53fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Wed, 24 Jan 2024 11:13:45 +0100 Subject: [PATCH 0977/1544] Add more device info to foscam camera (#108177) --- homeassistant/components/foscam/camera.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index c07ddfd9bfb..343868afb56 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -117,10 +117,16 @@ class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): self._rtsp_port = config_entry.data[CONF_RTSP_PORT] if self._rtsp_port: self._attr_supported_features = CameraEntityFeature.STREAM + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="Foscam", + name=config_entry.title, ) + if dev_info := coordinator.data.get("dev_info"): + self._attr_device_info["model"] = dev_info["productName"] + self._attr_device_info["sw_version"] = dev_info["firmwareVer"] + self._attr_device_info["hw_version"] = dev_info["hardwareVer"] async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" From 393dee1524636c2d30f7d8ea67bfcde17937984c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:24:44 +0000 Subject: [PATCH 0978/1544] Handle IP address changes properly for tplink (#108731) * Update device config for SETUP_RETRY and use CONF_HOST on startup * Make entry state checks use a constant Co-authored-by: J. Nick Koston * Update tests --------- Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/__init__.py | 2 + .../components/tplink/config_flow.py | 7 ++- tests/components/tplink/test_config_flow.py | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 4b684abf280..e2342e617de 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -143,6 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not config: config = DeviceConfig(host) + else: + config.host = host config.timeout = CONNECT_TIMEOUT if config.uses_http is True: diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 68a40d81415..96d720e59a0 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -78,8 +78,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _update_config_if_entry_in_setup_error( self, entry: ConfigEntry, host: str, config: dict ) -> None: - """If discovery encounters a device that is in SETUP_ERROR update the device config.""" - if entry.state is not ConfigEntryState.SETUP_ERROR: + """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config.""" + if entry.state not in ( + ConfigEntryState.SETUP_ERROR, + ConfigEntryState.SETUP_RETRY, + ): return entry_data = entry.data entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 96cfbead5e4..18e22db60f4 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.tplink import ( DOMAIN, AuthenticationException, Credentials, + DeviceConfig, SmartDeviceException, ) from homeassistant.components.tplink.const import CONF_DEVICE_CONFIG @@ -36,6 +37,7 @@ from . import ( MAC_ADDRESS, MAC_ADDRESS2, MODULE, + _mocked_bulb, _patch_connect, _patch_discovery, _patch_single_discovery, @@ -736,6 +738,57 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( assert result["reason"] == "cannot_connect" +async def test_discovery_with_ip_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_connect: AsyncMock, +) -> None: + """Test reauth flow.""" + mock_connect["connect"].side_effect = SmartDeviceException() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == config_entries.ConfigEntryState.SETUP_RETRY + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY + assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1" + + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AUTH, + }, + ) + await hass.async_block_till_done() + assert discovery_result["type"] is FlowResultType.ABORT + assert discovery_result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AUTH + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" + + config = DeviceConfig.from_dict(DEVICE_CONFIG_DICT_AUTH) + + mock_connect["connect"].reset_mock(side_effect=True) + bulb = _mocked_bulb( + device_config=config, + mac=mock_config_entry.unique_id, + ) + mock_connect["connect"].return_value = bulb + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED + # Check that init set the new host correctly before calling connect + assert config.host == "127.0.0.1" + config.host = "127.0.0.2" + mock_connect["connect"].assert_awaited_once_with(config=config) + + async def test_reauth( hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, From 1d7e0e7fe4250ea2a93adbe3a728d4ee4e72c026 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 24 Jan 2024 12:00:51 +0100 Subject: [PATCH 0979/1544] Add bang_olufsen integration (#93462) * Add bangolufsen integration * add untested files to .coveragerc * Simplify integration to media_player platform * Remove missing files from .coveragerc * Add beolink_set_relative_volume custom service Tweaks * Remove custom services Remove grouping as it was dependent on custom services * Update API to 3.2.1.150.0 Reduce and optimize code with feedback from joostlek Tweaks * Updated testing * Remove unused options schema * Fix bugfix setting wrong state * Fix wrong initial state * Bump API * Fix Beosound Level not reconnecting properly * Remove unused constant * Fix wrong variable checked to determine source * Update integration with feedback from emontnemery * Update integration with feedback from emontnemery * Remove unused code * Move API client into dataclass Fix not all config_flow exceptions caught Tweaks * Add Bang & Olufsen brand * Revert "Add Bang & Olufsen brand" This reverts commit 57b2722078ae0b563880306c6457d2cf3f528070. * Remove volume options from setup Simplify device checks rename integration to bang_olufsen update tests to pass Update API * Remove _device from base Add _device to websocket * Move SW version device update to websocket Sort websocket variables * Add WebSocket connection test * Remove unused constants * Remove confirmation form Make discovered devices get added to Home Assistant immediately Fix device not being available on mdns discovery Change config flow aborts to forms with error * Update tests for new config_flow Add missing api_exception test * Restrict manual and discovered IP addresses to IPv4 * Re-add confirmation step for zeroconf discovery Improve error messages Move exception mapping dict to module level * Enable remote control WebSocket listener * Update tests --- .coveragerc | 6 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/bang_olufsen/__init__.py | 85 +++ .../components/bang_olufsen/config_flow.py | 184 +++++ .../components/bang_olufsen/const.py | 207 ++++++ .../components/bang_olufsen/entity.py | 71 ++ .../components/bang_olufsen/manifest.json | 11 + .../components/bang_olufsen/media_player.py | 647 ++++++++++++++++++ .../components/bang_olufsen/strings.json | 28 + homeassistant/components/bang_olufsen/util.py | 21 + .../components/bang_olufsen/websocket.py | 182 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/bang_olufsen/__init__.py | 1 + tests/components/bang_olufsen/conftest.py | 70 ++ tests/components/bang_olufsen/const.py | 83 +++ .../bang_olufsen/test_config_flow.py | 163 +++++ 22 files changed, 1790 insertions(+) create mode 100644 homeassistant/components/bang_olufsen/__init__.py create mode 100644 homeassistant/components/bang_olufsen/config_flow.py create mode 100644 homeassistant/components/bang_olufsen/const.py create mode 100644 homeassistant/components/bang_olufsen/entity.py create mode 100644 homeassistant/components/bang_olufsen/manifest.json create mode 100644 homeassistant/components/bang_olufsen/media_player.py create mode 100644 homeassistant/components/bang_olufsen/strings.json create mode 100644 homeassistant/components/bang_olufsen/util.py create mode 100644 homeassistant/components/bang_olufsen/websocket.py create mode 100644 tests/components/bang_olufsen/__init__.py create mode 100644 tests/components/bang_olufsen/conftest.py create mode 100644 tests/components/bang_olufsen/const.py create mode 100644 tests/components/bang_olufsen/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 550eb050ee7..a988a50fd9f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -112,6 +112,12 @@ omit = homeassistant/components/baf/sensor.py homeassistant/components/baf/switch.py homeassistant/components/baidu/tts.py + homeassistant/components/bang_olufsen/__init__.py + homeassistant/components/bang_olufsen/const.py + homeassistant/components/bang_olufsen/entity.py + homeassistant/components/bang_olufsen/media_player.py + homeassistant/components/bang_olufsen/util.py + homeassistant/components/bang_olufsen/websocket.py homeassistant/components/bbox/device_tracker.py homeassistant/components/bbox/sensor.py homeassistant/components/beewi_smartclim/sensor.py diff --git a/.strict-typing b/.strict-typing index be0089a4333..d725a2920a4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -100,6 +100,7 @@ homeassistant.components.awair.* homeassistant.components.axis.* homeassistant.components.backup.* homeassistant.components.baf.* +homeassistant.components.bang_olufsen.* homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* diff --git a/CODEOWNERS b/CODEOWNERS index 4ab6493751a..dae1d0f1806 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -149,6 +149,8 @@ build.json @home-assistant/supervisor /tests/components/baf/ @bdraco @jfroy /homeassistant/components/balboa/ @garbled1 @natekspencer /tests/components/balboa/ @garbled1 @natekspencer +/homeassistant/components/bang_olufsen/ @mj23000 +/tests/components/bang_olufsen/ @mj23000 /homeassistant/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG /homeassistant/components/beewi_smartclim/ @alemuro diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py new file mode 100644 index 00000000000..3071b8fc6b2 --- /dev/null +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -0,0 +1,85 @@ +"""The Bang & Olufsen integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_MODEL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.device_registry as dr + +from .const import DOMAIN +from .websocket import BangOlufsenWebsocket + + +@dataclass +class BangOlufsenData: + """Dataclass for API client and WebSocket client.""" + + websocket: BangOlufsenWebsocket + client: MozartClient + + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + # Remove casts to str + assert entry.unique_id + + # Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + name=entry.title, + model=entry.data[CONF_MODEL], + ) + + client = MozartClient(host=entry.data[CONF_HOST], websocket_reconnect=True) + + # Check connection and try to initialize it. + try: + await client.get_battery_state(_request_timeout=3) + except (ApiException, ClientConnectorError, TimeoutError) as error: + await client.close_api_client() + raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error + + websocket = BangOlufsenWebsocket(hass, entry, client) + + # Add the websocket and API client + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BangOlufsenData( + websocket, + client, + ) + + # Check and start WebSocket connection + if not await client.connect_notifications(remote_control=True): + raise ConfigEntryNotReady( + f"Unable to connect to {entry.title} WebSocket notification channel" + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + # Close the API client and WebSocket notification listener + hass.data[DOMAIN][entry.entry_id].client.disconnect_notifications() + await hass.data[DOMAIN][entry.entry_id].client.close_api_client() + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py new file mode 100644 index 00000000000..6a26c4c5984 --- /dev/null +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -0,0 +1,184 @@ +"""Config flow for the Bang & Olufsen integration.""" +from __future__ import annotations + +from ipaddress import AddressValueError, IPv4Address +from typing import Any, TypedDict + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +from mozart_api.mozart_client import MozartClient +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import ( + ATTR_FRIENDLY_NAME, + ATTR_ITEM_NUMBER, + ATTR_SERIAL_NUMBER, + ATTR_TYPE_NUMBER, + COMPATIBLE_MODELS, + CONF_SERIAL_NUMBER, + DEFAULT_MODEL, + DOMAIN, +) + + +class EntryData(TypedDict, total=False): + """TypedDict for config_entry data.""" + + host: str + jid: str + model: str + name: str + + +# Map exception types to strings +_exception_map = { + ApiException: "api_exception", + ClientConnectorError: "client_connector_error", + TimeoutError: "timeout_error", + AddressValueError: "invalid_ip", +} + + +class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + _beolink_jid = "" + _client: MozartClient + _host = "" + _model = "" + _name = "" + _serial_number = "" + + def __init__(self) -> None: + """Init the config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_MODEL, default=DEFAULT_MODEL): SelectSelector( + SelectSelectorConfig(options=COMPATIBLE_MODELS) + ), + } + ) + + if user_input is not None: + self._host = user_input[CONF_HOST] + self._model = user_input[CONF_MODEL] + + # Check if the IP address is a valid IPv4 address. + try: + IPv4Address(self._host) + except AddressValueError as error: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": _exception_map[type(error)]}, + ) + + self._client = MozartClient(self._host) + + # Try to get information from Beolink self method. + async with self._client: + try: + beolink_self = await self._client.get_beolink_self( + _request_timeout=3 + ) + except ( + ApiException, + ClientConnectorError, + TimeoutError, + ) as error: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": _exception_map[type(error)]}, + ) + + self._beolink_jid = beolink_self.jid + self._serial_number = beolink_self.jid.split(".")[2].split("@")[0] + + await self.async_set_unique_id(self._serial_number) + self._abort_if_unique_id_configured() + + return await self._create_entry() + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: + """Handle discovery using Zeroconf.""" + + # Check if the discovered device is a Mozart device + if ATTR_FRIENDLY_NAME not in discovery_info.properties: + return self.async_abort(reason="not_mozart_device") + + # Ensure that an IPv4 address is received + self._host = discovery_info.host + try: + IPv4Address(self._host) + except AddressValueError: + return self.async_abort(reason="ipv6_address") + + self._model = discovery_info.hostname[:-16].replace("-", " ") + self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER] + self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com" + + await self.async_set_unique_id(self._serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + # Set the discovered device title + self.context["title_placeholders"] = { + "name": discovery_info.properties[ATTR_FRIENDLY_NAME] + } + + return await self.async_step_zeroconf_confirm() + + async def _create_entry(self) -> FlowResult: + """Create the config entry for a discovered or manually configured Bang & Olufsen device.""" + # Ensure that created entities have a unique and easily identifiable id and not a "friendly name" + self._name = f"{self._model}-{self._serial_number}" + + return self.async_create_entry( + title=self._name, + data=EntryData( + host=self._host, + jid=self._beolink_jid, + model=self._model, + name=self._name, + ), + ) + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the configuration of the device.""" + if user_input is not None: + return await self._create_entry() + + self._set_confirm_only() + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + CONF_HOST: self._host, + CONF_MODEL: self._model, + CONF_SERIAL_NUMBER: self._serial_number, + }, + last_step=True, + ) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py new file mode 100644 index 00000000000..3a6638fe31a --- /dev/null +++ b/homeassistant/components/bang_olufsen/const.py @@ -0,0 +1,207 @@ +"""Constants for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Final + +from mozart_api.models import Source, SourceArray, SourceTypeEnum + +from homeassistant.components.media_player import MediaPlayerState, MediaType + + +class SOURCE_ENUM(StrEnum): + """Enum used for associating device source ids with friendly names. May not include all sources.""" + + uriStreamer = "Audio Streamer" # noqa: N815 + bluetooth = "Bluetooth" + airPlay = "AirPlay" # noqa: N815 + chromeCast = "Chromecast built-in" # noqa: N815 + spotify = "Spotify Connect" + generator = "Tone Generator" + lineIn = "Line-In" # noqa: N815 + spdif = "Optical" + netRadio = "B&O Radio" # noqa: N815 + local = "Local" + dlna = "DLNA" + qplay = "QPlay" + wpl = "Wireless Powerlink" + pl = "Powerlink" + tv = "TV" + deezer = "Deezer" + beolink = "Networklink" + tidalConnect = "Tidal Connect" # noqa: N815 + + +BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { + # Dict used for translating device states to Home Assistant states. + "started": MediaPlayerState.PLAYING, + "buffering": MediaPlayerState.PLAYING, + "idle": MediaPlayerState.IDLE, + "paused": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.PAUSED, + "ended": MediaPlayerState.PAUSED, + "error": MediaPlayerState.IDLE, + # A device's initial state is "unknown" and should be treated as "idle" + "unknown": MediaPlayerState.IDLE, +} + + +# Media types for play_media +class BANG_OLUFSEN_MEDIA_TYPE(StrEnum): + """Bang & Olufsen specific media types.""" + + FAVOURITE = "favourite" + DEEZER = "deezer" + RADIO = "radio" + TTS = "provider" + + +class MODEL_ENUM(StrEnum): + """Enum for compatible model names.""" + + BEOLAB_8 = "BeoLab 8" + BEOLAB_28 = "BeoLab 28" + BEOSOUND_2 = "Beosound 2 3rd Gen" + BEOSOUND_A5 = "Beosound A5" + BEOSOUND_A9 = "Beosound A9 5th Gen" + BEOSOUND_BALANCE = "Beosound Balance" + BEOSOUND_EMERGE = "Beosound Emerge" + BEOSOUND_LEVEL = "Beosound Level" + BEOSOUND_THEATRE = "Beosound Theatre" + + +# Dispatcher events +class WEBSOCKET_NOTIFICATION(StrEnum): + """Enum for WebSocket notification types.""" + + PLAYBACK_ERROR: Final[str] = "playback_error" + PLAYBACK_METADATA: Final[str] = "playback_metadata" + PLAYBACK_PROGRESS: Final[str] = "playback_progress" + PLAYBACK_SOURCE: Final[str] = "playback_source" + PLAYBACK_STATE: Final[str] = "playback_state" + SOFTWARE_UPDATE_STATE: Final[str] = "software_update_state" + SOURCE_CHANGE: Final[str] = "source_change" + VOLUME: Final[str] = "volume" + + # Sub-notifications + NOTIFICATION: Final[str] = "notification" + REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged" + + ALL: Final[str] = "all" + + +DOMAIN: Final[str] = "bang_olufsen" + +# Default values for configuration. +DEFAULT_MODEL: Final[str] = MODEL_ENUM.BEOSOUND_BALANCE + +# Configuration. +CONF_SERIAL_NUMBER: Final = "serial_number" +CONF_BEOLINK_JID: Final = "jid" + +# Models to choose from in manual configuration. +COMPATIBLE_MODELS: list[str] = [x.value for x in MODEL_ENUM] + +# Attribute names for zeroconf discovery. +ATTR_TYPE_NUMBER: Final[str] = "tn" +ATTR_SERIAL_NUMBER: Final[str] = "sn" +ATTR_ITEM_NUMBER: Final[str] = "in" +ATTR_FRIENDLY_NAME: Final[str] = "fn" + +# Power states. +BANG_OLUFSEN_ON: Final[str] = "on" + +VALID_MEDIA_TYPES: Final[tuple] = ( + BANG_OLUFSEN_MEDIA_TYPE.FAVOURITE, + BANG_OLUFSEN_MEDIA_TYPE.DEEZER, + BANG_OLUFSEN_MEDIA_TYPE.RADIO, + BANG_OLUFSEN_MEDIA_TYPE.TTS, + MediaType.MUSIC, + MediaType.URL, + MediaType.CHANNEL, +) + +# Sources on the device that should not be selectable by the user +HIDDEN_SOURCE_IDS: Final[tuple] = ( + "airPlay", + "bluetooth", + "chromeCast", + "generator", + "local", + "dlna", + "qplay", + "wpl", + "pl", + "beolink", + "usbIn", +) + +# Fallback sources to use in case of API failure. +FALLBACK_SOURCES: Final[SourceArray] = SourceArray( + items=[ + Source( + id="uriStreamer", + is_enabled=True, + is_playable=False, + name="Audio Streamer", + type=SourceTypeEnum(value="uriStreamer"), + ), + Source( + id="bluetooth", + is_enabled=True, + is_playable=False, + name="Bluetooth", + type=SourceTypeEnum(value="bluetooth"), + ), + Source( + id="spotify", + is_enabled=True, + is_playable=False, + name="Spotify Connect", + type=SourceTypeEnum(value="spotify"), + ), + Source( + id="lineIn", + is_enabled=True, + is_playable=True, + name="Line-In", + type=SourceTypeEnum(value="lineIn"), + ), + Source( + id="spdif", + is_enabled=True, + is_playable=True, + name="Optical", + type=SourceTypeEnum(value="spdif"), + ), + Source( + id="netRadio", + is_enabled=True, + is_playable=True, + name="B&O Radio", + type=SourceTypeEnum(value="netRadio"), + ), + Source( + id="deezer", + is_enabled=True, + is_playable=True, + name="Deezer", + type=SourceTypeEnum(value="deezer"), + ), + Source( + id="tidalConnect", + is_enabled=True, + is_playable=True, + name="Tidal Connect", + type=SourceTypeEnum(value="tidalConnect"), + ), + ] +) + + +# Device events +BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event" + + +CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS" diff --git a/homeassistant/components/bang_olufsen/entity.py b/homeassistant/components/bang_olufsen/entity.py new file mode 100644 index 00000000000..76d93ca0635 --- /dev/null +++ b/homeassistant/components/bang_olufsen/entity.py @@ -0,0 +1,71 @@ +"""Entity representing a Bang & Olufsen device.""" +from __future__ import annotations + +from typing import cast + +from mozart_api.models import ( + PlaybackContentMetadata, + PlaybackProgress, + RenderingState, + Source, + VolumeLevel, + VolumeMute, + VolumeState, +) +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class BangOlufsenBase: + """Base class for BangOlufsen Home Assistant objects.""" + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the object.""" + + # Set the MozartClient + self._client = client + + # get the input from the config entry. + self.entry: ConfigEntry = entry + + # Set the configuration variables. + self._host: str = self.entry.data[CONF_HOST] + self._name: str = self.entry.title + self._unique_id: str = cast(str, self.entry.unique_id) + + # Objects that get directly updated by notifications. + self._playback_metadata: PlaybackContentMetadata = PlaybackContentMetadata() + self._playback_progress: PlaybackProgress = PlaybackProgress(total_duration=0) + self._playback_source: Source = Source() + self._playback_state: RenderingState = RenderingState() + self._source_change: Source = Source() + self._volume: VolumeState = VolumeState( + level=VolumeLevel(level=0), muted=VolumeMute(muted=False) + ) + + +class BangOlufsenEntity(Entity, BangOlufsenBase): + """Base Entity for BangOlufsen entities.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the object.""" + super().__init__(entry, client) + + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) + self._attr_device_class = None + self._attr_entity_category = None + self._attr_should_poll = False + + async def _update_connection_state(self, connection_state: bool) -> None: + """Update entity connection state.""" + self._attr_available = connection_state + + self.async_write_ha_state() diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json new file mode 100644 index 00000000000..3c920a99d7f --- /dev/null +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bang_olufsen", + "name": "Bang & Olufsen", + "codeowners": ["@mj23000"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["mozart-api==3.2.1.150.6"], + "zeroconf": ["_bangolufsen._tcp.local."] +} diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py new file mode 100644 index 00000000000..869cabc5a4a --- /dev/null +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -0,0 +1,647 @@ +"""Media player entity for the Bang & Olufsen integration.""" +from __future__ import annotations + +import json +import logging +from typing import Any, cast + +from mozart_api import __version__ as MOZART_API_VERSION +from mozart_api.exceptions import ApiException +from mozart_api.models import ( + Action, + Art, + OverlayPlayRequest, + PlaybackContentMetadata, + PlaybackError, + PlaybackProgress, + PlayQueueItem, + PlayQueueItemType, + RenderingState, + SceneProperties, + SoftwareUpdateState, + SoftwareUpdateStatus, + Source, + Uri, + UserFlow, + VolumeLevel, + VolumeMute, + VolumeState, +) +from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + ATTR_MEDIA_EXTRA, + BrowseMedia, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, + async_process_play_media_url, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import BangOlufsenData +from .const import ( + BANG_OLUFSEN_MEDIA_TYPE, + BANG_OLUFSEN_STATES, + CONF_BEOLINK_JID, + CONNECTION_STATUS, + DOMAIN, + FALLBACK_SOURCES, + HIDDEN_SOURCE_IDS, + SOURCE_ENUM, + VALID_MEDIA_TYPES, + WEBSOCKET_NOTIFICATION, +) +from .entity import BangOlufsenEntity + +_LOGGER = logging.getLogger(__name__) + +BANG_OLUFSEN_FEATURES = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.TURN_OFF +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Media Player entity from config entry.""" + data: BangOlufsenData = hass.data[DOMAIN][config_entry.entry_id] + + # Add MediaPlayer entity + async_add_entities(new_entities=[BangOlufsenMediaPlayer(config_entry, data.client)]) + + +class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): + """Representation of a media player.""" + + _attr_has_entity_name = False + _attr_icon = "mdi:speaker-wireless" + _attr_supported_features = BANG_OLUFSEN_FEATURES + + def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: + """Initialize the media player.""" + super().__init__(entry, client) + + self._beolink_jid: str = self.entry.data[CONF_BEOLINK_JID] + self._model: str = self.entry.data[CONF_MODEL] + + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{self._host}/#/", + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="Bang & Olufsen", + model=self._model, + name=cast(str, self.name), + serial_number=self._unique_id, + ) + self._attr_name = self._name + self._attr_unique_id = self._unique_id + self._attr_device_class = MediaPlayerDeviceClass.SPEAKER + + # Misc. variables. + self._audio_sources: dict[str, str] = {} + self._media_image: Art = Art() + self._software_status: SoftwareUpdateStatus = SoftwareUpdateStatus( + software_version="", + state=SoftwareUpdateState(seconds_remaining=0, value="idle"), + ) + self._sources: dict[str, str] = {} + self._state: str = MediaPlayerState.IDLE + self._video_sources: dict[str, str] = {} + + async def async_added_to_hass(self) -> None: + """Turn on the dispatchers.""" + await self._initialize() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._update_connection_state, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}", + self._update_playback_error, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}", + self._update_playback_metadata, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}", + self._update_playback_progress, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}", + self._update_playback_state, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}", + self._update_sources, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}", + self._update_source_change, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}", + self._update_volume, + ) + ) + + async def _initialize(self) -> None: + """Initialize connection dependent variables.""" + + # Get software version. + self._software_status = await self._client.get_softwareupdate_status() + + _LOGGER.debug( + "Connected to: %s %s running SW %s", + self._model, + self._unique_id, + self._software_status.software_version, + ) + + # Get overall device state once. This is handled by WebSocket events the rest of the time. + product_state = await self._client.get_product_state() + + # Get volume information. + if product_state.volume: + self._volume = product_state.volume + + # Get all playback information. + # Ensure that the metadata is not None upon startup + if product_state.playback: + if product_state.playback.metadata: + self._playback_metadata = product_state.playback.metadata + if product_state.playback.progress: + self._playback_progress = product_state.playback.progress + if product_state.playback.source: + self._source_change = product_state.playback.source + if product_state.playback.state: + self._playback_state = product_state.playback.state + # Set initial state + if self._playback_state.value: + self._state = self._playback_state.value + + self._attr_media_position_updated_at = utcnow() + + # Get the highest resolution available of the given images. + self._media_image = get_highest_resolution_artwork(self._playback_metadata) + + # If the device has been updated with new sources, then the API will fail here. + await self._update_sources() + + # Set the static entity attributes that needed more information. + self._attr_source_list = list(self._sources.values()) + + async def _update_sources(self) -> None: + """Get sources for the specific product.""" + + # Audio sources + try: + # Get all available sources. + sources = await self._client.get_available_sources(target_remote=False) + + # Use a fallback list of sources + except ValueError: + # Try to get software version from device + if self.device_info: + sw_version = self.device_info.get("sw_version") + if not sw_version: + sw_version = self._software_status.software_version + + _LOGGER.warning( + "The API is outdated compared to the device software version %s and %s. Using fallback sources", + MOZART_API_VERSION, + sw_version, + ) + sources = FALLBACK_SOURCES + + # Save all of the relevant enabled sources, both the ID and the friendly name for displaying in a dict. + self._audio_sources = { + source.id: source.name + for source in cast(list[Source], sources.items) + if source.is_enabled + and source.id + and source.name + and source.id not in HIDDEN_SOURCE_IDS + } + + # Video sources from remote menu + menu_items = await self._client.get_remote_menu() + + for key in menu_items: + menu_item = menu_items[key] + + if not menu_item.available: + continue + + # TV SOURCES + if ( + menu_item.content is not None + and menu_item.content.categories + and len(menu_item.content.categories) > 0 + and "music" not in menu_item.content.categories + and menu_item.label + and menu_item.label != "TV" + ): + self._video_sources[key] = menu_item.label + + # Combine the source dicts + self._sources = self._audio_sources | self._video_sources + + # HASS won't necessarily be running the first time this method is run + if self.hass.is_running: + self.async_write_ha_state() + + async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + """Update _playback_metadata and related.""" + self._playback_metadata = data + + # Update current artwork. + self._media_image = get_highest_resolution_artwork(self._playback_metadata) + + self.async_write_ha_state() + + async def _update_playback_error(self, data: PlaybackError) -> None: + """Show playback error.""" + _LOGGER.error(data.error) + + async def _update_playback_progress(self, data: PlaybackProgress) -> None: + """Update _playback_progress and last update.""" + self._playback_progress = data + self._attr_media_position_updated_at = utcnow() + + self.async_write_ha_state() + + async def _update_playback_state(self, data: RenderingState) -> None: + """Update _playback_state and related.""" + self._playback_state = data + + # Update entity state based on the playback state. + if self._playback_state.value: + self._state = self._playback_state.value + + self.async_write_ha_state() + + async def _update_source_change(self, data: Source) -> None: + """Update _source_change and related.""" + self._source_change = data + + # Check if source is line-in or optical and progress should be updated + if self._source_change.id in (SOURCE_ENUM.lineIn, SOURCE_ENUM.spdif): + self._playback_progress = PlaybackProgress(progress=0) + + async def _update_volume(self, data: VolumeState) -> None: + """Update _volume.""" + self._volume = data + + self.async_write_ha_state() + + @property + def state(self) -> MediaPlayerState: + """Return the current state of the media player.""" + return BANG_OLUFSEN_STATES[self._state] + + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if self._volume.level and self._volume.level.level: + return float(self._volume.level.level / 100) + return None + + @property + def is_volume_muted(self) -> bool | None: + """Boolean if volume is currently muted.""" + if self._volume.muted and self._volume.muted.muted: + return self._volume.muted.muted + return None + + @property + def media_content_type(self) -> str: + """Return the current media type.""" + # Hard to determine content type + if self.source == SOURCE_ENUM.uriStreamer: + return MediaType.URL + return MediaType.MUSIC + + @property + def media_duration(self) -> int | None: + """Return the total duration of the current track in seconds.""" + return self._playback_metadata.total_duration_seconds + + @property + def media_position(self) -> int | None: + """Return the current playback progress.""" + return self._playback_progress.progress + + @property + def media_image_url(self) -> str | None: + """Return URL of the currently playing music.""" + if self._media_image: + return self._media_image.url + return None + + @property + def media_image_remotely_accessible(self) -> bool: + """Return whether or not the image of the current media is available outside the local network.""" + return not self._media_image.has_local_image + + @property + def media_title(self) -> str | None: + """Return the currently playing title.""" + return self._playback_metadata.title + + @property + def media_album_name(self) -> str | None: + """Return the currently playing album name.""" + return self._playback_metadata.album_name + + @property + def media_album_artist(self) -> str | None: + """Return the currently playing artist name.""" + return self._playback_metadata.artist_name + + @property + def media_track(self) -> int | None: + """Return the currently playing track.""" + return self._playback_metadata.track + + @property + def media_channel(self) -> str | None: + """Return the currently playing channel.""" + return self._playback_metadata.organization + + @property + def source(self) -> str | None: + """Return the current audio source.""" + + # Try to fix some of the source_change chromecast weirdness. + if hasattr(self._playback_metadata, "title"): + # source_change is chromecast but line in is selected. + if self._playback_metadata.title == SOURCE_ENUM.lineIn: + return SOURCE_ENUM.lineIn + + # source_change is chromecast but bluetooth is selected. + if self._playback_metadata.title == SOURCE_ENUM.bluetooth: + return SOURCE_ENUM.bluetooth + + # source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket, + # And the source has not changed. + if self._source_change.id in ( + SOURCE_ENUM.bluetooth, + SOURCE_ENUM.lineIn, + SOURCE_ENUM.spdif, + ): + return SOURCE_ENUM.chromeCast + + # source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork + # So i assume that it is bluetooth and not chromecast + if ( + hasattr(self._playback_metadata, "art") + and self._playback_metadata.art is not None + ): + if ( + len(self._playback_metadata.art) == 0 + and self._source_change.name == SOURCE_ENUM.bluetooth + ): + return SOURCE_ENUM.bluetooth + + return self._source_change.name + + async def async_turn_off(self) -> None: + """Set the device to "networkStandby".""" + await self._client.post_standby() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self._client.set_current_volume_level( + volume_level=VolumeLevel(level=int(volume * 100)) + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute media player.""" + await self._client.set_volume_mute(volume_mute=VolumeMute(muted=mute)) + + async def async_media_play_pause(self) -> None: + """Toggle play/pause media player.""" + if self.state == MediaPlayerState.PLAYING: + await self.async_media_pause() + elif self.state in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE): + await self.async_media_play() + + async def async_media_pause(self) -> None: + """Pause media player.""" + await self._client.post_playback_command(command="pause") + + async def async_media_play(self) -> None: + """Play media player.""" + await self._client.post_playback_command(command="play") + + async def async_media_stop(self) -> None: + """Pause media player.""" + await self._client.post_playback_command(command="stop") + + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self._client.post_playback_command(command="skip") + + async def async_media_seek(self, position: float) -> None: + """Seek to position in ms.""" + if self.source == SOURCE_ENUM.deezer: + await self._client.seek_to_position(position_ms=int(position * 1000)) + # Try to prevent the playback progress from bouncing in the UI. + self._attr_media_position_updated_at = utcnow() + self._playback_progress = PlaybackProgress(progress=int(position)) + + self.async_write_ha_state() + else: + _LOGGER.error("Seeking is currently only supported when using Deezer") + + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self._client.post_playback_command(command="prev") + + async def async_clear_playlist(self) -> None: + """Clear the current playback queue.""" + await self._client.post_clear_queue() + + async def async_select_source(self, source: str) -> None: + """Select an input source.""" + if source not in self._sources.values(): + _LOGGER.error( + "Invalid source: %s. Valid sources are: %s", + source, + list(self._sources.values()), + ) + return + + # pylint: disable=consider-using-dict-items + key = [x for x in self._sources if self._sources[x] == source][0] + + # Check for source type + if source in self._audio_sources.values(): + # Audio + await self._client.set_active_source(source_id=key) + else: + # Video + await self._client.post_remote_trigger(id=key) + + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play from: netradio station id, URI, favourite or Deezer.""" + + # Convert audio/mpeg, audio/aac etc. to MediaType.MUSIC + if media_type.startswith("audio/"): + media_type = MediaType.MUSIC + + if media_type not in VALID_MEDIA_TYPES: + _LOGGER.error( + "%s is an invalid type. Valid values are: %s", + media_type, + VALID_MEDIA_TYPES, + ) + return + + if media_source.is_media_source_id(media_id): + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + + media_id = async_process_play_media_url(self.hass, sourced_media.url) + + # Remove playlist extension as it is unsupported. + if media_id.endswith(".m3u"): + media_id = media_id.replace(".m3u", "") + + if media_type in (MediaType.URL, MediaType.MUSIC): + await self._client.post_uri_source(uri=Uri(location=media_id)) + + # The "provider" media_type may not be suitable for overlay all the time. + # Use it for now. + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.TTS: + await self._client.post_overlay_play( + overlay_play_request=OverlayPlayRequest( + uri=Uri(location=media_id), + ) + ) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.RADIO: + await self._client.run_provided_scene( + scene_properties=SceneProperties( + action_list=[ + Action( + type="radio", + radio_station_id=media_id, + ) + ] + ) + ) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.FAVOURITE: + await self._client.activate_preset(id=int(media_id)) + + elif media_type == BANG_OLUFSEN_MEDIA_TYPE.DEEZER: + try: + if media_id == "flow": + deezer_id = None + + if "id" in kwargs[ATTR_MEDIA_EXTRA]: + deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"] + + # Play Deezer flow. + await self._client.start_deezer_flow( + user_flow=UserFlow(user_id=deezer_id) + ) + + # Play a Deezer playlist or album. + elif any(match in media_id for match in ("playlist", "album")): + start_from = 0 + if "start_from" in kwargs[ATTR_MEDIA_EXTRA]: + start_from = kwargs[ATTR_MEDIA_EXTRA]["start_from"] + + await self._client.add_to_queue( + play_queue_item=PlayQueueItem( + provider=PlayQueueItemType(value="deezer"), + start_now_from_position=start_from, + type="playlist", + uri=media_id, + ) + ) + + # Play a Deezer track. + else: + await self._client.add_to_queue( + play_queue_item=PlayQueueItem( + provider=PlayQueueItemType(value="deezer"), + start_now_from_position=0, + type="track", + uri=media_id, + ) + ) + + except ApiException as error: + _LOGGER.error(json.loads(error.body)["message"]) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the WebSocket media browsing helper.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json new file mode 100644 index 00000000000..3cebfb891bc --- /dev/null +++ b/homeassistant/components/bang_olufsen/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "error": { + "api_exception": "[%key:common::config_flow::error::cannot_connect%]", + "client_connector_error": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_error": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ip": "Invalid IPv4 address" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "model": "[%key:common::generic::model%]" + }, + "description": "Manually configure your Bang & Olufsen device." + }, + "zeroconf_confirm": { + "title": "Setup Bang & Olufsen device", + "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." + } + } + } +} diff --git a/homeassistant/components/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py new file mode 100644 index 00000000000..617eb4b1df6 --- /dev/null +++ b/homeassistant/components/bang_olufsen/util.py @@ -0,0 +1,21 @@ +"""Various utilities for the Bang & Olufsen integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN + + +def get_device(hass: HomeAssistant | None, unique_id: str) -> DeviceEntry | None: + """Get the device.""" + if not isinstance(hass, HomeAssistant): + return None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, unique_id)}) + assert device + + return device diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py new file mode 100644 index 00000000000..fd378a40bd3 --- /dev/null +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -0,0 +1,182 @@ +"""Update coordinator and WebSocket listener(s) for the Bang & Olufsen integration.""" + +from __future__ import annotations + +import logging + +from mozart_api.models import ( + PlaybackContentMetadata, + PlaybackError, + PlaybackProgress, + RenderingState, + SoftwareUpdateState, + Source, + VolumeState, + WebsocketNotificationTag, +) +from mozart_api.mozart_client import MozartClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + BANG_OLUFSEN_WEBSOCKET_EVENT, + CONNECTION_STATUS, + WEBSOCKET_NOTIFICATION, +) +from .entity import BangOlufsenBase +from .util import get_device + +_LOGGER = logging.getLogger(__name__) + + +class BangOlufsenWebsocket(BangOlufsenBase): + """The WebSocket listeners.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, client: MozartClient + ) -> None: + """Initialize the WebSocket listeners.""" + + BangOlufsenBase.__init__(self, entry, client) + + self.hass = hass + self._device = get_device(hass, self._unique_id) + + # WebSocket callbacks + self._client.get_notification_notifications(self.on_notification_notification) + self._client.get_on_connection_lost(self.on_connection_lost) + self._client.get_on_connection(self.on_connection) + self._client.get_playback_error_notifications( + self.on_playback_error_notification + ) + self._client.get_playback_metadata_notifications( + self.on_playback_metadata_notification + ) + self._client.get_playback_progress_notifications( + self.on_playback_progress_notification + ) + self._client.get_playback_state_notifications( + self.on_playback_state_notification + ) + self._client.get_software_update_state_notifications( + self.on_software_update_state + ) + self._client.get_source_change_notifications(self.on_source_change_notification) + self._client.get_volume_notifications(self.on_volume_notification) + + # Used for firing events and debugging + self._client.get_all_notifications_raw(self.on_all_notifications_raw) + + def _update_connection_status(self) -> None: + """Update all entities of the connection status.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{CONNECTION_STATUS}", + self._client.websocket_connected, + ) + + def on_connection(self) -> None: + """Handle WebSocket connection made.""" + _LOGGER.debug("Connected to the %s notification channel", self._name) + self._update_connection_status() + + def on_connection_lost(self) -> None: + """Handle WebSocket connection lost.""" + _LOGGER.error("Lost connection to the %s", self._name) + self._update_connection_status() + + def on_notification_notification( + self, notification: WebsocketNotificationTag + ) -> None: + """Send notification dispatch.""" + if notification.value: + if WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED in notification.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}", + ) + + def on_playback_error_notification(self, notification: PlaybackError) -> None: + """Send playback_error dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_ERROR}", + notification, + ) + + def on_playback_metadata_notification( + self, notification: PlaybackContentMetadata + ) -> None: + """Send playback_metadata dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_METADATA}", + notification, + ) + + def on_playback_progress_notification(self, notification: PlaybackProgress) -> None: + """Send playback_progress dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_PROGRESS}", + notification, + ) + + def on_playback_state_notification(self, notification: RenderingState) -> None: + """Send playback_state dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.PLAYBACK_STATE}", + notification, + ) + + def on_source_change_notification(self, notification: Source) -> None: + """Send source_change dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.SOURCE_CHANGE}", + notification, + ) + + def on_volume_notification(self, notification: VolumeState) -> None: + """Send volume dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.VOLUME}", + notification, + ) + + async def on_software_update_state(self, notification: SoftwareUpdateState) -> None: + """Check device sw version.""" + software_status = await self._client.get_softwareupdate_status() + + # Update the HA device if the sw version does not match + if not self._device: + self._device = get_device(self.hass, self._unique_id) + + assert self._device + + if software_status.software_version != self._device.sw_version: + device_registry = dr.async_get(self.hass) + + device_registry.async_update_device( + device_id=self._device.id, + sw_version=software_status.software_version, + ) + + def on_all_notifications_raw(self, notification: dict) -> None: + """Receive all notifications.""" + if not self._device: + self._device = get_device(self.hass, self._unique_id) + + assert self._device + + # Add the device_id and serial_number to the notification + notification["device_id"] = self._device.id + notification["serial_number"] = int(self._unique_id) + + _LOGGER.debug("%s", notification) + self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 278ae748c8d..a32c30293b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -67,6 +67,7 @@ FLOWS = { "azure_event_hub", "baf", "balboa", + "bang_olufsen", "blebox", "blink", "blue_current", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7c15e58e37f..b935fa25fbc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -581,6 +581,12 @@ "config_flow": true, "iot_class": "local_push" }, + "bang_olufsen": { + "name": "Bang & Olufsen", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "bayesian": { "name": "Bayesian", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 58728cd19d3..36b6aac8a7f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -344,6 +344,11 @@ ZEROCONF = { }, }, ], + "_bangolufsen._tcp.local.": [ + { + "domain": "bang_olufsen", + }, + ], "_bbxsrv._tcp.local.": [ { "domain": "blebox", diff --git a/mypy.ini b/mypy.ini index 6136a7b0d6f..7fb00178d1a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -760,6 +760,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bang_olufsen.*] +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.bayesian.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2b33f8e75b9..16adef37364 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1306,6 +1306,9 @@ motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 +# homeassistant.components.bang_olufsen +mozart-api==3.2.1.150.6 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a609f50961..2deb21d5900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1036,6 +1036,9 @@ motionblinds==0.6.19 # homeassistant.components.motioneye motioneye-client==0.3.14 +# homeassistant.components.bang_olufsen +mozart-api==3.2.1.150.6 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/bang_olufsen/__init__.py b/tests/components/bang_olufsen/__init__.py new file mode 100644 index 00000000000..150fc7c846d --- /dev/null +++ b/tests/components/bang_olufsen/__init__.py @@ -0,0 +1 @@ +"""Tests for the bang_olufsen integration.""" diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py new file mode 100644 index 00000000000..8c212ef16be --- /dev/null +++ b/tests/components/bang_olufsen/conftest.py @@ -0,0 +1,70 @@ +"""Test fixtures for bang_olufsen.""" + +from unittest.mock import AsyncMock, patch + +from mozart_api.models import BeolinkPeer +import pytest + +from homeassistant.components.bang_olufsen.const import DOMAIN + +from .const import ( + TEST_DATA_CREATE_ENTRY, + TEST_FRIENDLY_NAME, + TEST_JID_1, + TEST_NAME, + TEST_SERIAL_NUMBER, +) + +from tests.common import MockConfigEntry + + +class MockMozartClient: + """Class for mocking MozartClient objects and methods.""" + + async def __aenter__(self): + """Mock async context entry.""" + + async def __aexit__(self, exc_type, exc, tb): + """Mock async context exit.""" + + # API call results + get_beolink_self_result = BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME, jid=TEST_JID_1 + ) + + # API endpoints + get_beolink_self = AsyncMock() + get_beolink_self.return_value = get_beolink_self_result + + +@pytest.fixture +def mock_config_entry(): + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SERIAL_NUMBER, + data=TEST_DATA_CREATE_ENTRY, + title=TEST_NAME, + ) + + +@pytest.fixture +def mock_client(): + """Mock MozartClient.""" + + client = MockMozartClient() + + with patch("mozart_api.mozart_client.MozartClient", return_value=client): + yield client + + # Reset mocked API call counts and side effects + client.get_beolink_self.reset_mock(side_effect=True) + + +@pytest.fixture +def mock_setup_entry(): + """Mock successful setup entry.""" + with patch( + "homeassistant.components.bang_olufsen.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py new file mode 100644 index 00000000000..1b13e1b3412 --- /dev/null +++ b/tests/components/bang_olufsen/const.py @@ -0,0 +1,83 @@ +"""Constants used for testing the bang_olufsen integration.""" + + +from ipaddress import IPv4Address, IPv6Address + +from homeassistant.components.bang_olufsen.const import ( + ATTR_FRIENDLY_NAME, + ATTR_ITEM_NUMBER, + ATTR_SERIAL_NUMBER, + ATTR_TYPE_NUMBER, + CONF_BEOLINK_JID, +) +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME + +TEST_HOST = "192.168.0.1" +TEST_HOST_INVALID = "192.168.0" +TEST_HOST_IPV6 = "1111:2222:3333:4444:5555:6666:7777:8888" +TEST_MODEL_BALANCE = "Beosound Balance" +TEST_MODEL_THEATRE = "Beosound Theatre" +TEST_MODEL_LEVEL = "Beosound Level" +TEST_SERIAL_NUMBER = "11111111" +TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" +TEST_FRIENDLY_NAME = "Living room Balance" +TEST_TYPE_NUMBER = "1111" +TEST_ITEM_NUMBER = "1111111" +TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" + + +TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." +TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." +TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF + +TEST_DATA_USER = {CONF_HOST: TEST_HOST, CONF_MODEL: TEST_MODEL_BALANCE} +TEST_DATA_USER_INVALID = {CONF_HOST: TEST_HOST_INVALID, CONF_MODEL: TEST_MODEL_BALANCE} + + +TEST_DATA_CREATE_ENTRY = { + CONF_HOST: TEST_HOST, + CONF_MODEL: TEST_MODEL_BALANCE, + CONF_BEOLINK_JID: TEST_JID_1, + CONF_NAME: TEST_NAME, +} + +TEST_DATA_ZEROCONF = ZeroconfServiceInfo( + ip_address=IPv4Address(TEST_HOST), + ip_addresses=[IPv4Address(TEST_HOST)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ + ATTR_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER, + ATTR_TYPE_NUMBER: TEST_TYPE_NUMBER, + ATTR_ITEM_NUMBER: TEST_ITEM_NUMBER, + }, +) + +TEST_DATA_ZEROCONF_NOT_MOZART = ZeroconfServiceInfo( + ip_address=IPv4Address(TEST_HOST), + ip_addresses=[IPv4Address(TEST_HOST)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER}, +) + +TEST_DATA_ZEROCONF_IPV6 = ZeroconfServiceInfo( + ip_address=IPv6Address(TEST_HOST_IPV6), + ip_addresses=[IPv6Address(TEST_HOST_IPV6)], + port=80, + hostname=TEST_HOSTNAME_ZEROCONF, + type=TEST_TYPE_ZEROCONF, + name=TEST_NAME_ZEROCONF, + properties={ + ATTR_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ATTR_SERIAL_NUMBER: TEST_SERIAL_NUMBER, + ATTR_TYPE_NUMBER: TEST_TYPE_NUMBER, + ATTR_ITEM_NUMBER: TEST_ITEM_NUMBER, + }, +) diff --git a/tests/components/bang_olufsen/test_config_flow.py b/tests/components/bang_olufsen/test_config_flow.py new file mode 100644 index 00000000000..dd42c4c5c8c --- /dev/null +++ b/tests/components/bang_olufsen/test_config_flow.py @@ -0,0 +1,163 @@ +"""Test the bang_olufsen config_flow.""" + + +from unittest.mock import Mock + +from aiohttp.client_exceptions import ClientConnectorError +from mozart_api.exceptions import ApiException +import pytest + +from homeassistant.components.bang_olufsen.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MockMozartClient +from .const import ( + TEST_DATA_CREATE_ENTRY, + TEST_DATA_USER, + TEST_DATA_USER_INVALID, + TEST_DATA_ZEROCONF, + TEST_DATA_ZEROCONF_IPV6, + TEST_DATA_ZEROCONF_NOT_MOZART, +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_config_flow_timeout_error( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle timeout_error.""" + mock_client.get_beolink_self.side_effect = TimeoutError() + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "timeout_error"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_client_connector_error( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle client_connector_error.""" + mock_client.get_beolink_self.side_effect = ClientConnectorError(Mock(), Mock()) + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "client_connector_error"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_invalid_ip(hass: HomeAssistant) -> None: + """Test we handle invalid_ip.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER_INVALID, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "invalid_ip"} + + +async def test_config_flow_api_exception( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test we handle api_exception.""" + mock_client.get_beolink_self.side_effect = ApiException() + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=TEST_DATA_USER, + ) + assert result_user["type"] == FlowResultType.FORM + assert result_user["errors"] == {"base": "api_exception"} + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow(hass: HomeAssistant, mock_client: MockMozartClient) -> None: + """Test config flow.""" + + result_init = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=None, + ) + + assert result_init["type"] == FlowResultType.FORM + assert result_init["step_id"] == "user" + + result_user = await hass.config_entries.flow.async_configure( + flow_id=result_init["flow_id"], + user_input=TEST_DATA_USER, + ) + + assert result_user["type"] == FlowResultType.CREATE_ENTRY + assert result_user["data"] == TEST_DATA_CREATE_ENTRY + + assert mock_client.get_beolink_self.call_count == 1 + + +async def test_config_flow_zeroconf( + hass: HomeAssistant, mock_client: MockMozartClient +) -> None: + """Test zeroconf discovery.""" + + result_zeroconf = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF, + ) + + assert result_zeroconf["type"] == FlowResultType.FORM + assert result_zeroconf["step_id"] == "zeroconf_confirm" + + result_confirm = await hass.config_entries.flow.async_configure( + flow_id=result_zeroconf["flow_id"], + user_input=TEST_DATA_USER, + ) + + assert result_confirm["type"] == FlowResultType.CREATE_ENTRY + assert result_confirm["data"] == TEST_DATA_CREATE_ENTRY + + assert mock_client.get_beolink_self.call_count == 0 + + +async def test_config_flow_zeroconf_not_mozart_device(hass: HomeAssistant) -> None: + """Test zeroconf discovery of invalid device.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF_NOT_MOZART, + ) + + assert result_user["type"] == FlowResultType.ABORT + assert result_user["reason"] == "not_mozart_device" + + +async def test_config_flow_zeroconf_ipv6(hass: HomeAssistant) -> None: + """Test zeroconf discovery with IPv6 IP address.""" + + result_user = await hass.config_entries.flow.async_init( + handler=DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DATA_ZEROCONF_IPV6, + ) + + assert result_user["type"] == FlowResultType.ABORT + assert result_user["reason"] == "ipv6_address" From a67113a95a18f788c8cdb993035a8e2762a8de3b Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 24 Jan 2024 12:12:28 +0100 Subject: [PATCH 0980/1544] Parse template result in async_render_with_possible_json_value (#99670) * Optionally parse templates rendered with possible json * Remove duplicate strip * Add tests for parsing template result --- homeassistant/helpers/template.py | 10 +++++++++- tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 79ef6137f52..7c1113bdda8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -727,6 +727,7 @@ class Template: value: Any, error_value: Any = _SENTINEL, variables: dict[str, Any] | None = None, + parse_result: bool = False, ) -> Any: """Render template with value exposed. @@ -748,7 +749,9 @@ class Template: variables["value_json"] = json_loads(value) try: - return _render_with_context(self.template, compiled, **variables).strip() + render_result = _render_with_context( + self.template, compiled, **variables + ).strip() except jinja2.TemplateError as ex: if error_value is _SENTINEL: _LOGGER.error( @@ -759,6 +762,11 @@ class Template: ) return value if error_value is _SENTINEL else error_value + if not parse_result or self.hass and self.hass.config.legacy_templates: + return render_result + + return self._parse_result(render_result) + def _ensure_compiled( self, limited: bool = False, diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index bf48199d419..90af925ddca 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1728,6 +1728,26 @@ def test_render_with_possible_json_value_non_string_value(hass: HomeAssistant) - assert tpl.async_render_with_possible_json_value(value) == expected +def test_render_with_possible_json_value_and_parse_result(hass: HomeAssistant) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=True + ) + assert isinstance(result, dict) + + +def test_render_with_possible_json_value_and_dont_parse_result( + hass: HomeAssistant, +) -> None: + """Render with possible JSON value with valid JSON.""" + tpl = template.Template("{{ value_json.hello }}", hass) + result = tpl.async_render_with_possible_json_value( + """{"hello": {"world": "value1"}}""", parse_result=False + ) + assert isinstance(result, str) + + def test_if_state_exists(hass: HomeAssistant) -> None: """Test if state exists works.""" hass.states.async_set("test.object", "available") From f828b1ce8594e15b0f64e5824324d08cfbc9dae4 Mon Sep 17 00:00:00 2001 From: Jan Klausa Date: Wed, 24 Jan 2024 20:36:41 +0900 Subject: [PATCH 0981/1544] Bump py-switchbot-api to 2.0.0 (#108721) * Update switchbot-api to 2.0.0 * bump requirements --- homeassistant/components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 1539c81331e..cb651e5c84f 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.3.0"] + "requirements": ["switchbot-api==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16adef37364..1b25b66c878 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2621,7 +2621,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.3.0 +switchbot-api==2.0.0 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2deb21d5900..94f698975d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1998,7 +1998,7 @@ sunweg==2.1.0 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.3.0 +switchbot-api==2.0.0 # homeassistant.components.system_bridge systembridgeconnector==3.10.0 From 8fa93f6fe5858fc574ad7a17bcb82cbb73e78bbc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 13:49:26 +0100 Subject: [PATCH 0982/1544] Bump comments in light indicating backwards compatibility plan (#108770) --- homeassistant/components/light/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 6307b41f557..234e2547676 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -607,7 +607,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) # If white is set to True, set it to the light's brightness - # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an + # Add a warning in Home Assistant Core 2024.3 if the brightness is set to an # integer. if params.get(ATTR_WHITE) is True: params[ATTR_WHITE] = light.brightness @@ -896,7 +896,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the color mode of the light with backwards compatibility.""" if (color_mode := self.color_mode) is None: # Backwards compatibility for color_mode added in 2021.4 - # Add warning in 2021.6, remove in 2021.10 + # Add warning in 2024.3, remove in 2025.3 supported = self._light_internal_supported_color_modes if ColorMode.HS in supported and self.hs_color is not None: @@ -1075,7 +1075,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): color_mode = self._light_internal_color_mode if _is_on else None if color_mode and color_mode not in legacy_supported_color_modes: - # Increase severity to warning in 2021.6, reject in 2021.10 + # Increase severity to warning in 2024.3, reject in 2025.3 _LOGGER.debug( "%s: set to unsupported color_mode: %s, supported_color_modes: %s", self.entity_id, @@ -1092,7 +1092,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = None elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states - # Add warning in 2021.6, remove in 2021.10 + # Add warning in 2024.3, remove in 2025.3 if _is_on: data[ATTR_BRIGHTNESS] = self.brightness else: @@ -1113,7 +1113,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_COLOR_TEMP] = None elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility - # Add warning in 2021.6, remove in 2021.10 + # Add warning in 2024.3, remove in 2025.3 if _is_on: color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin @@ -1152,7 +1152,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return _supported_color_modes # Backwards compatibility for supported_color_modes added in 2021.4 - # Add warning in 2021.6, remove in 2021.10 + # Add warning in 2024.3, remove in 2025.3 supported_features = self.supported_features_compat supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() From af1ba4b22fa48ee385dacfa22f8c69807a244d0f Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Wed, 24 Jan 2024 12:56:49 +0000 Subject: [PATCH 0983/1544] Add ZHA metering summation received sensor (#107576) * Add sensor for exposing Summation Received from Metering cluster * Ruff format * Test updates for new sensor * Update test_sensor.py to support summation_received * Correct report_count for smart meterning and some pylint warning fixes --- .../zha/core/cluster_handlers/smartenergy.py | 1 + homeassistant/components/zha/sensor.py | 13 +++ homeassistant/components/zha/strings.json | 3 + tests/components/zha/test_cluster_handlers.py | 1 + tests/components/zha/test_sensor.py | 93 +++++++++++++------ tests/components/zha/zha_devices_list.py | 52 +++++++++++ 6 files changed, 134 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 2ceaeaf1013..32e7899d413 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -92,6 +92,7 @@ class Metering(ClusterHandler): AttrReportConfig( attr="current_tier6_summ_delivered", config=REPORT_CONFIG_DEFAULT ), + AttrReportConfig(attr="current_summ_received", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="status", config=REPORT_CONFIG_ASAP), ) ZCL_INIT_ATTRS = { diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b4531dc3f68..bb62494396a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -802,6 +802,19 @@ class Tier6SmartEnergySummation(PolledSmartEnergySummation): _attr_translation_key: str = "tier6_summation_delivered" +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SmartEnergySummationReceived(PolledSmartEnergySummation): + """Smart Energy Metering summation received sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_summ_received" + _unique_id_suffix = "summation_received" + _attr_translation_key: str = "summation_received" + + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) # pylint: disable-next=hass-invalid-inheritance # needs fixing class Pressure(Sensor): diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index a47e83fcf4b..e2875550398 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -837,6 +837,9 @@ "tier6_summation_delivered": { "name": "Tier 6 summation delivered" }, + "summation_received": { + "name": "Summation received" + }, "device_temperature": { "name": "Device temperature" }, diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 46efe306b91..7d5b46406cc 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -235,6 +235,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): "current_tier4_summ_delivered", "current_tier5_summ_delivered", "current_tier6_summ_delivered", + "current_summ_received", "status", }, ), diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 7d67e41512a..c5940a7b689 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -5,10 +5,7 @@ from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.homeautomation as homeautomation -import zigpy.zcl.clusters.measurement as measurement -import zigpy.zcl.clusters.smartenergy as smartenergy +from zigpy.zcl.clusters import general, homeautomation, measurement, smartenergy from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zha.core.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ @@ -70,7 +67,7 @@ def sensor_platform_only(): @pytest.fixture -async def elec_measurement_zigpy_dev(hass, zigpy_device_mock): +async def elec_measurement_zigpy_dev(hass: HomeAssistant, zigpy_device_mock): """Electric Measurement zigpy device.""" zigpy_device = zigpy_device_mock( @@ -110,19 +107,19 @@ async def elec_measurement_zha_dev(elec_measurement_zigpy_dev, zha_device_joined return zha_dev -async def async_test_humidity(hass, cluster, entity_id): +async def async_test_humidity(hass: HomeAssistant, cluster, entity_id): """Test humidity sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 100}) assert_state(hass, entity_id, "10.0", PERCENTAGE) -async def async_test_temperature(hass, cluster, entity_id): +async def async_test_temperature(hass: HomeAssistant, cluster, entity_id): """Test temperature sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 2900, 2: 100}) assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS) -async def async_test_pressure(hass, cluster, entity_id): +async def async_test_pressure(hass: HomeAssistant, cluster, entity_id): """Test pressure sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000}) assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) @@ -131,7 +128,7 @@ async def async_test_pressure(hass, cluster, entity_id): assert_state(hass, entity_id, "1000", UnitOfPressure.HPA) -async def async_test_illuminance(hass, cluster, entity_id): +async def async_test_illuminance(hass: HomeAssistant, cluster, entity_id): """Test illuminance sensor.""" await send_attributes_report(hass, cluster, {1: 1, 0: 10, 2: 20}) assert_state(hass, entity_id, "1", LIGHT_LUX) @@ -143,7 +140,7 @@ async def async_test_illuminance(hass, cluster, entity_id): assert_state(hass, entity_id, "unknown", LIGHT_LUX) -async def async_test_metering(hass, cluster, entity_id): +async def async_test_metering(hass: HomeAssistant, cluster, entity_id): """Test Smart Energy metering sensor.""" await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) assert_state(hass, entity_id, "12345.0", None) @@ -164,8 +161,10 @@ async def async_test_metering(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["status"] in ("", "32") -async def async_test_smart_energy_summation(hass, cluster, entity_id): - """Test SmartEnergy Summation delivered sensro.""" +async def async_test_smart_energy_summation_delivered( + hass: HomeAssistant, cluster, entity_id +): + """Test SmartEnergy Summation delivered sensor.""" await send_attributes_report( hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} @@ -179,7 +178,24 @@ async def async_test_smart_energy_summation(hass, cluster, entity_id): ) -async def async_test_electrical_measurement(hass, cluster, entity_id): +async def async_test_smart_energy_summation_received( + hass: HomeAssistant, cluster, entity_id +): + """Test SmartEnergy Summation received sensor.""" + + await send_attributes_report( + hass, cluster, {1025: 1, "current_summ_received": 12321, 1026: 100} + ) + assert_state(hass, entity_id, "12.321", UnitOfEnergy.KILO_WATT_HOUR) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + assert ( + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] + == SensorDeviceClass.ENERGY + ) + + +async def async_test_electrical_measurement(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement sensor.""" # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) @@ -201,7 +217,7 @@ async def async_test_electrical_measurement(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["active_power_max"] == "8.8" -async def async_test_em_apparent_power(hass, cluster, entity_id): +async def async_test_em_apparent_power(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement Apparent Power sensor.""" # update divisor cached value await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) @@ -219,7 +235,7 @@ async def async_test_em_apparent_power(hass, cluster, entity_id): assert_state(hass, entity_id, "9.9", UnitOfApparentPower.VOLT_AMPERE) -async def async_test_em_rms_current(hass, cluster, entity_id): +async def async_test_em_rms_current(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement RMS Current sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0508: 1234, 10: 1000}) @@ -237,7 +253,7 @@ async def async_test_em_rms_current(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["rms_current_max"] == "8.8" -async def async_test_em_rms_voltage(hass, cluster, entity_id): +async def async_test_em_rms_voltage(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement RMS Voltage sensor.""" await send_attributes_report(hass, cluster, {0: 1, 0x0505: 1234, 10: 1000}) @@ -255,7 +271,7 @@ async def async_test_em_rms_voltage(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["rms_voltage_max"] == "8.9" -async def async_test_powerconfiguration(hass, cluster, entity_id): +async def async_test_powerconfiguration(hass: HomeAssistant, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: 98}) assert_state(hass, entity_id, "49", "%") @@ -266,7 +282,7 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 -async def async_test_powerconfiguration2(hass, cluster, entity_id): +async def async_test_powerconfiguration2(hass: HomeAssistant, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: -1}) assert_state(hass, entity_id, STATE_UNKNOWN, "%") @@ -278,7 +294,7 @@ async def async_test_powerconfiguration2(hass, cluster, entity_id): assert_state(hass, entity_id, "49", "%") -async def async_test_device_temperature(hass, cluster, entity_id): +async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id): """Test temperature sensor.""" await send_attributes_report(hass, cluster, {0: 2900}) assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS) @@ -330,7 +346,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): smartenergy.Metering.cluster_id, "instantaneous_demand", async_test_metering, - 9, + 10, { "demand_formatting": 0xF9, "divisor": 1, @@ -338,13 +354,13 @@ async def async_test_device_temperature(hass, cluster, entity_id): "multiplier": 1, "status": 0x00, }, - {"current_summ_delivered"}, + {"current_summ_delivered", "current_summ_received"}, ), ( smartenergy.Metering.cluster_id, "summation_delivered", - async_test_smart_energy_summation, - 9, + async_test_smart_energy_summation_delivered, + 10, { "demand_formatting": 0xF9, "divisor": 1000, @@ -354,7 +370,23 @@ async def async_test_device_temperature(hass, cluster, entity_id): "summation_formatting": 0b1_0111_010, "unit_of_measure": 0x00, }, - {"instaneneous_demand"}, + {"instaneneous_demand", "current_summ_received"}, + ), + ( + smartenergy.Metering.cluster_id, + "summation_received", + async_test_smart_energy_summation_received, + 10, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summation_formatting": 0b1_0111_010, + "unit_of_measure": 0x00, + }, + {"instaneneous_demand", "current_summ_delivered"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -476,7 +508,7 @@ async def test_sensor( await async_test_rejoin(hass, zigpy_device, [cluster], (report_count,)) -def assert_state(hass, entity_id, state, unit_of_measurement): +def assert_state(hass: HomeAssistant, entity_id, state, unit_of_measurement): """Check that the state is what is expected. This is used to ensure that the logic in each sensor class handled the @@ -488,7 +520,7 @@ def assert_state(hass, entity_id, state, unit_of_measurement): @pytest.fixture -def hass_ms(hass): +def hass_ms(hass: HomeAssistant): """Hass instance with measurement system.""" async def _hass_ms(meas_sys): @@ -710,6 +742,7 @@ async def test_electrical_measurement_init( }, { "summation_delivered", + "summation_received", }, { "instantaneous_demand", @@ -717,19 +750,21 @@ async def test_electrical_measurement_init( ), ( smartenergy.Metering.cluster_id, - {"instantaneous_demand", "current_summ_delivered"}, + {"instantaneous_demand", "current_summ_delivered", "current_summ_received"}, {}, { - "summation_delivered", "instantaneous_demand", + "summation_delivered", + "summation_received", }, ), ( smartenergy.Metering.cluster_id, {}, { - "summation_delivered", "instantaneous_demand", + "summation_delivered", + "summation_received", }, {}, ), diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 65ef55c4711..84a7b6443a1 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -233,6 +233,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -571,6 +576,13 @@ DEVICES = [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered" ), }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: ( + "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_received" + ), + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1510,6 +1522,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1565,6 +1582,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1620,6 +1642,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -2183,6 +2210,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -4199,6 +4231,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -4955,6 +4992,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -5003,6 +5045,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -5051,6 +5098,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_received", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", From fcf72ae0c2323e3711679be2085c1b17023c1c8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:27:09 +0100 Subject: [PATCH 0984/1544] Fix race when deleting an automation (#108772) --- homeassistant/components/config/automation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index b3c7b27dc70..02131fe2169 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -22,9 +22,8 @@ async def async_setup(hass: HomeAssistant) -> bool: async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads automations.""" - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) - if action != ACTION_DELETE: + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) return ent_reg = er.async_get(hass) From dc672ff62cb8c51e203bbc125ec60d4d778e7bf4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:28:14 +0100 Subject: [PATCH 0985/1544] Fix light color mode in fritzbox (#108758) --- homeassistant/components/fritzbox/light.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index cb0c8594695..6c06f2cc699 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -91,9 +91,13 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" - if self.data.color_mode == COLOR_MODE: - return ColorMode.HS - return ColorMode.COLOR_TEMP + if self.data.has_color: + if self.data.color_mode == COLOR_MODE: + return ColorMode.HS + return ColorMode.COLOR_TEMP + if self.data.has_level: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF @property def supported_color_modes(self) -> set[ColorMode]: From 4a2a7872fb4d9a090c8b3b788cde555a721589b5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:28:40 +0100 Subject: [PATCH 0986/1544] Fix light color mode in tplink (#108760) --- homeassistant/components/tplink/light.py | 7 +++---- tests/components/tplink/test_light.py | 12 ++++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 70a078928d9..87d30e4f76a 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,7 +182,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): self._attr_unique_id = legacy_device_id(device) else: self._attr_unique_id = device.mac.replace(":", "").upper() - modes: set[ColorMode] = set() + modes: set[ColorMode] = {ColorMode.ONOFF} if device.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) temp_range = device.valid_temperature_range @@ -191,9 +192,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): modes.add(ColorMode.HS) if device.is_dimmable: modes.add(ColorMode.BRIGHTNESS) - if not modes: - modes.add(ColorMode.ONOFF) - self._attr_supported_color_modes = modes + self._attr_supported_color_modes = filter_supported_color_modes(modes) self._async_update_attrs() @callback diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index c541551a250..bd8a380daa1 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -86,7 +86,7 @@ async def test_color_light( attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp", "hs"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] assert attributes[ATTR_MIN_MIREDS] == 111 assert attributes[ATTR_MAX_MIREDS] == 250 assert attributes[ATTR_HS_COLOR] == (10, 30) @@ -163,7 +163,7 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None: attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "hs" - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "hs"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] assert attributes[ATTR_HS_COLOR] == (10, 30) assert attributes[ATTR_RGB_COLOR] == (255, 191, 178) assert attributes[ATTR_XY_COLOR] == (0.42, 0.336) @@ -225,13 +225,9 @@ async def test_color_temp_light( assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_COLOR_MODE] == "color_temp" if bulb.is_color: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - "brightness", - "color_temp", - "hs", - ] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] else: - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"] assert attributes[ATTR_MIN_MIREDS] == 111 assert attributes[ATTR_MAX_MIREDS] == 250 assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 From afbd71514facada9e7c10701424a9a9873732c7b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:28:59 +0100 Subject: [PATCH 0987/1544] Fix light color mode in advantage_air (#108757) --- homeassistant/components/advantage_air/light.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index d0aca153d4c..0ee91c6fcbc 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -40,6 +40,7 @@ async def async_setup_entry( class AdvantageAirLight(AdvantageAirEntity, LightEntity): """Representation of Advantage Air Light.""" + _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None From 97e038eb2e70505dfc2b3bbc8ac4ff8b509e67cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:29:16 +0100 Subject: [PATCH 0988/1544] Fix light color mode in netatmo (#108759) --- homeassistant/components/netatmo/light.py | 11 +++++++---- tests/components/netatmo/snapshots/test_light.ambr | 2 ++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index e5dd3b7354a..c38aec41564 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -64,7 +64,9 @@ async def async_setup_entry( class NetatmoCameraLight(NetatmoBaseEntity, LightEntity): """Representation of a Netatmo Presence camera light.""" + _attr_color_mode = ColorMode.ONOFF _attr_has_entity_name = True + _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( self, @@ -170,10 +172,11 @@ class NetatmoLight(NetatmoBaseEntity, LightEntity): self._attr_brightness = 0 self._attr_unique_id = f"{self._id}-light" - self._attr_supported_color_modes: set[str] = set() - - if not self._attr_supported_color_modes and self._dimmer.brightness is not None: - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + if self._dimmer.brightness is not None: + self._attr_color_mode = ColorMode.BRIGHTNESS + else: + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {self._attr_color_mode} self._signal_name = f"{HOME}-{self._home_id}" self._publishers.extend( diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr index 7fac90b4ec0..a116c6a3e08 100644 --- a/tests/components/netatmo/snapshots/test_light.ambr +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': dict({ 'supported_color_modes': list([ + , ]), }), 'config_entry_id': , @@ -40,6 +41,7 @@ 'color_mode': None, 'friendly_name': 'Bathroom light', 'supported_color_modes': list([ + , ]), 'supported_features': , }), From 431e4b38ac89e708ce0d7e1d153a5cb6917e8030 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:29:35 +0100 Subject: [PATCH 0989/1544] Improve tests of script trace (#108733) --- tests/helpers/test_script.py | 170 +++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 76 deletions(-) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index d769d89af69..1a506b88d39 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -31,7 +31,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -80,41 +80,32 @@ def compare_result_item(key, actual, expected, path): assert actual == expected -ANY_CONTEXT = {"context": ANY} +ANY_CONTEXT = {"context": Context(id=ANY)} -def assert_element(trace_element, expected_element, path): +def assert_element(trace_element, expected_element, path, numeric_path): """Assert a trace element is as expected. - Note: Unused variable 'path' is passed to get helpful errors from pytest. + Note: Unused variable 'numeric_path' is passed to get helpful errors from pytest. """ - expected_result = expected_element.get("result", {}) - - # Check that every item in expected_element is present and equal in trace_element - # The redundant set operation gives helpful errors from pytest - assert not set(expected_result) - set(trace_element._result or {}) - for result_key, result in expected_result.items(): - compare_result_item(result_key, trace_element._result[result_key], result, path) - assert trace_element._result[result_key] == result - - # Check for unexpected items in trace_element - assert not set(trace_element._result or {}) - set(expected_result) - - if "error_type" in expected_element and expected_element["error_type"] is not None: - assert isinstance(trace_element._error, expected_element["error_type"]) - else: - assert trace_element._error is None + expected_element = dict(expected_element) # Ignore the context variable in the first step, take care to not mutate if trace_element.path == "0": - expected_element = dict(expected_element) variables = expected_element.setdefault("variables", {}) expected_element["variables"] = variables | ANY_CONTEXT - if "variables" in expected_element: - assert expected_element["variables"] == trace_element._variables - else: - assert not trace_element._variables + # Rename variables to changed_variables + if variables := expected_element.pop("variables", None): + expected_element["changed_variables"] = variables + + # Set expected path + expected_element["path"] = str(path) + + # Ignore timestamp + expected_element["timestamp"] = ANY + + assert trace_element.as_dict() == expected_element def assert_action_trace(expected, expected_script_execution="finished"): @@ -128,7 +119,7 @@ def assert_action_trace(expected, expected_script_execution="finished"): assert len(action_trace[key]) == len(expected[key]) for index, element in enumerate(expected[key]): path = f"[{trace_key_index}][{index}]" - assert_element(action_trace[key][index], element, path) + assert_element(action_trace[key][index], element, key, path) assert script_execution == expected_script_execution @@ -781,7 +772,11 @@ async def test_delay_template_invalid( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": vol.MultipleInvalid}], + "1": [ + { + "error": "offset should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" + } + ], }, expected_script_execution="aborted", ) @@ -844,7 +839,7 @@ async def test_delay_template_complex_invalid( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": vol.MultipleInvalid}], + "1": [{"error": "expected float for dictionary value @ data['seconds']"}], }, expected_script_execution="aborted", ) @@ -935,17 +930,31 @@ async def test_wait_basic(hass: HomeAssistant, action_type) -> None: } ) else: + expected_trigger = { + "alias": None, + "attribute": None, + "description": "state of switch.test", + "entity_id": "switch.test", + "for": None, + "from_state": ANY, + "id": "0", + "idx": "0", + "platform": "state", + "to_state": ANY, + } assert_action_trace( { "0": [ { "result": { "wait": { - "trigger": {"description": "state of switch.test"}, + "trigger": expected_trigger, "remaining": None, } }, - "variables": {"wait": {"remaining": None}}, + "variables": { + "wait": {"remaining": None, "trigger": expected_trigger} + }, } ], } @@ -1332,7 +1341,7 @@ async def test_wait_continue_on_timeout( } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True - expected_trace["0"][0]["error_type"] = asyncio.TimeoutError + expected_trace["0"][0]["error"] = "" expected_script_execution = "aborted" else: expected_trace["0"][0]["variables"] = variable_wait @@ -1639,7 +1648,14 @@ async def test_condition_warning( { "0": [{"result": {"event": "test_event", "event_data": {}}}], "1": [{"result": {"result": False}}], - "1/entity_id/0": [{"error_type": ConditionError}], + "1/entity_id/0": [ + { + "error": ( + "In 'numeric_state' condition: entity test.entity state " + "'string' cannot be processed as a number" + ) + } + ], }, expected_script_execution="aborted", ) @@ -1734,7 +1750,7 @@ async def test_condition_subscript( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {}}], + "1": [{}], "1/repeat/sequence/0": [ { "variables": {"repeat": {"first": True, "index": 1}}, @@ -2261,11 +2277,7 @@ async def test_repeat_for_each_non_list_template(hass: HomeAssistant) -> None: assert_action_trace( { - "0": [ - { - "error_type": script._AbortScript, - } - ], + "0": [{"error": "Repeat 'for_each' must be a list of items"}], }, expected_script_execution="aborted", ) @@ -2300,7 +2312,7 @@ async def test_repeat_for_each_invalid_template( assert_action_trace( { - "0": [{"error_type": script._AbortScript}], + "0": [{"error": "Repeat 'for_each' must be a list of items"}], }, expected_script_execution="aborted", ) @@ -2362,10 +2374,14 @@ async def test_repeat_condition_warning( "variables": {"repeat": {"first": True, "index": 1}}, } ] - expected_trace[f"0/repeat/{condition}/0"] = [{"error_type": ConditionError}] - expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [ - {"error_type": ConditionError} + expected_error = ( + "In 'numeric_state' condition: entity sensor.test state '' cannot " + "be processed as a number" + ) + expected_trace[f"0/repeat/{condition}/0"] = [ + {"error": "In 'numeric_state':\n " + expected_error} ] + expected_trace[f"0/repeat/{condition}/0/entity_id/0"] = [{"error": expected_error}] assert_action_trace(expected_trace) @@ -2487,7 +2503,7 @@ async def test_repeat_until_condition_validation( assert_action_trace( { - "0": [{"result": {}}], + "0": [{}], "0/repeat/sequence/0": [ { "result": {"event": "test_event", "event_data": {}}, @@ -2550,7 +2566,7 @@ async def test_repeat_while_condition_validation( assert_action_trace( { - "0": [{"result": {}}], + "0": [{}], "0/repeat": [ { "result": {"result": False}, @@ -3011,7 +3027,7 @@ async def test_choose_condition_validation( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {}}], + "1": [{}], "1/choose/0": [{"result": {"result": False}}], "1/choose/0/conditions/0": [{"result": {"result": False}}], "1/choose/0/conditions/0/entity_id/0": [ @@ -3330,20 +3346,29 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - in caplog.text ) + expected_trigger = { + "alias": None, + "attribute": None, + "description": "state of switch.trigger", + "entity_id": "switch.trigger", + "for": None, + "from_state": ANY, + "id": "0", + "idx": "0", + "platform": "state", + "to_state": ANY, + } expected_trace = { - "0": [{"result": {}, "variables": {"what": "world"}}], + "0": [{"variables": {"what": "world"}}], "0/parallel/0/sequence/0": [ { "result": { "wait": { "remaining": None, - "trigger": { - "entity_id": "switch.trigger", - "description": "state of switch.trigger", - }, + "trigger": expected_trigger, } }, - "variables": {"wait": {"remaining": None}}, + "variables": {"wait": {"remaining": None, "trigger": expected_trigger}}, } ], "0/parallel/1/sequence/0": [ @@ -3429,13 +3454,9 @@ async def test_parallel_loop( assert events_loop2[2].data["hello2"] == "loop2_c" expected_trace = { - "0": [{"result": {}, "variables": {"what": "world"}}], - "0/parallel/0/sequence/0": [{"result": {}}], - "0/parallel/1/sequence/0": [ - { - "result": {}, - } - ], + "0": [{"variables": {"what": "world"}}], + "0/parallel/0/sequence/0": [{}], + "0/parallel/1/sequence/0": [{}], "0/parallel/0/sequence/0/repeat/sequence/0": [ { "variables": { @@ -3530,10 +3551,10 @@ async def test_parallel_error( assert len(events) == 0 expected_trace = { - "0": [{"error_type": ServiceNotFound, "result": {}}], + "0": [{"error": "Service epic.failure not found."}], "0/parallel/0/sequence/0": [ { - "error_type": ServiceNotFound, + "error": "Service epic.failure not found.", "result": { "params": { "domain": "epic", @@ -3581,7 +3602,7 @@ async def test_propagate_error_service_not_found(hass: HomeAssistant) -> None: expected_trace = { "0": [ { - "error_type": ServiceNotFound, + "error": "Service test.script not found.", "result": { "params": { "domain": "test", @@ -3617,7 +3638,7 @@ async def test_propagate_error_invalid_service_data(hass: HomeAssistant) -> None expected_trace = { "0": [ { - "error_type": vol.MultipleInvalid, + "error": "expected str for dictionary value @ data['text']", "result": { "params": { "domain": "test", @@ -3657,7 +3678,7 @@ async def test_propagate_error_service_exception(hass: HomeAssistant) -> None: expected_trace = { "0": [ { - "error_type": ValueError, + "error": "BROKEN", "result": { "params": { "domain": "test", @@ -4995,17 +5016,17 @@ async def test_stop_action( @pytest.mark.parametrize( - ("error", "error_type", "logmsg", "script_execution"), + ("error", "error_dict", "logmsg", "script_execution"), ( - (True, script._AbortScript, "Error", "aborted"), - (False, None, "Stop", "finished"), + (True, {"error": "In the name of love"}, "Error", "aborted"), + (False, {}, "Stop", "finished"), ), ) async def test_stop_action_subscript( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, error, - error_type, + error_dict, logmsg, script_execution, ) -> None: @@ -5044,14 +5065,11 @@ async def test_stop_action_subscript( assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"error_type": error_type, "result": {"choice": "then"}}], + "1": [{"result": {"choice": "then"}} | error_dict], "1/if": [{"result": {"result": True}}], "1/if/condition/0": [{"result": {"result": True, "entities": []}}], "1/then/0": [ - { - "error_type": error_type, - "result": {"stop": "In the name of love", "error": error}, - } + {"result": {"stop": "In the name of love", "error": error}} | error_dict ], }, expected_script_execution=script_execution, @@ -5091,7 +5109,7 @@ async def test_stop_action_with_error( "0": [{"result": {"event": "test_event", "event_data": {}}}], "1": [ { - "error_type": script._AbortScript, + "error": "Epic one...", "result": {"stop": "Epic one...", "error": True}, } ], @@ -5152,7 +5170,7 @@ async def test_continue_on_error(hass: HomeAssistant) -> None: "2": [{"result": {"event": "test_event", "event_data": {}}}], "3": [ { - "error_type": HomeAssistantError, + "error": "It is not working!", "result": { "params": { "domain": "broken", @@ -5210,7 +5228,7 @@ async def test_continue_on_error_automation_issue(hass: HomeAssistant) -> None: { "0": [ { - "error_type": ServiceNotFound, + "error": "Service service.not_found not found.", "result": { "params": { "domain": "service", @@ -5257,7 +5275,7 @@ async def test_continue_on_error_unknown_error(hass: HomeAssistant) -> None: { "0": [ { - "error_type": MyLibraryError, + "error": "It is not working!", "result": { "params": { "domain": "some", From c3de193e2ea366b0563a8facb25100869c803ac8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 15:44:45 +0100 Subject: [PATCH 0990/1544] Adjust color_mode checks when lights render effects (#108737) * Adjust color_mode checks when lights render effects * Improve comment * Avoid calling effect property if light does not support effects * Fix test --- homeassistant/components/light/__init__.py | 64 ++++++++++++++++++---- tests/components/light/test_init.py | 48 ++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 234e2547676..4abe18daa21 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -234,6 +234,7 @@ ATTR_EFFECT_LIST = "effect_list" # Apply an effect to the light, can be EFFECT_COLORLOOP. ATTR_EFFECT = "effect" EFFECT_COLORLOOP = "colorloop" +EFFECT_OFF = "off" EFFECT_RANDOM = "random" EFFECT_WHITE = "white" @@ -1060,6 +1061,51 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) return data + def __validate_color_mode( + self, + color_mode: ColorMode | str | None, + supported_color_modes: set[ColorMode] | set[str], + effect: str | None, + ) -> None: + """Validate the color mode.""" + if color_mode is None: + # The light is turned off + return + + if not effect or effect == EFFECT_OFF: + # No effect is active, the light must set color mode to one of the supported + # color modes + if color_mode in supported_color_modes: + return + # Increase severity to warning in 2024.3, reject in 2025.3 + _LOGGER.debug( + "%s: set to unsupported color_mode: %s, supported_color_modes: %s", + self.entity_id, + color_mode, + supported_color_modes, + ) + return + + # When an effect is active, the color mode should indicate what adjustments are + # supported by the effect. To make this possible, we allow the light to set its + # color mode to on_off, and to brightness if the light allows adjusting + # brightness, in addition to the otherwise supported color modes. + effect_color_modes = supported_color_modes | {ColorMode.ONOFF} + if brightness_supported(effect_color_modes): + effect_color_modes.add(ColorMode.BRIGHTNESS) + + if color_mode in effect_color_modes: + return + + # Increase severity to warning in 2024.3, reject in 2025.3 + _LOGGER.debug( + "%s: set to unsupported color_mode: %s, supported for effect: %s", + self.entity_id, + color_mode, + effect_color_modes, + ) + return + @final @property def state_attributes(self) -> dict[str, Any] | None: @@ -1074,14 +1120,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _is_on = self.is_on color_mode = self._light_internal_color_mode if _is_on else None - if color_mode and color_mode not in legacy_supported_color_modes: - # Increase severity to warning in 2024.3, reject in 2025.3 - _LOGGER.debug( - "%s: set to unsupported color_mode: %s, supported_color_modes: %s", - self.entity_id, - color_mode, - legacy_supported_color_modes, - ) + effect: str | None + if LightEntityFeature.EFFECT in supported_features: + data[ATTR_EFFECT] = effect = self.effect if _is_on else None + else: + effect = None + + self.__validate_color_mode(color_mode, legacy_supported_color_modes, effect) data[ATTR_COLOR_MODE] = color_mode @@ -1140,9 +1185,6 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if color_mode: data.update(self._light_internal_convert_color(color_mode)) - if LightEntityFeature.EFFECT in supported_features: - data[ATTR_EFFECT] = self.effect if _is_on else None - return data @property diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 903002063e8..69f6a841737 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2610,3 +2610,51 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> caplog.clear() assert entity.supported_features_compat is light.LightEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + +@pytest.mark.parametrize( + ("color_mode", "supported_color_modes", "effect", "warning_expected"), + [ + (light.ColorMode.ONOFF, {light.ColorMode.ONOFF}, None, False), + # A light which supports brightness should not set its color mode to on_off + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, None, True), + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, light.EFFECT_OFF, True), + # Unless it renders an effect + (light.ColorMode.ONOFF, {light.ColorMode.BRIGHTNESS}, "effect", False), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.BRIGHTNESS}, "effect", False), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.BRIGHTNESS}, None, False), + # A light which supports color should not set its color mode to brightnes + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, None, True), + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, light.EFFECT_OFF, True), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, None, True), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, light.EFFECT_OFF, True), + # Unless it renders an effect + (light.ColorMode.BRIGHTNESS, {light.ColorMode.HS}, "effect", False), + (light.ColorMode.ONOFF, {light.ColorMode.HS}, "effect", False), + (light.ColorMode.HS, {light.ColorMode.HS}, "effect", False), + # A light which supports brightness should not set its color mode to hs even + # if rendering an effect + (light.ColorMode.HS, {light.ColorMode.BRIGHTNESS}, "effect", True), + ], +) +def test_report_invalid_color_mode( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + color_mode: str, + supported_color_modes: set[str], + effect: str | None, + warning_expected: bool, +) -> None: + """Test a light setting an invalid color mode.""" + + class MockLightEntityEntity(light.LightEntity): + _attr_color_mode = color_mode + _attr_effect = effect + _attr_is_on = True + _attr_supported_features = light.LightEntityFeature.EFFECT + _attr_supported_color_modes = supported_color_modes + + entity = MockLightEntityEntity() + entity._async_calculate_state() + expected_warning = f"set to unsupported color_mode: {color_mode}" + assert (expected_warning in caplog.text) is warning_expected From 4b2b4ae36b3ac6f176aeacabe8a3b1d9e68bdfe2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 16:35:08 +0100 Subject: [PATCH 0991/1544] Add invert option to switch_as_x (#107535) * Add invert option to switch_as_x * Store invert flag in entity options * Add options flow * Update strings * Add tests * Address review comment * Update homeassistant/components/switch_as_x/strings.json Co-authored-by: G Johansson * Address review comments * Inline get_suggested which was only used once in tests * Address review comments --------- Co-authored-by: G Johansson --- .../components/switch_as_x/__init__.py | 33 +++- .../components/switch_as_x/config_flow.py | 13 +- homeassistant/components/switch_as_x/const.py | 1 + homeassistant/components/switch_as_x/cover.py | 15 +- .../components/switch_as_x/entity.py | 29 +++- homeassistant/components/switch_as_x/lock.py | 15 +- .../components/switch_as_x/strings.json | 16 ++ homeassistant/components/switch_as_x/valve.py | 15 +- tests/components/switch_as_x/__init__.py | 38 +++++ .../switch_as_x/test_config_flow.py | 76 +++++++-- tests/components/switch_as_x/test_cover.py | 97 ++++++++++- tests/components/switch_as_x/test_fan.py | 96 ++++++++++- tests/components/switch_as_x/test_init.py | 155 ++++++++++++++++-- tests/components/switch_as_x/test_light.py | 116 ++++++++++++- tests/components/switch_as_x/test_lock.py | 86 +++++++++- tests/components/switch_as_x/test_siren.py | 96 ++++++++++- tests/components/switch_as_x/test_valve.py | 96 ++++++++++- 17 files changed, 939 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index e2ad91e990e..3fe2ff7bc7d 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.helpers.typing import EventType -from .const import CONF_TARGET_DOMAIN +from .const import CONF_INVERT, CONF_TARGET_DOMAIN from .light import LightSwitch __all__ = ["LightSwitch"] @@ -91,6 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entity_id, async_registry_updated ) ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) device_id = async_add_to_device(hass, entry, entity_id) @@ -100,6 +101,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + options.setdefault(CONF_INVERT, False) + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry, options=options) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 90f6b985893..e40e247f105 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( wrapped_entity_config_entry_title, ) -from .const import CONF_TARGET_DOMAIN, DOMAIN +from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN TARGET_DOMAIN_OPTIONS = [ selector.SelectOptionDict(value=Platform.COVER, label="Cover"), @@ -32,6 +32,7 @@ CONFIG_FLOW = { vol.Required(CONF_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain=Platform.SWITCH), ), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), vol.Required(CONF_TARGET_DOMAIN): selector.SelectSelector( selector.SelectSelectorConfig(options=TARGET_DOMAIN_OPTIONS), ), @@ -40,11 +41,21 @@ CONFIG_FLOW = { ) } +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + vol.Schema({vol.Required(CONF_INVERT): selector.BooleanSelector()}) + ), +} + class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Switch as X.""" config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + VERSION = 1 + MINOR_VERSION = 2 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title and hide the wrapped entity if registered.""" diff --git a/homeassistant/components/switch_as_x/const.py b/homeassistant/components/switch_as_x/const.py index 4963d6fa60b..58ace36487a 100644 --- a/homeassistant/components/switch_as_x/const.py +++ b/homeassistant/components/switch_as_x/const.py @@ -4,4 +4,5 @@ from typing import Final DOMAIN: Final = "switch_as_x" +CONF_INVERT: Final = "invert" CONF_TARGET_DOMAIN: Final = "target_domain" diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index b7fe0fbf364..37071ac6771 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -23,7 +23,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -43,6 +44,7 @@ async def async_setup_entry( hass, config_entry.title, COVER_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ) -class CoverSwitch(BaseEntity, CoverEntity): +class CoverSwitch(BaseInvertableEntity, CoverEntity): """Represents a Switch as a Cover.""" _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -59,7 +61,7 @@ class CoverSwitch(BaseEntity, CoverEntity): """Open the cover.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -69,7 +71,7 @@ class CoverSwitch(BaseEntity, CoverEntity): """Close cover.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -87,4 +89,7 @@ class CoverSwitch(BaseEntity, CoverEntity): ): return - self._attr_is_closed = state.state != STATE_ON + if self._invert_state: + self._attr_is_closed = state.state == STATE_ON + else: + self._attr_is_closed = state.state != STATE_ON diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 52d58157e34..39c2a8cab60 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -106,7 +106,7 @@ class BaseEntity(Entity): registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, - {"entity_id": self._switch_entity_id}, + self.async_generate_entity_options(), ) if not self._is_new_entity or not ( @@ -141,6 +141,11 @@ class BaseEntity(Entity): copy_custom_name(wrapped_switch) copy_expose_settings() + @callback + def async_generate_entity_options(self) -> dict[str, Any]: + """Generate entity options.""" + return {"entity_id": self._switch_entity_id, "invert": False} + class BaseToggleEntity(BaseEntity, ToggleEntity): """Represents a Switch as a ToggleEntity.""" @@ -178,3 +183,25 @@ class BaseToggleEntity(BaseEntity, ToggleEntity): return self._attr_is_on = state.state == STATE_ON + + +class BaseInvertableEntity(BaseEntity): + """Represents a Switch as an X.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry_title: str, + domain: str, + invert: bool, + switch_entity_id: str, + unique_id: str, + ) -> None: + """Initialize Switch as an X.""" + super().__init__(hass, config_entry_title, domain, switch_entity_id, unique_id) + self._invert_state = invert + + @callback + def async_generate_entity_options(self) -> dict[str, Any]: + """Generate entity options.""" + return super().async_generate_entity_options() | {"invert": self._invert_state} diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 9e7606865a1..528825c0300 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -19,7 +19,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -39,6 +40,7 @@ async def async_setup_entry( hass, config_entry.title, LOCK_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -46,14 +48,14 @@ async def async_setup_entry( ) -class LockSwitch(BaseEntity, LockEntity): +class LockSwitch(BaseInvertableEntity, LockEntity): """Represents a Switch as a Lock.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -63,7 +65,7 @@ class LockSwitch(BaseEntity, LockEntity): """Unlock the lock.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -83,4 +85,7 @@ class LockSwitch(BaseEntity, LockEntity): # Logic is the same as the lock device class for binary sensors # on means open (unlocked), off means closed (locked) - self._attr_is_locked = state.state != STATE_ON + if self._invert_state: + self._attr_is_locked = state.state == STATE_ON + else: + self._attr_is_locked = state.state != STATE_ON diff --git a/homeassistant/components/switch_as_x/strings.json b/homeassistant/components/switch_as_x/strings.json index 10adfd7686e..81567ef9e40 100644 --- a/homeassistant/components/switch_as_x/strings.json +++ b/homeassistant/components/switch_as_x/strings.json @@ -6,7 +6,23 @@ "description": "Pick a switch that you want to show up in Home Assistant as a light, cover or anything else. The original switch will be hidden.", "data": { "entity_id": "Switch", + "invert": "Invert state", "target_domain": "New Type" + }, + "data_description": { + "invert": "Invert state, only supported for cover, lock and valve." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "invert": "[%key:component::switch_as_x::config::step::user::data::invert%]" + }, + "data_description": { + "invert": "[%key:component::switch_as_x::config::step::user::data_description::invert%]" } } } diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py index 3a9fbc16247..971338764a5 100644 --- a/homeassistant/components/switch_as_x/valve.py +++ b/homeassistant/components/switch_as_x/valve.py @@ -23,7 +23,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.typing import EventType -from .entity import BaseEntity +from .const import CONF_INVERT +from .entity import BaseInvertableEntity async def async_setup_entry( @@ -43,6 +44,7 @@ async def async_setup_entry( hass, config_entry.title, VALVE_DOMAIN, + config_entry.options[CONF_INVERT], entity_id, config_entry.entry_id, ) @@ -50,7 +52,7 @@ async def async_setup_entry( ) -class ValveSwitch(BaseEntity, ValveEntity): +class ValveSwitch(BaseInvertableEntity, ValveEntity): """Represents a Switch as a Valve.""" _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE @@ -60,7 +62,7 @@ class ValveSwitch(BaseEntity, ValveEntity): """Open the valve.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF if self._invert_state else SERVICE_TURN_ON, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -70,7 +72,7 @@ class ValveSwitch(BaseEntity, ValveEntity): """Close valve.""" await self.hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + SERVICE_TURN_ON if self._invert_state else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self._switch_entity_id}, blocking=True, context=self._context, @@ -88,4 +90,7 @@ class ValveSwitch(BaseEntity, ValveEntity): ): return - self._attr_is_closed = state.state != STATE_ON + if self._invert_state: + self._attr_is_closed = state.state == STATE_ON + else: + self._attr_is_closed = state.state != STATE_ON diff --git a/tests/components/switch_as_x/__init__.py b/tests/components/switch_as_x/__init__.py index d7cf944e624..de6f1bac790 100644 --- a/tests/components/switch_as_x/__init__.py +++ b/tests/components/switch_as_x/__init__.py @@ -1 +1,39 @@ """The tests for Switch as X platforms.""" + +from homeassistant.const import ( + STATE_CLOSED, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_OPEN, + STATE_UNLOCKED, + Platform, +) + +PLATFORMS_TO_TEST = ( + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SIREN, + Platform.VALVE, +) + +STATE_MAP = { + False: { + Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, + Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LOCK: {STATE_ON: STATE_UNLOCKED, STATE_OFF: STATE_LOCKED}, + Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.VALVE: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, + }, + True: { + Platform.COVER: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, + Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.LOCK: {STATE_ON: STATE_LOCKED, STATE_OFF: STATE_UNLOCKED}, + Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, + Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, + }, +} diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 51efbf99892..09661b0619c 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -5,23 +5,21 @@ from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN -from homeassistant.const import CONF_ENTITY_ID, Platform +from homeassistant import config_entries +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) +from homeassistant.const import CONF_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import PLATFORMS_TO_TEST, STATE_MAP -PLATFORMS_TO_TEST = ( - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SIREN, - Platform.VALVE, -) +from tests.common import MockConfigEntry @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) @@ -41,6 +39,7 @@ async def test_config_flow( result["flow_id"], { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, ) @@ -51,6 +50,7 @@ async def test_config_flow( assert result["data"] == {} assert result["options"] == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,6 +59,7 @@ async def test_config_flow( assert config_entry.data == {} assert config_entry.options == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } @@ -96,6 +97,7 @@ async def test_config_flow_registered_entity( result["flow_id"], { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, ) @@ -106,6 +108,7 @@ async def test_config_flow_registered_entity( assert result["data"] == {} assert result["options"] == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } assert len(mock_setup_entry.mock_calls) == 1 @@ -114,6 +117,7 @@ async def test_config_flow_registered_entity( assert config_entry.data == {} assert config_entry.options == { CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, } @@ -125,26 +129,66 @@ async def test_config_flow_registered_entity( async def test_options( hass: HomeAssistant, target_domain: Platform, - mock_setup_entry: AsyncMock, ) -> None: """Test reconfiguring.""" + switch_state = STATE_ON + hass.states.async_set("switch.ceiling", switch_state) switch_as_x_config_entry = MockConfigEntry( data={}, domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: True, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() + state = hass.states.get(f"{target_domain}.abc") + assert state.state == STATE_MAP[True][target_domain][switch_state] + config_entry = hass.config_entries.async_entries(DOMAIN)[0] assert config_entry - # Switch light has no options flow - with pytest.raises(data_entry_flow.UnknownHandler): - await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + schema_key = next(k for k in schema if k == CONF_INVERT) + assert schema_key.description["suggested_value"] is True + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_INVERT: False, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.data == {} + assert config_entry.options == { + CONF_ENTITY_ID: "switch.ceiling", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.title == "ABC" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + # Check the state of the entity has changed as expected + state = hass.states.get(f"{target_domain}.abc") + assert state.state == STATE_MAP[False][target_domain][switch_state] diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index d0aef0b9490..78a76c20beb 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -1,7 +1,13 @@ """Tests for the Switch as X Cover platform.""" + from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -28,9 +34,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.COVER, }, title="Garage Door", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -51,9 +60,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.COVER, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -120,3 +132,86 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to cover.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.COVER, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {CONF_ENTITY_ID: "cover.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index cf6789d439c..c459831b3ad 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Fan platform.""" from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -24,9 +29,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.FAN, }, title="Wind Machine", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,9 +55,95 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.FAN, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("fan.decorative_lights").state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "fan.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as fan entity.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.FAN, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 738127faf43..2b0a67f3984 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -6,7 +6,13 @@ from unittest.mock import patch import pytest from homeassistant.components.homeassistant import exposed_entities -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_ENTITY_ID, STATE_CLOSED, @@ -22,6 +28,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from . import PLATFORMS_TO_TEST + from tests.common import MockConfigEntry EXPOSE_SETTINGS = { @@ -30,15 +38,6 @@ EXPOSE_SETTINGS = { "conversation": True, } -PLATFORMS_TO_TEST = ( - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SIREN, - Platform.VALVE, -) - @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( @@ -52,9 +51,12 @@ async def test_config_entry_unregistered_uuid( domain=DOMAIN, options={ CONF_ENTITY_ID: fake_uuid, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -92,9 +94,12 @@ async def test_entity_registry_events( domain=DOMAIN, options={ CONF_ENTITY_ID: registry_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -169,9 +174,12 @@ async def test_device_registry_config_entry_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -224,9 +232,12 @@ async def test_device_registry_config_entry_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -258,9 +269,12 @@ async def test_config_entry_entity_id( domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.abc", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -296,9 +310,12 @@ async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) - domain=DOMAIN, options={ CONF_ENTITY_ID: registry_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) @@ -331,9 +348,12 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -360,9 +380,12 @@ async def test_setup_and_remove_config_entry( domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) @@ -409,9 +432,12 @@ async def test_reset_hidden_by( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -445,9 +471,12 @@ async def test_entity_category_inheritance( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -481,9 +510,12 @@ async def test_entity_options( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -494,7 +526,7 @@ async def test_entity_options( assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False}, } @@ -534,9 +566,12 @@ async def test_entity_name( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -550,7 +585,7 @@ async def test_entity_name( assert entity_entry.name is None assert entity_entry.original_name is None assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -592,9 +627,12 @@ async def test_custom_name_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -610,7 +648,7 @@ async def test_custom_name_1( assert entity_entry.name == "Custom entity name" assert entity_entry.original_name == "Original entity name" assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -656,9 +694,12 @@ async def test_custom_name_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -689,7 +730,7 @@ async def test_custom_name_2( assert entity_entry.name == "Old custom entity name" assert entity_entry.original_name == "Original entity name" assert entity_entry.options == { - DOMAIN: {"entity_id": switch_entity_entry.entity_id} + DOMAIN: {"entity_id": switch_entity_entry.entity_id, "invert": False} } @@ -719,9 +760,12 @@ async def test_import_expose_settings_1( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -777,9 +821,12 @@ async def test_import_expose_settings_2( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -842,9 +889,12 @@ async def test_restore_expose_settings( domain=DOMAIN, options={ CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, CONF_TARGET_DOMAIN: target_domain, }, title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) switch_as_x_config_entry.add_to_hass(hass) @@ -871,3 +921,80 @@ async def test_restore_expose_settings( ) for assistant in EXPOSE_SETTINGS: assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_migrate( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test migration.""" + registry = er.async_get(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check migration was successful and added invert option + assert config_entry.state == ConfigEntryState.LOADED + assert config_entry.options == { + CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.version == SwitchAsXConfigFlowHandler.VERSION + assert config_entry.minor_version == SwitchAsXConfigFlowHandler.MINOR_VERSION + + # Check the state and entity registry entry are present + assert hass.states.get(f"{target_domain}.abc") is not None + assert registry.async_get(f"{target_domain}.abc") is not None + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_migrate_from_future( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test migration.""" + registry = er.async_get(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check migration was not successful and did not add invert option + assert config_entry.state == ConfigEntryState.MIGRATION_ERROR + assert config_entry.options == { + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: target_domain, + } + assert config_entry.version == 2 + assert config_entry.minor_version == 1 + + # Check the state and entity registry entry are not present + assert hass.states.get(f"{target_domain}.abc") is None + assert registry.async_get(f"{target_domain}.abc") is None diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index 9a33bab20a8..5bdec990fd4 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -11,7 +11,12 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -34,9 +39,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="Christmas Tree Lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -64,9 +72,12 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="decorative_lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -118,9 +129,112 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LIGHT, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("light.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("light.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("light.decorative_lights").state == STATE_ON + + +async def test_light_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to light.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LIGHT, + }, + title="decorative_lights", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("light.decorative_lights").state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("light.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("light.decorative_lights").state == STATE_ON + assert ( + hass.states.get("light.decorative_lights").attributes.get(ATTR_COLOR_MODE) + == ColorMode.ONOFF + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "light.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("light.decorative_lights").state == STATE_OFF + + +async def test_switch_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to switch.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LIGHT, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index 6d30ac4646b..bdf1b754c5a 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Lock platform.""" from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_LOCK, @@ -28,9 +33,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LOCK, }, title="candy_jar", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -50,9 +58,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.LOCK, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -109,3 +120,76 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as lock entity.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.LOCK, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {CONF_ENTITY_ID: "lock.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {CONF_ENTITY_ID: "lock.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index f776ab2ae01..581aa74daff 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -1,7 +1,12 @@ """Tests for the Switch as X Siren platform.""" from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_TOGGLE, @@ -24,9 +29,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.SIREN, }, title="Noise Maker", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,9 +55,95 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.SIREN, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("siren.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "siren.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls affecting the switch as siren entity.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.SIREN, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py index da20c544f64..b76da012bde 100644 --- a/tests/components/switch_as_x/test_valve.py +++ b/tests/components/switch_as_x/test_valve.py @@ -1,6 +1,11 @@ """Tests for the Switch as X Valve platform.""" from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler +from homeassistant.components.switch_as_x.const import ( + CONF_INVERT, + CONF_TARGET_DOMAIN, + DOMAIN, +) from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.const import ( CONF_ENTITY_ID, @@ -28,9 +33,12 @@ async def test_default_state(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.test", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.VALVE, }, title="Garage Door", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -51,9 +59,12 @@ async def test_service_calls(hass: HomeAssistant) -> None: domain=DOMAIN, options={ CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: False, CONF_TARGET_DOMAIN: Platform.VALVE, }, title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -120,3 +131,86 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_ON assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + +async def test_service_calls_inverted(hass: HomeAssistant) -> None: + """Test service calls to valve.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_INVERT: True, + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Title is ignored", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED From aaf1cc818a06140c9b5a42fae528cea6a467f2dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 16:55:18 +0100 Subject: [PATCH 0992/1544] Fix light color mode in tradfri (#108761) --- homeassistant/components/tradfri/light.py | 29 +++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index df35301b373..769c8f6f9e1 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -14,6 +14,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -51,6 +52,7 @@ class TradfriLight(TradfriBaseEntity, LightEntity): _attr_name = None _attr_supported_features = LightEntityFeature.TRANSITION + _fixed_color_mode: ColorMode | None = None def __init__( self, @@ -72,18 +74,16 @@ class TradfriLight(TradfriBaseEntity, LightEntity): self._hs_color = None # Calculate supported color modes - self._attr_supported_color_modes: set[ColorMode] = set() + modes: set[ColorMode] = {ColorMode.ONOFF} if self._device.light_control.can_set_color: - self._attr_supported_color_modes.add(ColorMode.HS) + modes.add(ColorMode.HS) if self._device.light_control.can_set_temp: - self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - if ( - not self._attr_supported_color_modes - and self._device.light_control.can_set_dimmer - ): - # Must be the only supported mode according to docs for - # ColorMode.BRIGHTNESS - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + modes.add(ColorMode.COLOR_TEMP) + if self._device.light_control.can_set_dimmer: + modes.add(ColorMode.BRIGHTNESS) + self._attr_supported_color_modes = filter_supported_color_modes(modes) + if len(self._attr_supported_color_modes) == 1: + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) if self._device_control: self._attr_min_mireds = self._device_control.min_mireds @@ -100,6 +100,15 @@ class TradfriLight(TradfriBaseEntity, LightEntity): return False return cast(bool, self._device_data.state) + @property + def color_mode(self) -> ColorMode | None: + """Return the color mode of the light.""" + if self._fixed_color_mode: + return self._fixed_color_mode + if self.hs_color: + return ColorMode.HS + return ColorMode.COLOR_TEMP + @property def brightness(self) -> int | None: """Return the brightness of the light.""" From 5467fe8ff1a1d24a2ead49ee4b260f8b717afce1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 24 Jan 2024 17:17:43 +0100 Subject: [PATCH 0993/1544] Add Ecovacs select entities (#108766) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/icons.json | 8 ++ homeassistant/components/ecovacs/select.py | 101 +++++++++++++++ homeassistant/components/ecovacs/strings.json | 20 +++ tests/components/ecovacs/conftest.py | 24 +++- .../ecovacs/snapshots/test_select.ambr | 57 +++++++++ .../components/ecovacs/test_binary_sensor.py | 22 +++- tests/components/ecovacs/test_init.py | 18 +++ tests/components/ecovacs/test_select.py | 115 ++++++++++++++++++ tests/components/ecovacs/test_sensor.py | 19 ++- 10 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/ecovacs/select.py create mode 100644 tests/components/ecovacs/snapshots/test_select.ambr create mode 100644 tests/components/ecovacs/test_select.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 22572d47580..5a17fd6d66f 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 50c03ad2bd2..f29dd1bb1b1 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -45,6 +45,14 @@ "total_stats_cleanings": { "default": "mdi:counter" } + }, + "select": { + "water_amount": { + "default": "mdi:water" + }, + "work_mode": { + "default": "mdi:cog" + } } } } diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py new file mode 100644 index 00000000000..cd1cdd10379 --- /dev/null +++ b/homeassistant/components/ecovacs/select.py @@ -0,0 +1,101 @@ +"""Ecovacs select entity module.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic + +from deebot_client.capabilities import CapabilitySetTypes +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 .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSelectEntityDescription( + SelectEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs select entity description.""" + + current_option_fn: Callable[[EventT], str | None] + options_fn: Callable[[CapabilitySetTypes], list[str]] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( + EcovacsSelectEntityDescription[WaterInfoEvent]( + capability_fn=lambda caps: caps.water, + current_option_fn=lambda e: e.amount.display_name, + options_fn=lambda water: [amount.display_name for amount in water.types], + key="water_amount", + translation_key="water_amount", + entity_category=EntityCategory.CONFIG, + ), + EcovacsSelectEntityDescription[WorkModeEvent]( + capability_fn=lambda caps: caps.clean.work_mode, + current_option_fn=lambda e: e.mode.display_name, + options_fn=lambda cap: [mode.display_name for mode in cap.types], + key="work_mode", + translation_key="work_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities = get_supported_entitites( + controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSelectEntity( + EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], + SelectEntity, +): + """Ecovacs select entity.""" + + _attr_current_option: str | None = None + entity_description: EcovacsSelectEntityDescription + + def __init__( + self, + device: Device, + capability: CapabilitySetTypes[EventT, str], + entity_description: EcovacsSelectEntityDescription, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, entity_description, **kwargs) + self._attr_options = entity_description.options_fn(capability) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_current_option = self.entity_description.current_option_fn(event) + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._device.execute_command(self._capability.set(option)) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 7497e97e795..7a9065d7706 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -67,6 +67,26 @@ "name": "Total time cleaned" } }, + "select": { + "water_amount": { + "name": "Water amount", + "state": { + "high": "High", + "low": "Low", + "medium": "Medium", + "ultrahigh": "Ultrahigh" + } + }, + "work_mode": { + "name": "Work mode", + "state": { + "mop": "Mop", + "mop_after_vacuum": "Mop after vacuum", + "vacuum": "Vacuum", + "vacuum_and_mop": "Vacuum & mop" + } + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 65b214e6b9c..74e4d30a16d 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -9,8 +9,10 @@ from deebot_client.exceptions import ApiError from deebot_client.models import Credentials import pytest +from homeassistant.components.ecovacs import PLATFORMS from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import VALID_ENTRY_DATA @@ -103,6 +105,12 @@ def mock_device_execute() -> AsyncMock: yield mock_device_execute +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return PLATFORMS + + @pytest.fixture async def init_integration( hass: HomeAssistant, @@ -110,13 +118,21 @@ async def init_integration( mock_authenticator: Mock, mock_mqtt_client: Mock, mock_device_execute: AsyncMock, + platforms: Platform | list[Platform], ) -> MockConfigEntry: """Set up the Ecovacs integration for testing.""" - mock_config_entry.add_to_hass(hass) + if not isinstance(platforms, list): + platforms = [platforms] - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - return mock_config_entry + with patch( + "homeassistant.components.ecovacs.PLATFORMS", + platforms, + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry @pytest.fixture diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr new file mode 100644 index 00000000000..abf37a17256 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ozmo_950_water_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water amount', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': 'E1234567890000000001_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Water amount', + 'options': list([ + 'low', + 'medium', + 'high', + 'ultrahigh', + ]), + }), + 'context': , + 'entity_id': 'select.ozmo_950_water_amount', + 'last_changed': , + 'last_updated': , + 'state': 'ultrahigh', + }) +# --- diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index a912df60c62..f72ad6bd7e5 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,25 +1,32 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.event_bus import EventBus from deebot_client.events import WaterAmount, WaterInfoEvent import pytest from syrupy import SnapshotAssertion +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.const import STATE_OFF, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .util import notify_and_wait pytestmark = [pytest.mark.usefixtures("init_integration")] +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BINARY_SENSOR + + async def test_mop_attached( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - controller: EcovacsController, snapshot: SnapshotAssertion, + controller: EcovacsController, ) -> None: """Test mop_attached binary sensor.""" entity_id = "binary_sensor.ozmo_950_mop_attached" @@ -30,7 +37,12 @@ async def test_mop_attached( assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") assert entity_entry.device_id - event_bus: EventBus = controller.devices[0].events + device = controller.devices[0] + + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + event_bus = device.events await notify_and_wait( hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True) ) diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 103ab254650..c64d3055624 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -110,3 +110,21 @@ async def test_devices_in_dr( ) ) assert device_entry == snapshot(name=device.device_info.did) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ("yna5x1", 16), + ], +) +async def test_all_entities_loaded( + hass: HomeAssistant, + device_fixture: str, + entities: int, +) -> None: + """Test that all entities are loaded together.""" + assert ( + hass.states.async_entity_ids_count() == entities + ), f"loaded entities for {device_fixture}: {hass.states.async_entity_ids()}" diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py new file mode 100644 index 00000000000..cfe34c5a7a6 --- /dev/null +++ b/tests/components/ecovacs/test_select.py @@ -0,0 +1,115 @@ +"""Tests for Ecovacs select entities.""" + +from deebot_client.command import Command +from deebot_client.commands.json import SetWaterInfo +from deebot_client.event_bus import EventBus +from deebot_client.events import WaterAmount, WaterInfoEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components import select +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SELECT + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(WaterInfoEvent(WaterAmount.ULTRAHIGH)) + await block_till_done(hass, event_bus) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "select.ozmo_950_water_amount", + ], + ), + ], +) +async def test_selects( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entity_ids: list[str], +) -> None: + """Test that select entity snapshots match.""" + assert entity_ids == sorted(hass.states.async_entity_ids()) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device = controller.devices[0] + await notify_events(hass, device.events) + for entity_id in entity_ids: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "current_state", "set_state", "command"), + [ + ( + "yna5x1", + "select.ozmo_950_water_amount", + "ultrahigh", + "low", + SetWaterInfo(WaterAmount.LOW), + ), + ], +) +async def test_selects_change( + hass: HomeAssistant, + controller: EcovacsController, + entity_id: list[str], + current_state: str, + set_state: str, + command: Command, +) -> None: + """Test that changing select entities works.""" + device = controller.devices[0] + await notify_events(hass, device.events) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == current_state + + device._execute_command.reset_mock() + await hass.services.async_call( + select.DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: set_state}, + blocking=True, + ) + device._execute_command.assert_called_with(command) diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 35dc0dbbe53..18d65349fa2 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -13,16 +13,23 @@ from deebot_client.events import ( import pytest from syrupy import SnapshotAssertion +from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .util import block_till_done pytestmark = [pytest.mark.usefixtures("init_integration")] +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SENSOR + + async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(StatsEvent(10, 300, "spotArea")) @@ -65,18 +72,20 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): ) async def test_sensors( hass: HomeAssistant, - controller: EcovacsController, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + controller: EcovacsController, entity_ids: list[str], ) -> None: """Test that sensor entity snapshots match.""" - assert entity_ids == sorted(hass.states.async_entity_ids(Platform.SENSOR)) + assert entity_ids == sorted(hass.states.async_entity_ids()) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN - await notify_events(hass, controller.devices[0].events) + device = controller.devices[0] + await notify_events(hass, device.events) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert snapshot(name=f"{entity_id}:state") == state @@ -85,6 +94,8 @@ async def test_sensors( assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} @pytest.mark.parametrize( From a90d8b6a0c6e516663a3ec9712d47bb0c7beecd5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 24 Jan 2024 18:56:21 +0100 Subject: [PATCH 0994/1544] Stabilize alexa discovery (#108787) --- homeassistant/components/alexa/entities.py | 77 ++++++++++---------- homeassistant/components/alexa/handlers.py | 17 +++-- tests/components/alexa/test_entities.py | 81 +++++++++++++++++++++- 3 files changed, 126 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 70679f8dafb..939644b4600 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -380,12 +380,17 @@ def async_get_entities( if state.domain not in ENTITY_ADAPTERS: continue - alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) - - if not list(alexa_entity.interfaces()): - continue - - entities.append(alexa_entity) + try: + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + interfaces = list(alexa_entity.interfaces()) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Unable to serialize %s for discovery: %s", state.entity_id, exc + ) + else: + if not interfaces: + continue + entities.append(alexa_entity) return entities @@ -406,13 +411,11 @@ class GenericCapabilities(AlexaEntity): return [DisplayCategory.OTHER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaPowerController(self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaPowerController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(input_boolean.DOMAIN) @@ -431,14 +434,12 @@ class SwitchCapabilities(AlexaEntity): return [DisplayCategory.SWITCH] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaPowerController(self.entity), - AlexaContactSensor(self.hass, self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaPowerController(self.entity) + yield AlexaContactSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(button.DOMAIN) @@ -450,14 +451,12 @@ class ButtonCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=False), - AlexaEventDetectionSensor(self.hass, self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(climate.DOMAIN) @@ -676,13 +675,11 @@ class LockCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SMARTLOCK] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaLockController(self.entity), - AlexaEndpointHealth(self.hass, self.entity), - Alexa(self.entity), - ] + yield AlexaLockController(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(media_player.const.DOMAIN) @@ -767,12 +764,10 @@ class SceneCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SCENE_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=False), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=False) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(script.DOMAIN) @@ -783,12 +778,10 @@ class ScriptCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> list[AlexaCapability]: + def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" - return [ - AlexaSceneController(self.entity, supports_deactivation=True), - Alexa(self.entity), - ] + yield AlexaSceneController(self.entity, supports_deactivation=True) + yield Alexa(self.entity) @ENTITY_ADAPTERS.register(sensor.DOMAIN) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 463693f7da6..398c6218193 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -119,11 +119,18 @@ async def async_api_discovery( Async friendly. """ - discovery_endpoints = [ - alexa_entity.serialize_discovery() - for alexa_entity in async_get_entities(hass, config) - if config.should_expose(alexa_entity.entity_id) - ] + discovery_endpoints: list[dict[str, Any]] = [] + for alexa_entity in async_get_entities(hass, config): + if not config.should_expose(alexa_entity.entity_id): + continue + try: + discovered_serialized_entity = alexa_entity.serialize_discovery() + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception( + "Unable to serialize %s for discovery: %s", alexa_entity.entity_id, exc + ) + else: + discovery_endpoints.append(discovered_serialized_entity) return directive.response( name="Discover.Response", diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 87aab24a3b1..c7949253af0 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -1,10 +1,11 @@ """Test Alexa entity representation.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components.alexa import smart_home -from homeassistant.const import EntityCategory, __version__ +from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -75,7 +76,7 @@ async def test_categorized_hidden_entities( async def test_serialize_discovery(hass: HomeAssistant) -> None: - """Test we handle an interface raising unexpectedly during serialize discovery.""" + """Test we can serialize a discovery.""" request = get_new_request("Alexa.Discovery", "Discover") hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"}) @@ -94,6 +95,82 @@ async def test_serialize_discovery(hass: HomeAssistant) -> None: } +async def test_serialize_discovery_partly_fails( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we can partly serialize a discovery.""" + + async def _mock_discovery() -> dict[str, Any]: + request = get_new_request("Alexa.Discovery", "Discover") + hass.states.async_set("switch.bla", "on", {"friendly_name": "My Switch"}) + hass.states.async_set("fan.bla", "on", {"friendly_name": "My Fan"}) + hass.states.async_set( + "humidifier.bla", "on", {"friendly_name": "My Humidifier"} + ) + hass.states.async_set( + "sensor.bla", + "20.1", + { + "friendly_name": "Livingroom temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS, + "device_class": "temperature", + }, + ) + return await smart_home.async_handle_message( + hass, get_default_config(hass), request + ) + + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 4 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "fan#bla", "humidifier#bla", "sensor#bla"] + ) + + # Simulate fetching the interfaces fails for fan entity + with patch( + "homeassistant.components.alexa.entities.FanCapabilities.interfaces", + side_effect=TypeError(), + ): + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 3 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "humidifier#bla", "sensor#bla"] + ) + assert "Unable to serialize fan.bla for discovery" in caplog.text + caplog.clear() + + # Simulate serializing properties fails for sensor entity + with patch( + "homeassistant.components.alexa.entities.SensorCapabilities.default_display_categories", + side_effect=ValueError(), + ): + msg = await _mock_discovery() + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 3 + endpoint_ids = { + attributes["endpointId"] for attributes in msg["payload"]["endpoints"] + } + assert all( + entity in endpoint_ids + for entity in ["switch#bla", "humidifier#bla", "fan#bla"] + ) + assert "Unable to serialize sensor.bla for discovery" in caplog.text + caplog.clear() + + async def test_serialize_discovery_recovers( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From c6a1ec96f4c866c867bebd525618b00a1926d5c7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 24 Jan 2024 19:00:57 +0100 Subject: [PATCH 0995/1544] Add Shelly CoAP port to default container port (#108016) * Add Shelly CoAP port to default container port * Update devcontainer.json --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44a81718e10..553f3bbdf0e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "1" }, - "appPort": ["8123:8123"], + // Port 5683 udp is used by Shelly integration + "appPort": ["8123:8123", "5683:5683/udp"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], "customizations": { "vscode": { From 9c727e5ea8f298e5ec0e61e0941cc67e6b8ae1b4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 19:11:03 +0100 Subject: [PATCH 0996/1544] Add icon to areas (#108650) --- .../components/config/area_registry.py | 3 ++ homeassistant/helpers/area_registry.py | 21 ++++++++++++-- tests/components/config/test_area_registry.py | 14 ++++++++- tests/helpers/test_area_registry.py | 29 +++++++++++++++---- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index e9cdc523686..c8dc7450183 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -38,6 +38,7 @@ def websocket_list_areas( { vol.Required("type"): "config/area_registry/create", vol.Optional("aliases"): list, + vol.Optional("icon"): str, vol.Required("name"): str, vol.Optional("picture"): vol.Any(str, None), } @@ -97,6 +98,7 @@ def websocket_delete_area( vol.Required("type"): "config/area_registry/update", vol.Optional("aliases"): list, vol.Required("area_id"): str, + vol.Optional("icon"): vol.Any(str, None), vol.Optional("name"): str, vol.Optional("picture"): vol.Any(str, None), } @@ -133,6 +135,7 @@ def _entry_dict(entry: AreaEntry) -> dict[str, Any]: return { "aliases": list(entry.aliases), "area_id": entry.id, + "icon": entry.icon, "name": entry.name, "picture": entry.picture, } diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index b3da01114d3..26da98e3fac 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -33,6 +33,7 @@ class AreaEntry: """Area Registry Entry.""" aliases: set[str] + icon: str | None id: str name: str normalized_name: str @@ -107,6 +108,11 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): for area in old_data["areas"]: area["aliases"] = [] + if old_minor_version < 4: + # Version 1.4 adds icon + for area in old_data["areas"]: + area["icon"] = None + if old_major_version > 1: raise NotImplementedError return old_data @@ -161,6 +167,7 @@ class AreaRegistry: name: str, *, aliases: set[str] | None = None, + icon: str | None = None, picture: str | None = None, ) -> AreaEntry: """Create a new area.""" @@ -172,6 +179,7 @@ class AreaRegistry: area_id = self._generate_area_id(name) area = AreaEntry( aliases=aliases or set(), + icon=icon, id=area_id, name=name, normalized_name=normalized_name, @@ -207,12 +215,17 @@ class AreaRegistry: area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: """Update name of area.""" updated = self._async_update( - area_id, aliases=aliases, name=name, picture=picture + area_id, + aliases=aliases, + icon=icon, + name=name, + picture=picture, ) self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, {"action": "update", "area_id": area_id} @@ -225,6 +238,7 @@ class AreaRegistry: area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: @@ -235,6 +249,7 @@ class AreaRegistry: for attr_name, value in ( ("aliases", aliases), + ("icon", icon), ("picture", picture), ): if value is not UNDEFINED and value != getattr(old, attr_name): @@ -264,6 +279,7 @@ class AreaRegistry: normalized_name = normalize_area_name(area["name"]) areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), + icon=area["icon"], id=area["id"], name=area["name"], normalized_name=normalized_name, @@ -286,8 +302,9 @@ class AreaRegistry: data["areas"] = [ { "aliases": list(entry.aliases), - "name": entry.name, + "icon": entry.icon, "id": entry.id, + "name": entry.name, "picture": entry.picture, } for entry in self.areas.values() diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index c012104a2db..1d1e14173f7 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -22,7 +22,10 @@ async def test_list_areas( """Test list entries.""" area1 = area_registry.async_create("mock 1") area2 = area_registry.async_create( - "mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png" + "mock 2", + aliases={"alias_1", "alias_2"}, + icon="mdi:garage", + picture="/image/example.png", ) await client.send_json({"id": 1, "type": "config/area_registry/list"}) @@ -32,12 +35,14 @@ async def test_list_areas( { "aliases": [], "area_id": area1.id, + "icon": None, "name": "mock 1", "picture": None, }, { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area2.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", }, @@ -58,6 +63,7 @@ async def test_create_area( assert msg["result"] == { "aliases": [], "area_id": ANY, + "icon": None, "name": "mock", "picture": None, } @@ -68,6 +74,7 @@ async def test_create_area( { "id": 2, "aliases": ["alias_1", "alias_2"], + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", "type": "config/area_registry/create", @@ -79,6 +86,7 @@ async def test_create_area( assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": ANY, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", } @@ -148,6 +156,7 @@ async def test_update_area( "id": 1, "aliases": ["alias_1", "alias_2"], "area_id": area.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", "type": "config/area_registry/update", @@ -159,6 +168,7 @@ async def test_update_area( assert msg["result"] == { "aliases": unordered(["alias_1", "alias_2"]), "area_id": area.id, + "icon": "mdi:garage", "name": "mock 2", "picture": "/image/example.png", } @@ -169,6 +179,7 @@ async def test_update_area( "id": 2, "aliases": ["alias_1", "alias_1"], "area_id": area.id, + "icon": None, "picture": None, "type": "config/area_registry/update", } @@ -179,6 +190,7 @@ async def test_update_area( assert msg["result"] == { "aliases": ["alias_1"], "area_id": area.id, + "icon": None, "name": "mock 2", "picture": None, } diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index fd74da547d4..8a7c023ced8 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -40,7 +40,12 @@ async def test_create_area( area = area_registry.async_create("mock") assert area == ar.AreaEntry( - name="mock", normalized_name=ANY, aliases=set(), id=ANY, picture=None + aliases=set(), + icon=None, + id=ANY, + name="mock", + normalized_name=ANY, + picture=None, ) assert len(area_registry.areas) == 1 @@ -56,10 +61,11 @@ async def test_create_area( ) assert area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + icon=None, + id=ANY, name="mock 2", normalized_name=ANY, - aliases={"alias_1", "alias_2"}, - id=ANY, picture="/image/example.png", ) assert len(area_registry.areas) == 2 @@ -139,16 +145,18 @@ async def test_update_area( updated_area = area_registry.async_update( area.id, aliases={"alias_1", "alias_2"}, + icon="mdi:garage", name="mock1", picture="/image/example.png", ) assert updated_area != area assert updated_area == ar.AreaEntry( + aliases={"alias_1", "alias_2"}, + icon="mdi:garage", + id=ANY, name="mock1", normalized_name=ANY, - aliases={"alias_1", "alias_2"}, - id=ANY, picture="/image/example.png", ) assert len(area_registry.areas) == 1 @@ -250,6 +258,7 @@ async def test_loading_area_from_storage( { "aliases": ["alias_1", "alias_2"], "id": "12345A", + "icon": "mdi:garage", "name": "mock", "picture": "blah", } @@ -287,7 +296,15 @@ async def test_migration_from_1_1( "minor_version": ar.STORAGE_VERSION_MINOR, "key": ar.STORAGE_KEY, "data": { - "areas": [{"aliases": [], "id": "12345A", "name": "mock", "picture": None}] + "areas": [ + { + "aliases": [], + "icon": None, + "id": "12345A", + "name": "mock", + "picture": None, + } + ] }, } From aa86d87a311524489d235c0174c0e8610fb8354c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 24 Jan 2024 13:11:39 -0500 Subject: [PATCH 0997/1544] Bump python-roborock to 39.1 (#108751) --- .../components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 118 ------------------ 4 files changed, 3 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index b2567b89abe..ddb65c3187c 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.39.0", + "python-roborock==0.39.1", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b25b66c878..40ddda1c524 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2263,7 +2263,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.39.0 +python-roborock==0.39.1 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94f698975d1..285dac13a95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1724,7 +1724,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==0.39.0 +python-roborock==0.39.1 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 9176e2552ef..6bcd2152a95 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -36,65 +36,6 @@ 'roborock_device_info': dict({ 'device': dict({ 'activeTime': 1672364449, - 'deviceFeatures': dict({ - 'anyStateTransitGotoSupported': True, - 'avoidCollisionSupported': False, - 'backChargeAutoWashSupported': False, - 'carefulSlowMapSupported': False, - 'carpetDeepCleanSupported': False, - 'carpetShowOnMap': False, - 'carpetSupported': True, - 'cleanRouteFastModeSupported': False, - 'cliffZoneSupported': False, - 'currentMapRestoreEnabled': False, - 'customDoorSillSupported': False, - 'customWaterBoxDistanceSupported': False, - 'downloadTestVoiceSupported': False, - 'dryingSupported': False, - 'dustCollectionSettingSupported': False, - 'eggModeSupported': False, - 'flowLedSettingSupported': False, - 'fwFilterObstacleSupported': True, - 'ignoreUnknownMapObjectSupported': True, - 'mapBeautifyInternalDebugSupported': False, - 'mapCarpetAddSupported': False, - 'mopPathSupported': False, - 'multiMapSegmentTimerSupported': False, - 'newDataForCleanHistory': False, - 'newDataForCleanHistoryDetail': False, - 'offlineMapSupported': False, - 'photoUploadSupported': False, - 'recordAllowed': True, - 'resegmentSupported': False, - 'roomNameSupported': False, - 'rpcRetrySupported': False, - 'setChildSupported': True, - 'shakeMopSetSupported': False, - 'showCleanFinishReasonSupported': True, - 'smartDoorSillSupported': False, - 'stuckZoneSupported': False, - 'supportBackupMap': False, - 'supportCleanEstimate': False, - 'supportCustomDnd': False, - 'supportCustomModeInCleaning': False, - 'supportFloorDirection': False, - 'supportFloorEdit': False, - 'supportFurniture': False, - 'supportIncrementalMap': True, - 'supportQuickMapBuilder': False, - 'supportRemoteControlInCall': False, - 'supportRoomTag': False, - 'supportSetSwitchMapMode': False, - 'supportSetVolumeInCall': True, - 'supportSmartGlobalCleanWithCustomMode': False, - 'supportSmartScene': False, - 'supportedValleyElectricity': False, - 'unsaveMapReasonSupported': False, - 'videoMonitorSupported': True, - 'videoSettingSupported': True, - 'washThenChargeCmdSupported': False, - 'wifiManageSupported': False, - }), 'deviceStatus': dict({ '120': 0, '121': 8, @@ -372,65 +313,6 @@ 'roborock_device_info': dict({ 'device': dict({ 'activeTime': 1672364449, - 'deviceFeatures': dict({ - 'anyStateTransitGotoSupported': True, - 'avoidCollisionSupported': False, - 'backChargeAutoWashSupported': False, - 'carefulSlowMapSupported': False, - 'carpetDeepCleanSupported': False, - 'carpetShowOnMap': False, - 'carpetSupported': True, - 'cleanRouteFastModeSupported': False, - 'cliffZoneSupported': False, - 'currentMapRestoreEnabled': False, - 'customDoorSillSupported': False, - 'customWaterBoxDistanceSupported': False, - 'downloadTestVoiceSupported': False, - 'dryingSupported': False, - 'dustCollectionSettingSupported': False, - 'eggModeSupported': False, - 'flowLedSettingSupported': False, - 'fwFilterObstacleSupported': True, - 'ignoreUnknownMapObjectSupported': True, - 'mapBeautifyInternalDebugSupported': False, - 'mapCarpetAddSupported': False, - 'mopPathSupported': False, - 'multiMapSegmentTimerSupported': False, - 'newDataForCleanHistory': False, - 'newDataForCleanHistoryDetail': False, - 'offlineMapSupported': False, - 'photoUploadSupported': False, - 'recordAllowed': True, - 'resegmentSupported': False, - 'roomNameSupported': False, - 'rpcRetrySupported': False, - 'setChildSupported': True, - 'shakeMopSetSupported': False, - 'showCleanFinishReasonSupported': True, - 'smartDoorSillSupported': False, - 'stuckZoneSupported': False, - 'supportBackupMap': False, - 'supportCleanEstimate': False, - 'supportCustomDnd': False, - 'supportCustomModeInCleaning': False, - 'supportFloorDirection': False, - 'supportFloorEdit': False, - 'supportFurniture': False, - 'supportIncrementalMap': True, - 'supportQuickMapBuilder': False, - 'supportRemoteControlInCall': False, - 'supportRoomTag': False, - 'supportSetSwitchMapMode': False, - 'supportSetVolumeInCall': True, - 'supportSmartGlobalCleanWithCustomMode': False, - 'supportSmartScene': False, - 'supportedValleyElectricity': False, - 'unsaveMapReasonSupported': False, - 'videoMonitorSupported': True, - 'videoSettingSupported': True, - 'washThenChargeCmdSupported': False, - 'wifiManageSupported': False, - }), 'deviceStatus': dict({ '120': 0, '121': 8, From 909e58066d202df9f599dcedeabbac59cf9336af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jan 2024 19:12:45 +0100 Subject: [PATCH 0998/1544] Fix changed_variables in automation and script traces (#108788) --- homeassistant/helpers/script.py | 3 ++- tests/helpers/test_script.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index b391dcd5397..040059276d3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -477,11 +477,12 @@ class _ScriptRun: try: handler = f"_async_{action}_step" await getattr(self, handler)() - trace_element.update_variables(self._variables) except Exception as ex: # pylint: disable=broad-except self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions ) + finally: + trace_element.update_variables(self._variables) def _finish(self) -> None: self._script._runs.remove(self) # pylint: disable=protected-access diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 1a506b88d39..57c1b2dd473 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1337,14 +1337,13 @@ async def test_wait_continue_on_timeout( result_wait = {"wait": {"trigger": None, "remaining": 0.0}} variable_wait = dict(result_wait) expected_trace = { - "0": [{"result": result_wait}], + "0": [{"result": result_wait, "variables": variable_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error"] = "" expected_script_execution = "aborted" else: - expected_trace["0"][0]["variables"] = variable_wait expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) From 30c9a70dbf46ae534b0c394936904d626b632ff4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 24 Jan 2024 19:47:07 +0100 Subject: [PATCH 0999/1544] Fix google_assistant climate modes might be None (#108793) --- homeassistant/components/google_assistant/trait.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 9b8a95f0b4a..189d1354e26 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1152,12 +1152,12 @@ class TemperatureSettingTrait(_Trait): modes = [] attrs = self.state.attributes - for mode in attrs.get(climate.ATTR_HVAC_MODES, []): + for mode in attrs.get(climate.ATTR_HVAC_MODES) or []: google_mode = self.hvac_to_google.get(mode) if google_mode and google_mode not in modes: modes.append(google_mode) - for preset in attrs.get(climate.ATTR_PRESET_MODES, []): + for preset in attrs.get(climate.ATTR_PRESET_MODES) or []: google_mode = self.preset_to_google.get(preset) if google_mode and google_mode not in modes: modes.append(google_mode) @@ -2094,9 +2094,10 @@ class InputSelectorTrait(_Trait): def sync_attributes(self): """Return mode attributes for a sync request.""" attrs = self.state.attributes + sourcelist: list[str] = attrs.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] inputs = [ {"key": source, "names": [{"name_synonym": [source], "lang": "en"}]} - for source in attrs.get(media_player.ATTR_INPUT_SOURCE_LIST, []) + for source in sourcelist ] payload = {"availableInputs": inputs, "orderedInputs": True} From 852e4c21c65569a2f1f67ca0358492ef893282b0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 24 Jan 2024 19:50:55 +0100 Subject: [PATCH 1000/1544] Complete device tracker entity tests (#108768) --- .../components/device_tracker/config_entry.py | 4 +- .../device_tracker/test_config_entry.py | 908 +++++++++++++++--- .../device_tracker/test_entities.py | 85 -- 3 files changed, 760 insertions(+), 237 deletions(-) delete mode 100644 tests/components/device_tracker/test_entities.py diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 50f9acf3e1a..c169c78cacc 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -132,6 +132,7 @@ def _async_register_mac( device_entry = dev_reg.async_get(ev.data["device_id"]) if device_entry is None: + # This should not happen, since the device was just created. return # Check if device has a mac @@ -153,8 +154,7 @@ def _async_register_mac( if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None: return - if (entity_entry := ent_reg.async_get(entity_id)) is None: - return + entity_entry = ent_reg.entities[entity_id] # Make sure entity has a config entry and was disabled by the # default disable logic in the integration and new entities diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 49912fd282f..fe52ec1219a 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -1,273 +1,881 @@ """Test Device Tracker config entry things.""" -from homeassistant.components.device_tracker import DOMAIN, config_entry as ce +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components.device_tracker import ( + ATTR_HOST_NAME, + ATTR_IP, + ATTR_MAC, + ATTR_SOURCE_TYPE, + DOMAIN, + SourceType, +) +from homeassistant.components.device_tracker.config_entry import ( + CONNECTED_DEVICE_REGISTERED, + BaseTrackerEntity, + ScannerEntity, + TrackerEntity, +) +from homeassistant.components.zone import ATTR_RADIUS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import MockConfigEntry, MockEntityPlatform, MockPlatform +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" +TEST_MAC_ADDRESS = "12:34:56:AB:CD:EF" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.DEVICE_TRACKER] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms( + config_entry, [Platform.DEVICE_TRACKER] + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: + """Return the config entry used for the tests.""" + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + return config_entry + + +async def create_mock_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entities: list[Entity], +) -> MockConfigEntry: + """Create a device tracker platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="entity_id") +def entity_id_fixture() -> str: + """Return the entity_id of the entity for the test.""" + return "device_tracker.entity1" + + +class MockTrackerEntity(TrackerEntity): + """Test tracker entity.""" + + def __init__( + self, + battery_level: int | None = None, + location_name: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + ) -> None: + """Initialize entity.""" + self._battery_level = battery_level + self._location_name = location_name + self._latitude = latitude + self._longitude = longitude + + @property + def battery_level(self) -> int | None: + """Return the battery level of the device. + + Percentage from 0-100. + """ + return self._battery_level + + @property + def source_type(self) -> SourceType | str: + """Return the source type, eg gps or router, of the device.""" + return SourceType.GPS + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._longitude + + +@pytest.fixture(name="battery_level") +def battery_level_fixture() -> int | None: + """Return the battery level of the entity for the test.""" + return None + + +@pytest.fixture(name="location_name") +def location_name_fixture() -> str | None: + """Return the location_name of the entity for the test.""" + return None + + +@pytest.fixture(name="latitude") +def latitude_fixture() -> float | None: + """Return the latitude of the entity for the test.""" + return None + + +@pytest.fixture(name="longitude") +def longitude_fixture() -> float | None: + """Return the longitude of the entity for the test.""" + return None + + +@pytest.fixture(name="tracker_entity") +def tracker_entity_fixture( + entity_id: str, + battery_level: int | None, + location_name: str | None, + latitude: float | None, + longitude: float | None, +) -> MockTrackerEntity: + """Create a test tracker entity.""" + entity = MockTrackerEntity( + battery_level=battery_level, + location_name=location_name, + latitude=latitude, + longitude=longitude, + ) + entity.entity_id = entity_id + return entity + + +class MockScannerEntity(ScannerEntity): + """Test scanner entity.""" + + def __init__( + self, + ip_address: str | None = None, + mac_address: str | None = None, + hostname: str | None = None, + connected: bool = False, + unique_id: str | None = None, + ) -> None: + """Initialize entity.""" + self._ip_address = ip_address + self._mac_address = mac_address + self._hostname = hostname + self._connected = connected + self._unique_id = unique_id + + @property + def should_poll(self) -> bool: + """Return False for the test entity.""" + return False + + @property + def source_type(self) -> SourceType | str: + """Return the source type, eg gps or router, of the device.""" + return SourceType.ROUTER + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._ip_address + + @property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self._mac_address + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + return self._hostname + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._connected + + @property + def unique_id(self) -> str | None: + """Return hostname of the device.""" + return self._unique_id or self._mac_address + + @callback + def set_connected(self, connected: bool) -> None: + """Set connected state.""" + self._connected = connected + self.async_write_ha_state() + + +@pytest.fixture(name="ip_address") +def ip_address_fixture() -> str | None: + """Return the ip_address of the entity for the test.""" + return None + + +@pytest.fixture(name="mac_address") +def mac_address_fixture() -> str | None: + """Return the mac_address of the entity for the test.""" + return None + + +@pytest.fixture(name="hostname") +def hostname_fixture() -> str | None: + """Return the hostname of the entity for the test.""" + return None + + +@pytest.fixture(name="unique_id") +def unique_id_fixture() -> str | None: + """Return the unique_id of the entity for the test.""" + return None + + +@pytest.fixture(name="scanner_entity") +def scanner_entity_fixture( + entity_id: str, + ip_address: str | None, + mac_address: str | None, + hostname: str | None, + unique_id: str | None, +) -> MockScannerEntity: + """Create a test scanner entity.""" + entity = MockScannerEntity( + ip_address=ip_address, + mac_address=mac_address, + hostname=hostname, + unique_id=unique_id, + ) + entity.entity_id = entity_id + return entity + + +async def test_load_unload_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, + tracker_entity: MockTrackerEntity, +) -> None: + """Test loading and unloading a config entry with a device tracker entity.""" + config_entry = await create_mock_platform(hass, config_entry, [tracker_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + state = hass.states.get(entity_id) + assert not state + + +@pytest.mark.parametrize( + ( + "battery_level", + "location_name", + "latitude", + "longitude", + "expected_state", + "expected_attributes", + ), + [ + ( + None, + None, + 1.0, + 2.0, + STATE_NOT_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: 1.0, + ATTR_LONGITUDE: 2.0, + }, + ), + ( + None, + None, + 50.0, + 60.0, + STATE_HOME, + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: 50.0, + ATTR_LONGITUDE: 60.0, + }, + ), + ( + None, + None, + -50.0, + -60.0, + "other zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + ATTR_GPS_ACCURACY: 0, + ATTR_LATITUDE: -50.0, + ATTR_LONGITUDE: -60.0, + }, + ), + ( + None, + "zen_zone", + None, + None, + "zen_zone", + { + ATTR_SOURCE_TYPE: SourceType.GPS, + }, + ), + ( + None, + None, + None, + None, + STATE_UNKNOWN, + {ATTR_SOURCE_TYPE: SourceType.GPS}, + ), + ( + 100, + None, + None, + None, + STATE_UNKNOWN, + {ATTR_BATTERY_LEVEL: 100, ATTR_SOURCE_TYPE: SourceType.GPS}, + ), + ], +) +async def test_tracker_entity_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_id: str, + tracker_entity: MockTrackerEntity, + expected_state: str, + expected_attributes: dict[str, Any], +) -> None: + """Test tracker entity state and state attributes.""" + config_entry = await create_mock_platform(hass, config_entry, [tracker_entity]) + assert config_entry.state == ConfigEntryState.LOADED + hass.states.async_set( + "zone.home", + "0", + {ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 200}, + ) + hass.states.async_set( + "zone.other_zone", + "0", + {ATTR_LATITUDE: -50.0, ATTR_LONGITUDE: -60.0, ATTR_RADIUS: 300}, + ) + await hass.async_block_till_done() + # Write state again to ensure the zone state is taken into account. + tracker_entity.async_write_ha_state() + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes == expected_attributes + + +@pytest.mark.parametrize( + ("ip_address", "mac_address", "hostname"), + [("0.0.0.0", "ad:de:ef:be:ed:fe", "test.hostname.org")], +) +async def test_scanner_entity_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_id: str, + ip_address: str, + mac_address: str, + hostname: str, + scanner_entity: MockScannerEntity, +) -> None: + """Test ScannerEntity based device tracker.""" + # Make device tied to other integration so device tracker entities get enabled + other_config_entry = MockConfigEntry(domain="not_fake_integration") + other_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + name="Device from other integration", + config_entry_id=other_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + ) + + config_entry = await create_mock_platform(hass, config_entry, [scanner_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes == { + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_IP: ip_address, + ATTR_MAC: mac_address, + ATTR_HOST_NAME: hostname, + } + assert entity_state.state == STATE_NOT_HOME + + scanner_entity.set_connected(True) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == STATE_HOME def test_tracker_entity() -> None: - """Test tracker entity.""" + """Test coverage for base TrackerEntity class.""" + entity = TrackerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + with pytest.raises(NotImplementedError): + assert entity.latitude is None + with pytest.raises(NotImplementedError): + assert entity.longitude is None + assert entity.location_name is None + with pytest.raises(NotImplementedError): + assert entity.state is None + assert entity.battery_level is None + assert entity.should_poll is False + assert entity.force_update is True - class TestEntry(ce.TrackerEntity): + class MockEntity(TrackerEntity): """Mock tracker class.""" - should_poll = False + def __init__(self) -> None: + """Initialize.""" + self.is_polling = False - instance = TestEntry() + @property + def should_poll(self) -> bool: + """Return False for the test entity.""" + return self.is_polling - assert instance.force_update + test_entity = MockEntity() - instance.should_poll = True + assert test_entity.force_update - assert not instance.force_update + test_entity.is_polling = True + + assert not test_entity.force_update + + +def test_scanner_entity() -> None: + """Test coverage for base ScannerEntity entity class.""" + entity = ScannerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + with pytest.raises(NotImplementedError): + assert entity.is_connected is None + with pytest.raises(NotImplementedError): + assert entity.state == STATE_NOT_HOME + assert entity.battery_level is None + assert entity.ip_address is None + assert entity.mac_address is None + assert entity.hostname is None + + class MockEntity(ScannerEntity): + """Mock scanner class.""" + + def __init__(self) -> None: + """Initialize.""" + self.mock_mac_address: str | None = None + + @property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self.mock_mac_address + + test_entity = MockEntity() + + assert test_entity.unique_id is None + + test_entity.mock_mac_address = TEST_MAC_ADDRESS + + assert test_entity.unique_id == TEST_MAC_ADDRESS + + +def test_base_tracker_entity() -> None: + """Test coverage for base BaseTrackerEntity entity class.""" + entity = BaseTrackerEntity() + with pytest.raises(NotImplementedError): + assert entity.source_type is None + assert entity.battery_level is None + with pytest.raises(NotImplementedError): + assert entity.state_attributes is None async def test_cleanup_legacy( hass: HomeAssistant, + config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, - enable_custom_integrations: None, ) -> None: """Test we clean up devices created by old device tracker.""" - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - - device1 = device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device1")} ) - device2 = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device2")} ) - device3 = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device3")} ) # Device with light + device tracker entity - entity1a = entity_registry.async_get_or_create( + entity_entry_1a = entity_registry.async_get_or_create( DOMAIN, "test", "entity1a-unique", config_entry=config_entry, - device_id=device1.id, + device_id=device_entry_1.id, ) - entity1b = entity_registry.async_get_or_create( + entity_entry_1b = entity_registry.async_get_or_create( "light", "test", "entity1b-unique", config_entry=config_entry, - device_id=device1.id, + device_id=device_entry_1.id, ) # Just device tracker entity - entity2a = entity_registry.async_get_or_create( + entity_entry_2a = entity_registry.async_get_or_create( DOMAIN, "test", "entity2a-unique", config_entry=config_entry, - device_id=device2.id, + device_id=device_entry_2.id, ) # Device with no device tracker entities - entity3a = entity_registry.async_get_or_create( + entity_entry_3a = entity_registry.async_get_or_create( "light", "test", "entity3a-unique", config_entry=config_entry, - device_id=device3.id, + device_id=device_entry_3.id, ) # Device tracker but no device - entity4a = entity_registry.async_get_or_create( + entity_entry_4a = entity_registry.async_get_or_create( DOMAIN, "test", "entity4a-unique", config_entry=config_entry, ) # Completely different entity - entity5a = entity_registry.async_get_or_create( + entity_entry_5a = entity_registry.async_get_or_create( "light", "test", "entity4a-unique", config_entry=config_entry, ) - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) - await hass.async_block_till_done() + await create_mock_platform(hass, config_entry, []) - for entity in (entity1a, entity1b, entity3a, entity4a, entity5a): - assert entity_registry.async_get(entity.entity_id) is not None + for entity_entry in ( + entity_entry_1a, + entity_entry_1b, + entity_entry_3a, + entity_entry_4a, + entity_entry_5a, + ): + assert entity_registry.async_get(entity_entry.entity_id) is not None + entity_entry = entity_registry.async_get(entity_entry_2a.entity_id) + assert entity_entry is not None # We've removed device so device ID cleared - assert entity_registry.async_get(entity2a.entity_id).device_id is None + assert entity_entry.device_id is None # Removed because only had device tracker entity - assert device_registry.async_get(device2.id) is None + assert device_registry.async_get(device_entry_2.id) is None +@pytest.mark.parametrize( + ("mac_address", "unique_id"), [(TEST_MAC_ADDRESS, f"{TEST_MAC_ADDRESS}_yo1")] +) async def test_register_mac( hass: HomeAssistant, + config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + mac_address: str, + unique_id: str, ) -> None: """Test registering a mac.""" - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) + await create_mock_platform(hass, config_entry, [scanner_entity]) - mac1 = "12:34:56:AB:CD:EF" - - entity_entry_1 = entity_registry.async_get_or_create( - "device_tracker", - "test", - mac1 + "yo1", - original_name="name 1", - config_entry=config_entry, - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - - ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, ) - await hass.async_block_till_done() - entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) - - assert entity_entry_1.disabled_by is None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by is None +@pytest.mark.parametrize( + ("connections", "mac_address", "unique_id"), + [ + ( + set(), + TEST_MAC_ADDRESS, + f"{TEST_MAC_ADDRESS}_yo1", + ), + ( + {(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)}, + "aa:bb:cc:dd:ee:ff", + "aa_bb_cc_dd_ee_ff", + ), + ], +) +async def test_register_mac_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + connections: set[tuple[str, str]], + mac_address: str, + unique_id: str, +) -> None: + """Test registering a mac when the mac or entity isn't found.""" + registering_scanner_entity = MockScannerEntity(mac_address="aa:bb:cc:dd:ee:ff") + registering_scanner_entity.entity_id = f"{DOMAIN}.registering_scanner_entity" + + await create_mock_platform( + hass, config_entry, [registering_scanner_entity, scanner_entity] + ) + + test_entity_entry = entity_registry.async_get(entity_id) + assert test_entity_entry is not None + assert test_entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=connections, + identifiers={(TEST_DOMAIN, "device1")}, + ) + await hass.async_block_till_done() + + # The entity entry under test should still be disabled. + test_entity_entry = entity_registry.async_get(entity_id) + assert test_entity_entry is not None + assert test_entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + ("mac_address", "unique_id"), [(TEST_MAC_ADDRESS, f"{TEST_MAC_ADDRESS}_yo1")] +) async def test_register_mac_ignored( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + scanner_entity: MockScannerEntity, + entity_id: str, + mac_address: str, + unique_id: str, ) -> None: """Test ignoring registering a mac.""" - config_entry = MockConfigEntry(domain="test", pref_disable_new_entities=True) + config_entry = MockConfigEntry(domain=TEST_DOMAIN, pref_disable_new_entities=True) config_entry.add_to_hass(hass) - mac1 = "12:34:56:AB:CD:EF" + await create_mock_platform(hass, config_entry, [scanner_entity]) - entity_entry_1 = entity_registry.async_get_or_create( - "device_tracker", - "test", - mac1 + "yo1", - original_name="name 1", - config_entry=config_entry, - disabled_by=er.RegistryEntryDisabler.INTEGRATION, - ) - - ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, ) - await hass.async_block_till_done() - entity_entry_1 = entity_registry.async_get(entity_entry_1.entity_id) - - assert entity_entry_1.disabled_by == er.RegistryEntryDisabler.INTEGRATION + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION async def test_connected_device_registered( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test dispatch on connected device being registered.""" - dispatches = [] + dispatches: list[dict[str, Any]] = [] @callback - def _save_dispatch(msg): + def _save_dispatch(msg: dict[str, Any]) -> None: + """Save dispatched message.""" dispatches.append(msg) - unsub = async_dispatcher_connect( - hass, ce.CONNECTED_DEVICE_REGISTERED, _save_dispatch + unsub = async_dispatcher_connect(hass, CONNECTED_DEVICE_REGISTERED, _save_dispatch) + + connected_scanner_entity = MockScannerEntity( + ip_address="5.4.3.2", + mac_address="aa:bb:cc:dd:ee:ff", + hostname="connected", + connected=True, + ) + disconnected_scanner_entity = MockScannerEntity( + ip_address="5.4.3.2", + mac_address="aa:bb:cc:dd:ee:00", + hostname="disconnected", + connected=False, + ) + connected_scanner_entity_bad_ip = MockScannerEntity( + ip_address="", + mac_address="aa:bb:cc:dd:ee:01", + hostname="connected_bad_ip", + connected=True, ) - class MockScannerEntity(ce.ScannerEntity): - """Mock a scanner entity.""" - - @property - def ip_address(self) -> str: - return "5.4.3.2" - - @property - def unique_id(self) -> str: - return self.mac_address - - class MockDisconnectedScannerEntity(MockScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:00" - - @property - def is_connected(self) -> bool: - return False - - @property - def hostname(self) -> str: - return "disconnected" - - class MockConnectedScannerEntity(MockScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:ff" - - @property - def is_connected(self) -> bool: - return True - - @property - def hostname(self) -> str: - return "connected" - - class MockConnectedScannerEntityBadIPAddress(MockConnectedScannerEntity): - """Mock a disconnected scanner entity.""" - - @property - def mac_address(self) -> str: - return "aa:bb:cc:dd:ee:01" - - @property - def ip_address(self) -> str: - return "" - - @property - def hostname(self) -> str: - return "connected_bad_ip" - - async def async_setup_entry(hass, config_entry, async_add_entities): - """Mock setup entry method.""" - async_add_entities( - [ - MockConnectedScannerEntity(), - MockDisconnectedScannerEntity(), - MockConnectedScannerEntityBadIPAddress(), - ] - ) - return True - - platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") - entity_platform = MockEntityPlatform( - hass, platform_name=config_entry.domain, platform=platform + config_entry = await create_mock_platform( + hass, + config_entry, + [ + connected_scanner_entity, + disconnected_scanner_entity, + connected_scanner_entity_bad_ip, + ], ) - assert await entity_platform.async_setup_entry(config_entry) - await hass.async_block_till_done() - full_name = f"{config_entry.domain}.{entity_platform.domain}" + full_name = f"{config_entry.domain}.{DOMAIN}" assert full_name in hass.config.components - assert len(hass.states.async_entity_ids()) == 0 # should be disabled + assert ( + len(hass.states.async_entity_ids(domain_filter=DOMAIN)) == 0 + ) # should be disabled assert len(entity_registry.entities) == 3 assert ( - entity_registry.entities["test_domain.test_aa_bb_cc_dd_ee_ff"].config_entry_id - == "super-mock-id" + entity_registry.entities[ + "device_tracker.test_aa_bb_cc_dd_ee_ff" + ].config_entry_id + == config_entry.entry_id ) unsub() assert dispatches == [ {"ip": "5.4.3.2", "mac": "aa:bb:cc:dd:ee:ff", "host_name": "connected"} ] + + +async def test_entity_has_device_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a scanner entity with device info.""" + + class DeviceInfoScannerEntity(MockScannerEntity): + """Test scanner entity with device info.""" + + @property + def device_info(self) -> dr.DeviceInfo: + """Return device info.""" + return dr.DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)}, + identifiers={(TEST_DOMAIN, "device1")}, + manufacturer="manufacturer", + model="model", + ) + + scanner_entity = DeviceInfoScannerEntity( + ip_address="5.4.3.2", + mac_address=TEST_MAC_ADDRESS, + ) + + config_entry = await create_mock_platform(hass, config_entry, [scanner_entity]) + + assert ( + len(hass.states.async_entity_ids(domain_filter=DOMAIN)) == 1 + ) # should be enabled + assert len(entity_registry.entities) == 1 + assert ( + entity_registry.entities[ + f"{DOMAIN}.{TEST_DOMAIN}_{TEST_MAC_ADDRESS.replace(':', '_').lower()}" + ].config_entry_id + == config_entry.entry_id + ) diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py deleted file mode 100644 index 45f1b21c89a..00000000000 --- a/tests/components/device_tracker/test_entities.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for device tracker entities.""" -import pytest - -from homeassistant.components.device_tracker.config_entry import ( - BaseTrackerEntity, - ScannerEntity, -) -from homeassistant.components.device_tracker.const import ( - ATTR_HOST_NAME, - ATTR_IP, - ATTR_MAC, - ATTR_SOURCE_TYPE, - DOMAIN, - SourceType, -) -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from tests.common import MockConfigEntry - - -async def test_scanner_entity_device_tracker( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - enable_custom_integrations: None, -) -> None: - """Test ScannerEntity based device tracker.""" - # Make device tied to other integration so device tracker entities get enabled - other_config_entry = MockConfigEntry(domain="not_fake_integration") - other_config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - name="Device from other integration", - config_entry_id=other_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, - ) - - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) - await hass.async_block_till_done() - - entity_id = "device_tracker.test_ad_de_ef_be_ed_fe" - entity_state = hass.states.get(entity_id) - assert entity_state.attributes == { - ATTR_SOURCE_TYPE: SourceType.ROUTER, - ATTR_BATTERY_LEVEL: 100, - ATTR_IP: "0.0.0.0", - ATTR_MAC: "ad:de:ef:be:ed:fe", - ATTR_HOST_NAME: "test.hostname.org", - } - assert entity_state.state == STATE_NOT_HOME - - entity = hass.data[DOMAIN].get_entity(entity_id) - entity.set_connected() - await hass.async_block_till_done() - - entity_state = hass.states.get(entity_id) - assert entity_state.state == STATE_HOME - - -def test_scanner_entity() -> None: - """Test coverage for base ScannerEntity entity class.""" - entity = ScannerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None - with pytest.raises(NotImplementedError): - assert entity.is_connected is None - with pytest.raises(NotImplementedError): - assert entity.state == STATE_NOT_HOME - assert entity.battery_level is None - assert entity.ip_address is None - assert entity.mac_address is None - assert entity.hostname is None - - -def test_base_tracker_entity() -> None: - """Test coverage for base BaseTrackerEntity entity class.""" - entity = BaseTrackerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None - assert entity.battery_level is None - with pytest.raises(NotImplementedError): - assert entity.state_attributes is None From f883f721c8e75f91a6b34bbc671ef19d82672bef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 08:52:34 -1000 Subject: [PATCH 1001/1544] Avoid copying translations for single components (#108645) --- homeassistant/helpers/translation.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index e20a290a4e2..ab9d5f576fe 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -218,8 +218,14 @@ class _TranslationCache: if components_to_load := components - loaded: await self._async_load(language, components_to_load) - result: dict[str, str] = {} category_cache = self.cache.get(language, {}).get(category, {}) + # If only one component was requested, return it directly + # to avoid merging the dictionaries and keeping additional + # copies of the same data in memory. + if len(components) == 1 and (component := next(iter(components))): + return category_cache.get(component, {}) + + result: dict[str, str] = {} for component in components.intersection(category_cache): result.update(category_cache[component]) return result @@ -298,7 +304,6 @@ class _TranslationCache: """Extract resources into the cache.""" resource: dict[str, Any] | str cached = self.cache.setdefault(language, {}) - categories: set[str] = set() for resource in translation_strings.values(): categories.update(resource) From ddf02959f41a56486e03e6928cd7fd98768b33d9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 24 Jan 2024 20:31:43 +0100 Subject: [PATCH 1002/1544] Bump area registry storage minor version to 4 (#108798) --- homeassistant/helpers/area_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 26da98e3fac..e55f71beb88 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -17,7 +17,7 @@ DATA_REGISTRY = "area_registry" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 From 0d633f33faf52d97cdbe303b699a78f37df5b5c8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Jan 2024 20:40:48 +0100 Subject: [PATCH 1003/1544] Set right icon for set_humidity climate service (#108801) --- homeassistant/components/climate/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index 69da8c401fb..4317698b257 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -55,7 +55,7 @@ }, "services": { "set_fan_mode": "mdi:fan", - "set_humidity": "mdi:humidity-percent", + "set_humidity": "mdi:water-percent", "set_swing_mode": "mdi:arrow-oscillating", "set_temperature": "mdi:thermometer" } From df9faeae6fe1f4e4a0249093b9c575af70492973 Mon Sep 17 00:00:00 2001 From: CR-Tech <41435902+crug80@users.noreply.github.com> Date: Wed, 24 Jan 2024 20:48:55 +0100 Subject: [PATCH 1004/1544] Add write_registers support for Fan Mode in modbus (#108053) --- homeassistant/components/modbus/__init__.py | 3 +- homeassistant/components/modbus/climate.py | 28 +++++++++++------ homeassistant/components/modbus/validators.py | 20 ++++++++++++- tests/components/modbus/test_climate.py | 30 +++++++++++++++++-- tests/components/modbus/test_init.py | 21 ++++++++++++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 734546a34bc..f81a1200905 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -137,6 +137,7 @@ from .validators import ( duplicate_fan_mode_validator, nan_validator, number_validator, + register_int_list_validator, struct_validator, ) @@ -279,7 +280,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { - CONF_ADDRESS: cv.positive_int, + vol.Required(CONF_ADDRESS): register_int_list_validator, CONF_FAN_MODE_VALUES: { vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index aa345324dc8..71c01d20205 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -170,7 +170,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._fan_mode_mapping_to_modbus: dict[str, int] = {} self._fan_mode_mapping_from_modbus: dict[int, str] = {} mode_value_config = mode_config[CONF_FAN_MODE_VALUES] - for fan_mode_kw, fan_mode in ( (CONF_FAN_MODE_ON, FAN_ON), (CONF_FAN_MODE_OFF, FAN_OFF), @@ -253,16 +252,23 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if self._fan_mode_register is not None: # Write a value to the mode register for the desired mode. value = self._fan_mode_mapping_to_modbus[fan_mode] - await self._hub.async_pb_call( - self._slave, - self._fan_mode_register, - value, - CALL_TYPE_WRITE_REGISTER, - ) + if isinstance(self._fan_mode_register, list): + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register[0], + [value], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) await self.async_update() @@ -344,7 +350,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # Read the Fan mode register if defined if self._fan_mode_register is not None: fan_mode = await self._async_read_register( - CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + CALL_TYPE_REGISTER_HOLDING, + self._fan_mode_register + if isinstance(self._fan_mode_register, int) + else self._fan_mode_register[0], + raw=True, ) # Translate the value received diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index e108231c5e6..e8ce35e834f 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -293,7 +293,11 @@ def duplicate_entity_validator(config: dict) -> dict: a += "_" + str(inx) entry_addrs.add(a) if CONF_FAN_MODE_REGISTER in entry: - a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a = str( + entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS] + if isinstance(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS], int) + else entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS][0] + ) a += "_" + str(inx) entry_addrs.add(a) @@ -351,6 +355,20 @@ def duplicate_modbus_validator(config: dict) -> dict: return config +def register_int_list_validator(value: Any) -> Any: + """Check if a register (CONF_ADRESS) is an int or a list having only 1 register.""" + if isinstance(value, int) and value >= 0: + return value + + if isinstance(value, list): + if (len(value) == 1) and isinstance(value[0], int) and value[0] >= 0: + return value + + raise vol.Invalid( + f"Invalid {CONF_ADDRESS} register for fan mode. Required type: positive integer, allowed 1 or list of 1 register." + ) + + def check_config(config: dict) -> dict: """Do final config check.""" config2 = duplicate_modbus_validator(config) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index b6855d7be18..3ff9aa37bcf 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -30,6 +30,7 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_OFF, CONF_FAN_MODE_ON, CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -491,7 +492,7 @@ async def test_service_climate_update( CONF_SCAN_INTERVAL: 0, CONF_DATA_TYPE: DataType.INT32, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 118, + CONF_ADDRESS: [118], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_LOW: 0, CONF_FAN_MODE_MEDIUM: 1, @@ -505,6 +506,31 @@ async def test_service_climate_update( FAN_HIGH, [0x02], ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: [118], + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + CONF_FAN_MODE_TOP: 3, + }, + }, + }, + ] + }, + FAN_TOP, + [0x03], + ), ], ) async def test_service_climate_fan_update( @@ -740,7 +766,7 @@ async def test_service_set_hvac_mode( CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 118, + CONF_ADDRESS: [118], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_ON: 1, CONF_FAN_MODE_OFF: 2, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index da46979526f..24ae8d0ebfc 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -84,6 +84,7 @@ from homeassistant.components.modbus.validators import ( duplicate_modbus_validator, nan_validator, number_validator, + register_int_list_validator, struct_validator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -138,6 +139,24 @@ async def mock_modbus_with_pymodbus_fixture(hass, caplog, do_config, mock_pymodb return mock_pymodbus +async def test_register_int_list_validator() -> None: + """Test conf address register validator.""" + for value, vtype in ( + (15, int), + ([15], list), + ): + assert isinstance(register_int_list_validator(value), vtype) + + with pytest.raises(vol.Invalid): + register_int_list_validator([15, 16]) + + with pytest.raises(vol.Invalid): + register_int_list_validator(-15) + + with pytest.raises(vol.Invalid): + register_int_list_validator(["aq"]) + + async def test_number_validator() -> None: """Test number validator.""" @@ -584,7 +603,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_SLAVE: 0, CONF_TARGET_TEMP: 117, CONF_FAN_MODE_REGISTER: { - CONF_ADDRESS: 121, + CONF_ADDRESS: [121], CONF_FAN_MODE_VALUES: { CONF_FAN_MODE_ON: 0, CONF_FAN_MODE_HIGH: 1, From de38e7a3675aa12fe52f78e1e6a65360a93c84c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 10:56:26 -1000 Subject: [PATCH 1005/1544] Bump aioshelly to 8.0.1 (#108805) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 94e2e9d70f0..e08b04d16a3 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==8.0.0"], + "requirements": ["aioshelly==8.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 40ddda1c524..67189ca9044 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==8.0.0 +aioshelly==8.0.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 285dac13a95..3c0bffd42d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -332,7 +332,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==8.0.0 +aioshelly==8.0.1 # homeassistant.components.skybell aioskybell==22.7.0 From 066692506c1ac4a214c6983c123de907a3910167 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 24 Jan 2024 22:14:15 +0100 Subject: [PATCH 1006/1544] Fix unhandled exception on humidifier intent when available_modes is None (#108802) --- homeassistant/components/humidifier/intent.py | 2 +- tests/components/humidifier/test_intent.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index d949874cc67..103521aeb04 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -110,7 +110,7 @@ class SetModeHandler(intent.IntentHandler): intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") mode = slots["mode"]["value"] - if mode not in state.attributes.get(ATTR_AVAILABLE_MODES, []): + if mode not in (state.attributes.get(ATTR_AVAILABLE_MODES) or []): raise intent.IntentHandleError( f"Entity {state.name} does not support {mode} mode" ) diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py index cbdd5c3da26..d8c9f199f57 100644 --- a/tests/components/humidifier/test_intent.py +++ b/tests/components/humidifier/test_intent.py @@ -188,7 +188,10 @@ async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: assert len(mode_calls) == 0 -async def test_intent_set_unknown_mode(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("available_modes", (["home", "away"], None)) +async def test_intent_set_unknown_mode( + hass: HomeAssistant, available_modes: list[str] | None +) -> None: """Test the set mode intent for unsupported mode.""" hass.states.async_set( "humidifier.bedroom_humidifier", @@ -196,8 +199,8 @@ async def test_intent_set_unknown_mode(hass: HomeAssistant) -> None: { ATTR_HUMIDITY: 40, ATTR_SUPPORTED_FEATURES: 1, - ATTR_AVAILABLE_MODES: ["home", "away"], - ATTR_MODE: "home", + ATTR_AVAILABLE_MODES: available_modes, + ATTR_MODE: None, }, ) mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) From 134cc7840039ff1ee750347fc26fba620d6ad6d4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 24 Jan 2024 22:15:00 +0100 Subject: [PATCH 1007/1544] Fix processing supported color modes for emulated_hue (#108803) --- homeassistant/components/emulated_hue/hue_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 0730eced60c..94ac97b6b36 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -386,7 +386,7 @@ class HueOneLightChangeView(HomeAssistantView): # Get the entity's supported features entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if entity.domain == light.DOMAIN: - color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) + color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] # Parse the request parsed: dict[str, Any] = { @@ -765,7 +765,7 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) + color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) From 02f7165ca50a3d10810ab9b1b443ffc3890d38fb Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Wed, 24 Jan 2024 14:28:27 -0800 Subject: [PATCH 1008/1544] Add super chlorination services to screenlogic (#108048) Co-authored-by: J. Nick Koston --- .../components/screenlogic/__init__.py | 4 +- homeassistant/components/screenlogic/const.py | 7 + .../components/screenlogic/services.py | 124 +++++++++++++----- .../components/screenlogic/services.yaml | 17 +++ .../components/screenlogic/strings.json | 14 ++ 5 files changed, 133 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 7276ec28323..6d066f86072 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -56,14 +56,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, config_entry=entry, gateway=gateway ) - async_load_screenlogic_services(hass) - await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(async_update_listener)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + async_load_screenlogic_services(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 8181e0f612a..3125f52989e 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -24,6 +24,13 @@ SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} +SERVICE_START_SUPER_CHLORINATION = "start_super_chlorination" +ATTR_RUNTIME = "runtime" +MAX_RUNTIME = 72 +MIN_RUNTIME = 0 + +SERVICE_STOP_SUPER_CHLORINATION = "stop_super_chlorination" + LIGHT_CIRCUIT_FUNCTIONS = { FUNCTION.COLOR_WHEEL, FUNCTION.DIMMER, diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index c9c66183daf..2c8e786491c 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -3,8 +3,10 @@ import logging from screenlogicpy import ScreenLogicError +from screenlogicpy.device_const.system import EQUIPMENT_FLAG import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -12,10 +14,16 @@ from homeassistant.helpers.service import async_extract_config_entry_ids from .const import ( ATTR_COLOR_MODE, + ATTR_RUNTIME, DOMAIN, + MAX_RUNTIME, + MIN_RUNTIME, SERVICE_SET_COLOR_MODE, + SERVICE_START_SUPER_CHLORINATION, + SERVICE_STOP_SUPER_CHLORINATION, SUPPORTED_COLOR_MODES, ) +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,35 +33,38 @@ SET_COLOR_MODE_SCHEMA = cv.make_entity_service_schema( }, ) +TURN_ON_SUPER_CHLOR_SCHEMA = cv.make_entity_service_schema( + { + vol.Optional(ATTR_RUNTIME, default=24): vol.Clamp( + min=MIN_RUNTIME, max=MAX_RUNTIME + ), + } +) + @callback -def async_load_screenlogic_services(hass: HomeAssistant): +def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): """Set up services for the ScreenLogic integration.""" - if hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - # Integration-level services have already been added. Return. - return async def extract_screenlogic_config_entry_ids(service_call: ServiceCall): - return [ - entry_id - for entry_id in await async_extract_config_entry_ids(hass, service_call) - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - ] - - async def async_set_color_mode(service_call: ServiceCall) -> None: if not ( - screenlogic_entry_ids := await extract_screenlogic_config_entry_ids( - service_call - ) + screenlogic_entry_ids := [ + entry_id + for entry_id in await async_extract_config_entry_ids(hass, service_call) + if (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.domain == DOMAIN + ] ): raise HomeAssistantError( - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for" + f"Failed to call service '{service_call.service}'. Config entry for" " target not found" ) + return screenlogic_entry_ids + + async def async_set_color_mode(service_call: ServiceCall) -> None: color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - for entry_id in screenlogic_entry_ids: - coordinator = hass.data[DOMAIN][entry_id] + for entry_id in await extract_screenlogic_config_entry_ids(service_call): + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] _LOGGER.debug( "Service %s called on %s with mode %s", SERVICE_SET_COLOR_MODE, @@ -62,26 +73,77 @@ def async_load_screenlogic_services(hass: HomeAssistant): ) try: await coordinator.gateway.async_set_color_lights(color_num) - # Debounced refresh to catch any secondary - # changes in the device + # Debounced refresh to catch any secondary changes in the device await coordinator.async_request_refresh() except ScreenLogicError as error: raise HomeAssistantError(error) from error - hass.services.async_register( - DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA - ) + async def async_set_super_chlor( + service_call: ServiceCall, + is_on: bool, + runtime: int | None = None, + ) -> None: + for entry_id in await extract_screenlogic_config_entry_ids(service_call): + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + _LOGGER.debug( + "Service %s called on %s with runtime %s", + SERVICE_START_SUPER_CHLORINATION, + coordinator.gateway.name, + runtime, + ) + try: + await coordinator.gateway.async_set_scg_config( + super_chlor_timer=runtime, super_chlorinate=is_on + ) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + async def async_start_super_chlor(service_call: ServiceCall) -> None: + runtime = service_call.data[ATTR_RUNTIME] + await async_set_super_chlor(service_call, True, runtime) + + async def async_stop_super_chlor(service_call: ServiceCall) -> None: + await async_set_super_chlor(service_call, False) + + if not hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): + hass.services.async_register( + DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA + ) + + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + equipment_flags = coordinator.gateway.equipment_flags + + if EQUIPMENT_FLAG.CHLORINATOR in equipment_flags: + if not hass.services.has_service(DOMAIN, SERVICE_START_SUPER_CHLORINATION): + hass.services.async_register( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + async_start_super_chlor, + TURN_ON_SUPER_CHLOR_SCHEMA, + ) + + if not hass.services.has_service(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION): + hass.services.async_register( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + async_stop_super_chlor, + ) @callback def async_unload_screenlogic_services(hass: HomeAssistant): """Unload services for the ScreenLogic integration.""" - if hass.data[DOMAIN]: - # There is still another config entry for this domain, don't remove services. - return - if not hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - return - - _LOGGER.info("Unloading ScreenLogic Services") - hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_COLOR_MODE) + if not hass.data[DOMAIN]: + _LOGGER.debug("Unloading all ScreenLogic services") + for service in hass.services.async_services_for_domain(DOMAIN): + hass.services.async_remove(DOMAIN, service) + elif not any( + EQUIPMENT_FLAG.CHLORINATOR in coordinator.gateway.equipment_flags + for coordinator in hass.data[DOMAIN].values() + ): + _LOGGER.debug("Unloading ScreenLogic chlorination services") + hass.services.async_remove(DOMAIN, SERVICE_START_SUPER_CHLORINATION) + hass.services.async_remove(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION) diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index 8e4a82a1079..7b51d1a21db 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -31,3 +31,20 @@ set_color_mode: - sunset - thumper - white +start_super_chlorination: + target: + device: + integration: screenlogic + fields: + runtime: + default: 24 + selector: + number: + min: 0 + max: 72 + unit_of_measurement: hours + mode: slider +stop_super_chlorination: + target: + device: + integration: screenlogic diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 4894bc6437d..fcddbc1d415 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -46,6 +46,20 @@ "description": "The ScreenLogic color mode to set." } } + }, + "start_super_chlorination": { + "name": "Start Super Chlorination", + "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", + "fields": { + "runtime": { + "name": "Run Time", + "description": "Number of hours for super chlorination to run." + } + } + }, + "stop_super_chlorination": { + "name": "Stop Super Chlorination", + "description": "Stops super chlorination." } } } From 0d22822ed0f1b6a027277f97d66409546fa3de88 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 24 Jan 2024 23:30:14 +0100 Subject: [PATCH 1009/1544] Add Ecovacs diagnostics (#108791) * Add Ecovacs diagnostics * Fix test --- .../components/ecovacs/diagnostics.py | 36 +++++++++++++ .../ecovacs/snapshots/test_diagnostics.ambr | 50 +++++++++++++++++++ tests/components/ecovacs/test_diagnostics.py | 22 ++++++++ 3 files changed, 108 insertions(+) create mode 100644 homeassistant/components/ecovacs/diagnostics.py create mode 100644 tests/components/ecovacs/snapshots/test_diagnostics.ambr create mode 100644 tests/components/ecovacs/test_diagnostics.py diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py new file mode 100644 index 00000000000..fa7d85ed52a --- /dev/null +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -0,0 +1,36 @@ +"""Ecovacs diagnostics.""" +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_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .controller import EcovacsController + +REDACT_CONFIG = {CONF_USERNAME, CONF_PASSWORD, "title"} +REDACT_DEVICE = {"did", CONF_NAME, "homeId"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + diag: dict[str, Any] = { + "config": async_redact_data(config_entry.as_dict(), REDACT_CONFIG) + } + + diag["devices"] = [ + async_redact_data(device.device_info.api_device_info, REDACT_DEVICE) + for device in controller.devices + ] + diag["legacy_devices"] = [ + async_redact_data(device.vacuum, REDACT_DEVICE) + for device in controller.legacy_devices + ] + + return diag diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b27883745b --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'data': dict({ + 'country': 'IT', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ecovacs', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + 'devices': list([ + dict({ + 'UILogicId': 'DX_9G', + 'class': 'yna5xi', + 'company': 'eco-ng', + 'deviceName': 'DEEBOT OZMO 950 Series', + 'did': '**REDACTED**', + 'homeSort': 9999, + 'icon': 'https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1', + 'materialNo': '110-1820-0101', + 'model': 'DX9G', + 'name': '**REDACTED**', + 'nick': 'Ozmo 950', + 'otaUpgrade': dict({ + }), + 'pid': '5c19a91ca1e6ee000178224a', + 'product_category': 'DEEBOT', + 'resource': 'upQ6', + 'service': dict({ + 'jmq': 'jmq-ngiot-eu.dc.ww.ecouser.net', + 'mqs': 'api-ngiot.dc-as.ww.ecouser.net', + }), + 'status': 1, + }), + ]), + 'legacy_devices': list([ + ]), + }) +# --- diff --git a/tests/components/ecovacs/test_diagnostics.py b/tests/components/ecovacs/test_diagnostics.py new file mode 100644 index 00000000000..8244efd7fec --- /dev/null +++ b/tests/components/ecovacs/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for diagnostics data.""" + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == snapshot(exclude=props("entry_id")) From f5d439799b0886396eba364cd6c7ec5b674a4027 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 25 Jan 2024 00:24:22 +0100 Subject: [PATCH 1010/1544] Add expiration of unused refresh tokens (#108428) Co-authored-by: J. Nick Koston --- homeassistant/auth/__init__.py | 71 +++++++++++++++++++++++++++-- homeassistant/auth/auth_store.py | 33 +++++++++++++- homeassistant/auth/const.py | 1 + homeassistant/auth/models.py | 2 + tests/auth/test_auth_store.py | 67 ++++++++++++++++++++++++++++ tests/auth/test_init.py | 76 +++++++++++++++++++++++++++++++- 6 files changed, 243 insertions(+), 7 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 0194be10ba9..15094681454 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial import time from typing import Any, cast @@ -12,11 +12,19 @@ from typing import Any, cast import jwt from homeassistant import data_entry_flow -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util from . import auth_store, jwt_wrapper, models -from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN +from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRATION from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -75,7 +83,9 @@ async def auth_manager_from_config( for module in modules: module_hash[module.id] = module - return AuthManager(hass, store, provider_hash, module_hash) + manager = AuthManager(hass, store, provider_hash, module_hash) + manager.async_setup() + return manager class AuthManagerFlowManager(data_entry_flow.FlowManager): @@ -159,6 +169,21 @@ class AuthManager: self._mfa_modules = mfa_modules self.login_flow = AuthManagerFlowManager(hass, self) self._revoke_callbacks: dict[str, set[CALLBACK_TYPE]] = {} + self._expire_callback: CALLBACK_TYPE | None = None + self._remove_expired_job = HassJob( + self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback + ) + + @callback + def async_setup(self) -> None: + """Set up the auth manager.""" + hass = self.hass + hass.async_add_shutdown_job( + HassJob( + self._async_cancel_expiration_schedule, job_type=HassJobType.Callback + ) + ) + self._async_track_next_refresh_token_expiration() @property def auth_providers(self) -> list[AuthProvider]: @@ -424,6 +449,11 @@ class AuthManager: else: token_type = models.TOKEN_TYPE_NORMAL + if token_type is models.TOKEN_TYPE_NORMAL: + expire_at = time.time() + REFRESH_TOKEN_EXPIRATION + else: + expire_at = None + if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): raise ValueError( "System generated users can only have system type refresh tokens" @@ -455,6 +485,7 @@ class AuthManager: client_icon, token_type, access_token_expiration, + expire_at, credential, ) @@ -479,6 +510,38 @@ class AuthManager: for revoke_callback in callbacks: revoke_callback() + @callback + def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None: + """Remove expired refresh tokens.""" + now = time.time() + for token in self._store.async_get_refresh_tokens()[:]: + if (expire_at := token.expire_at) is not None and expire_at <= now: + self.async_remove_refresh_token(token) + self._async_track_next_refresh_token_expiration() + + @callback + def _async_track_next_refresh_token_expiration(self) -> None: + """Initialise all token expiration scheduled tasks.""" + next_expiration = time.time() + REFRESH_TOKEN_EXPIRATION + for token in self._store.async_get_refresh_tokens(): + if ( + expire_at := token.expire_at + ) is not None and expire_at < next_expiration: + next_expiration = expire_at + + self._expire_callback = async_track_point_in_utc_time( + self.hass, + self._remove_expired_job, + dt_util.utc_from_timestamp(next_expiration), + ) + + @callback + def _async_cancel_expiration_schedule(self) -> None: + """Cancel tracking of expired refresh tokens.""" + if self._expire_callback: + self._expire_callback() + self._expire_callback = None + @callback def _async_unregister( self, callbacks: set[CALLBACK_TYPE], callback_: CALLBACK_TYPE diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 6d63f9bfd50..983ba7da6a1 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import hmac +import itertools from logging import getLogger from typing import Any @@ -17,6 +18,7 @@ from .const import ( GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER, + REFRESH_TOKEN_EXPIRATION, ) from .permissions import system_policies from .permissions.models import PermissionLookup @@ -186,6 +188,7 @@ class AuthStore: client_icon: str | None = None, token_type: str = models.TOKEN_TYPE_NORMAL, access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, + expire_at: float | None = None, credential: models.Credentials | None = None, ) -> models.RefreshToken: """Create a new token for a user.""" @@ -194,6 +197,7 @@ class AuthStore: "client_id": client_id, "token_type": token_type, "access_token_expiration": access_token_expiration, + "expire_at": expire_at, "credential": credential, } if client_name: @@ -239,6 +243,15 @@ class AuthStore: return found + @callback + def async_get_refresh_tokens(self) -> list[models.RefreshToken]: + """Get all refresh tokens.""" + return list( + itertools.chain.from_iterable( + user.refresh_tokens.values() for user in self._users.values() + ) + ) + @callback def async_log_refresh_token_usage( self, refresh_token: models.RefreshToken, remote_ip: str | None = None @@ -246,9 +259,13 @@ class AuthStore: """Update refresh token last used information.""" refresh_token.last_used_at = dt_util.utcnow() refresh_token.last_used_ip = remote_ip + if refresh_token.expire_at: + refresh_token.expire_at = ( + refresh_token.last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION + ) self._async_schedule_save() - async def async_load(self) -> None: + async def async_load(self) -> None: # noqa: C901 """Load the users.""" if self._loaded: raise RuntimeError("Auth storage is already loaded") @@ -261,6 +278,8 @@ class AuthStore: perm_lookup = PermissionLookup(ent_reg, dev_reg) self._perm_lookup = perm_lookup + now_ts = dt_util.utcnow().timestamp() + if data is None or not isinstance(data, dict): self._set_defaults() return @@ -414,6 +433,14 @@ class AuthStore: else: last_used_at = None + if ( + expire_at := rt_dict.get("expire_at") + ) is None and token_type == models.TOKEN_TYPE_NORMAL: + if last_used_at: + expire_at = last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION + else: + expire_at = now_ts + REFRESH_TOKEN_EXPIRATION + token = models.RefreshToken( id=rt_dict["id"], user=users[rt_dict["user_id"]], @@ -430,6 +457,7 @@ class AuthStore: jwt_key=rt_dict["jwt_key"], last_used_at=last_used_at, last_used_ip=rt_dict.get("last_used_ip"), + expire_at=expire_at, version=rt_dict.get("version"), ) if "credential_id" in rt_dict: @@ -439,6 +467,8 @@ class AuthStore: self._groups = groups self._users = users + self._async_schedule_save() + @callback def _async_schedule_save(self) -> None: """Save users.""" @@ -503,6 +533,7 @@ class AuthStore: if refresh_token.last_used_at else None, "last_used_ip": refresh_token.last_used_ip, + "expire_at": refresh_token.expire_at, "credential_id": refresh_token.credential.id if refresh_token.credential else None, diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 5e17e752bdd..704f5d1d57c 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -3,6 +3,7 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) +REFRESH_TOKEN_EXPIRATION = timedelta(days=90).total_seconds() GROUP_ID_ADMIN = "system-admin" GROUP_ID_USER = "system-users" diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 574f0cc75c0..4cf94401478 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -117,6 +117,8 @@ class RefreshToken: last_used_at: datetime | None = attr.ib(default=None) last_used_ip: str | None = attr.ib(default=None) + expire_at: float | None = attr.ib(default=None) + credential: Credentials | None = attr.ib(default=None) version: str | None = attr.ib(default=__version__) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 778095388a8..858d4d082b1 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,12 +1,15 @@ """Tests for the auth store.""" import asyncio +from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.auth import auth_store from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util async def test_loading_no_group_data_format( @@ -267,3 +270,67 @@ async def test_loading_only_once(hass: HomeAssistant) -> None: mock_dev_registry.assert_called_once_with(hass) mock_load.assert_called_once_with() assert results[0] == results[1] + + +async def test_add_expire_at_property( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we correctly add expired_at property if not existing.""" + now = dt_util.utcnow() + with freeze_time(now): + hass_storage[auth_store.STORAGE_KEY] = { + "version": 1, + "data": { + "credentials": [], + "users": [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + }, + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": str(now - timedelta(days=10)), + "token": "some-token", + "user_id": "user-id", + "version": "1.2.3", + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id2", + "jwt_key": "some-key2", + "token": "some-token", + "user_id": "user-id", + }, + ], + }, + } + + store = auth_store.AuthStore(hass) + await store.async_load() + + users = await store.async_get_users() + + assert len(users[0].refresh_tokens) == 2 + token1, token2 = users[0].refresh_tokens.values() + assert token1.expire_at + assert token1.expire_at == now.timestamp() + timedelta(days=80).total_seconds() + assert token2.expire_at + assert token2.expire_at == now.timestamp() + timedelta(days=90).total_seconds() diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 5e08f5e3aeb..b561b17112b 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -26,6 +26,7 @@ from tests.common import ( CLIENT_ID, MockUser, async_capture_events, + async_fire_time_changed, ensure_auth_manager_loaded, flush_store, ) @@ -406,6 +407,8 @@ async def test_generating_system_user(hass: HomeAssistant) -> None: assert not user.local_only assert token is not None assert token.client_id is None + assert token.token_type == auth.models.TOKEN_TYPE_SYSTEM + assert token.expire_at is None await hass.async_block_till_done() assert len(events) == 1 @@ -421,6 +424,8 @@ async def test_generating_system_user(hass: HomeAssistant) -> None: assert user.local_only assert token is not None assert token.client_id is None + assert token.token_type == auth.models.TOKEN_TYPE_SYSTEM + assert token.expire_at is None await hass.async_block_till_done() assert len(events) == 2 @@ -474,6 +479,8 @@ async def test_refresh_token_with_specific_access_token_expiration( assert token is not None assert token.client_id == CLIENT_ID assert token.access_token_expiration == timedelta(days=100) + assert token.token_type == auth.models.TOKEN_TYPE_NORMAL + assert token.expire_at is not None async def test_refresh_token_type(hass: HomeAssistant) -> None: @@ -515,6 +522,7 @@ async def test_refresh_token_type_long_lived_access_token(hass: HomeAssistant) - assert token.client_name == "GPS LOGGER" assert token.client_icon == "mdi:home" assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + assert token.expire_at is None async def test_refresh_token_provider_validation(mock_hass) -> None: @@ -565,9 +573,9 @@ async def test_cannot_deactive_owner(mock_hass) -> None: await manager.async_deactivate_user(owner) -async def test_remove_refresh_token(mock_hass) -> None: +async def test_remove_refresh_token(hass: HomeAssistant) -> None: """Test that we can remove a refresh token.""" - manager = await auth.auth_manager_from_config(mock_hass, [], []) + manager = await auth.auth_manager_from_config(hass, [], []) user = MockUser().add_to_auth_manager(manager) refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) access_token = manager.async_create_access_token(refresh_token) @@ -578,6 +586,70 @@ async def test_remove_refresh_token(mock_hass) -> None: assert manager.async_validate_access_token(access_token) is None +async def test_remove_expired_refresh_token(hass: HomeAssistant) -> None: + """Test that expired refresh tokens are deleted.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + now = dt_util.utcnow() + with freeze_time(now): + refresh_token1 = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token1.expire_at + == now.timestamp() + timedelta(days=90).total_seconds() + ) + + with freeze_time(now + timedelta(days=30)): + async_fire_time_changed(hass, now + timedelta(days=30)) + refresh_token2 = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token2.expire_at + == now.timestamp() + timedelta(days=120).total_seconds() + ) + + with freeze_time(now + timedelta(days=89, hours=23)): + async_fire_time_changed(hass, now + timedelta(days=89, hours=23)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) + assert manager.async_get_refresh_token(refresh_token2.id) + + with freeze_time(now + timedelta(days=90, seconds=5)): + async_fire_time_changed(hass, now + timedelta(days=90, seconds=5)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) is None + assert manager.async_get_refresh_token(refresh_token2.id) + + with freeze_time(now + timedelta(days=120, seconds=5)): + async_fire_time_changed(hass, now + timedelta(days=120, seconds=5)) + await hass.async_block_till_done() + assert manager.async_get_refresh_token(refresh_token1.id) is None + assert manager.async_get_refresh_token(refresh_token2.id) is None + + +async def test_update_expire_at_refresh_token(hass: HomeAssistant) -> None: + """Test that expire at is updated when refresh token is used.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + now = dt_util.utcnow() + with freeze_time(now): + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert ( + refresh_token.expire_at + == now.timestamp() + timedelta(days=90).total_seconds() + ) + + with freeze_time(now + timedelta(days=30)): + async_fire_time_changed(hass, now + timedelta(days=30)) + await hass.async_block_till_done() + assert manager.async_create_access_token(refresh_token) + await hass.async_block_till_done() + assert ( + refresh_token.expire_at + == now.timestamp() + + timedelta(days=30).total_seconds() + + timedelta(days=90).total_seconds() + ) + + async def test_register_revoke_token_callback(mock_hass) -> None: """Test that a registered revoke token callback is called.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) From 7f56330e3b844f5ac3e723b901451eadc18aa744 Mon Sep 17 00:00:00 2001 From: John Hess Date: Wed, 24 Jan 2024 20:26:58 -0700 Subject: [PATCH 1011/1544] Bump thermopro-ble to 0.9.0 (#108820) --- CODEOWNERS | 4 +- .../components/thermopro/manifest.json | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/thermopro/__init__.py | 20 +++++ tests/components/thermopro/test_sensor.py | 84 ++++++++++++++++++- 6 files changed, 109 insertions(+), 7 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index dae1d0f1806..339d4aca6ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1351,8 +1351,8 @@ build.json @home-assistant/supervisor /homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco -/homeassistant/components/thermopro/ @bdraco -/tests/components/thermopro/ @bdraco +/homeassistant/components/thermopro/ @bdraco @h3ss +/tests/components/thermopro/ @bdraco @h3ss /homeassistant/components/thethingsnetwork/ @fabaff /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 237cd39fb66..817df22d6e1 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -15,10 +15,10 @@ "connectable": false } ], - "codeowners": ["@bdraco"], + "codeowners": ["@bdraco", "@h3ss"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.8.0"] + "requirements": ["thermopro-ble==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67189ca9044..848c6948490 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2672,7 +2672,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.8.0 +thermopro-ble==0.9.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c0bffd42d0..4c5cbe5cb8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2028,7 +2028,7 @@ tessie-api==0.0.9 thermobeacon-ble==0.6.2 # homeassistant.components.thermopro -thermopro-ble==0.8.0 +thermopro-ble==0.9.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index a7dd5fcf9c5..f66b608f6d3 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -23,3 +23,23 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( service_data={}, source="local", ) + +TP962R_SERVICE_INFO = BluetoothServiceInfo( + name="TP962R (0000)", + manufacturer_data={14081: b"\x00;\x0b7\x00"}, + service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], + address="aa:bb:cc:dd:ee:ff", + rssi=-52, + service_data={}, + source="local", +) + +TP962R_SERVICE_INFO_2 = BluetoothServiceInfo( + name="TP962R (0000)", + manufacturer_data={17152: b"\x00\x17\nC\x00", 14081: b"\x00;\x0b7\x00"}, + service_uuids=["72fbb631-6f6b-d1ba-db55-2ee6fdd942bd"], + address="aa:bb:cc:dd:ee:ff", + rssi=-52, + service_data={}, + source="local", +) diff --git a/tests/components/thermopro/test_sensor.py b/tests/components/thermopro/test_sensor.py index bead3a53dea..d754991f3d8 100644 --- a/tests/components/thermopro/test_sensor.py +++ b/tests/components/thermopro/test_sensor.py @@ -4,12 +4,94 @@ from homeassistant.components.thermopro.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import TP357_SERVICE_INFO +from . import TP357_SERVICE_INFO, TP962R_SERVICE_INFO, TP962R_SERVICE_INFO_2 from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info +async def test_sensors_tp962r(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info(hass, TP962R_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_2_internal_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "25" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 2 Internal Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_2_ambient_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "25" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 2 Ambient Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp962r_0000_probe_2_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "100" + assert ( + battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP962R (0000) Probe 2 Battery" + ) + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + inject_bluetooth_service_info(hass, TP962R_SERVICE_INFO_2) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 6 + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_1_internal_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "37" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 1 Internal Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.tp962r_0000_probe_1_ambient_temperature") + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "37" + assert ( + temp_sensor_attributes[ATTR_FRIENDLY_NAME] + == "TP962R (0000) Probe 1 Ambient Temperature" + ) + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + battery_sensor = hass.states.get("sensor.tp962r_0000_probe_1_battery") + battery_sensor_attributes = battery_sensor.attributes + assert battery_sensor.state == "82.0" + assert ( + battery_sensor_attributes[ATTR_FRIENDLY_NAME] == "TP962R (0000) Probe 1 Battery" + ) + assert battery_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( From d588ec820286602e0b00fd17b8c15c69df6da4d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 17:29:11 -1000 Subject: [PATCH 1012/1544] Fix ESPHome not fully removing entities when entity info changes (#108823) --- homeassistant/components/esphome/entity.py | 91 ++++++++----- .../components/esphome/entry_data.py | 12 +- tests/components/esphome/conftest.py | 5 +- tests/components/esphome/test_entity.py | 124 +++++++++++++++++- 4 files changed, 196 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 1abf60be18a..14602077a94 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -37,6 +37,51 @@ _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) +@callback +def async_static_info_updated( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + platform: entity_platform.EntityPlatform, + async_add_entities: AddEntitiesCallback, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], + infos: list[EntityInfo], +) -> None: + """Update entities of this platform when entities are listed.""" + current_infos = entry_data.info[info_type] + new_infos: dict[int, EntityInfo] = {} + add_entities: list[_EntityT] = [] + + for info in infos: + if not current_infos.pop(info.key, None): + # Create new entity + entity = entity_type(entry_data, platform.domain, info, state_type) + add_entities.append(entity) + new_infos[info.key] = info + + # Anything still in current_infos is now gone + if current_infos: + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None + hass.async_create_task( + entry_data.async_remove_entities( + hass, current_infos.values(), device_info.mac_address + ) + ) + + # Then update the actual info + entry_data.info[info_type] = new_infos + + if new_infos: + entry_data.async_update_entity_infos(new_infos.values()) + + if add_entities: + # Add entities to Home Assistant + async_add_entities(add_entities) + + async def platform_async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -55,39 +100,21 @@ async def platform_async_setup_entry( entry_data.info[info_type] = {} entry_data.state.setdefault(state_type, {}) platform = entity_platform.async_get_current_platform() - - @callback - def async_list_entities(infos: list[EntityInfo]) -> None: - """Update entities of this platform when entities are listed.""" - current_infos = entry_data.info[info_type] - new_infos: dict[int, EntityInfo] = {} - add_entities: list[_EntityT] = [] - - for info in infos: - if not current_infos.pop(info.key, None): - # Create new entity - entity = entity_type(entry_data, platform.domain, info, state_type) - add_entities.append(entity) - new_infos[info.key] = info - - # Anything still in current_infos is now gone - if current_infos: - hass.async_create_task( - entry_data.async_remove_entities(current_infos.values()) - ) - - # Then update the actual info - entry_data.info[info_type] = new_infos - - if new_infos: - entry_data.async_update_entity_infos(new_infos.values()) - - if add_entities: - # Add entities to Home Assistant - async_add_entities(add_entities) - + on_static_info_update = functools.partial( + async_static_info_updated, + hass, + entry_data, + platform, + async_add_entities, + info_type, + entity_type, + state_type, + ) entry_data.cleanup_callbacks.append( - entry_data.async_register_static_info_callback(info_type, async_list_entities) + entry_data.async_register_static_info_callback( + info_type, + on_static_info_update, + ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 723141a94a2..940b1560ba4 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -243,8 +243,18 @@ class RuntimeEntryData: """Unsubscribe to assist pipeline updates.""" self.assist_pipeline_update_callbacks.remove(update_callback) - async def async_remove_entities(self, static_infos: Iterable[EntityInfo]) -> None: + async def async_remove_entities( + self, hass: HomeAssistant, static_infos: Iterable[EntityInfo], mac: str + ) -> None: """Schedule the removal of an entity.""" + # Remove from entity registry first so the entity is fully removed + ent_reg = er.async_get(hass) + for info in static_infos: + if entry := ent_reg.async_get_entity_id( + INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info) + ): + ent_reg.async_remove(entry) + callbacks: list[Coroutine[Any, Any, None]] = [] for static_info in static_infos: callback_key = (type(static_info), static_info.key) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 8c46fac08d4..ac9d9235917 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -177,9 +177,10 @@ async def mock_dashboard(hass): class MockESPHomeDevice: """Mock an esphome device.""" - def __init__(self, entry: MockConfigEntry) -> None: + def __init__(self, entry: MockConfigEntry, client: APIClient) -> None: """Init the mock.""" self.entry = entry + self.client = client self.state_callback: Callable[[EntityState], None] self.service_call_callback: Callable[[HomeassistantServiceCall], None] self.on_disconnect: Callable[[bool], None] @@ -258,7 +259,7 @@ async def _mock_generic_device_entry( ) entry.add_to_hass(hass) - mock_device = MockESPHomeDevice(entry) + mock_device = MockESPHomeDevice(entry, mock_client) default_device_info = { "name": "test", diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 9a5cb441f28..03fd21c32f8 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -1,6 +1,7 @@ """Test ESPHome binary sensors.""" from collections.abc import Awaitable, Callable from typing import Any +from unittest.mock import AsyncMock from aioesphomeapi import ( APIClient, @@ -21,6 +22,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import MockESPHomeDevice @@ -34,7 +36,8 @@ async def test_entities_removed( Awaitable[MockESPHomeDevice], ], ) -> None: - """Test a generic binary_sensor where has_state is false.""" + """Test entities are removed when static info changes.""" + ent_reg = er.async_get(hass) entity_info = [ BinarySensorInfo( object_id="mybinary_sensor", @@ -80,6 +83,8 @@ async def test_entities_removed( assert state.attributes[ATTR_RESTORED] is True state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True entity_info = [ @@ -106,11 +111,128 @@ async def test_entities_removed( assert state.state == STATE_ON state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is None + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 +async def test_entities_removed_after_reload( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test entities and their registry entry are removed when static info changes after a reload.""" + ent_reg = er.async_get(hass) + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + BinarySensorInfo( + object_id="mybinary_sensor_to_be_removed", + key=2, + name="my binary_sensor to be removed", + unique_id="mybinary_sensor_to_be_removed", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + entry = mock_device.entry + entry_id = entry.entry_id + storage_key = f"esphome.{entry_id}" + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.state == STATE_ON + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 + + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert state.attributes[ATTR_RESTORED] is True + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 + + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert ATTR_RESTORED not in state.attributes + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is not None + assert ATTR_RESTORED not in state.attributes + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is not None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + mock_device.client.list_entities_services = AsyncMock( + return_value=(entity_info, user_service) + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_device.entry.entry_id == entry_id + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert state is None + + await hass.async_block_till_done() + + reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + assert reg_entry is None + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + async def test_entity_info_object_ids( hass: HomeAssistant, mock_client: APIClient, From c01e8288c1822e79238a4c0835e1d08194a3fb7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 17:34:52 -1000 Subject: [PATCH 1013/1544] Convert http auth internals to normal functions (#108815) Nothing was being awaited here anymore, these can be normal functions --- homeassistant/components/http/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 98d17637f89..99d38bf582e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -135,7 +135,8 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: hass.data[STORAGE_KEY] = refresh_token.id - async def async_validate_auth_header(request: Request) -> bool: + @callback + def async_validate_auth_header(request: Request) -> bool: """Test authorization header against access token. Basic auth_type is legacy code, should be removed with api_password. @@ -163,7 +164,8 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True - async def async_validate_signed_request(request: Request) -> bool: + @callback + def async_validate_signed_request(request: Request) -> bool: """Validate a signed request.""" if (secret := hass.data.get(DATA_SIGN_SECRET)) is None: return False @@ -205,7 +207,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: """Authenticate as middleware.""" authenticated = False - if hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( + if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( request ): authenticated = True @@ -216,7 +218,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: elif ( request.method == "GET" and SIGN_QUERY_PARAM in request.query_string - and await async_validate_signed_request(request) + and async_validate_signed_request(request) ): authenticated = True auth_type = "signed request" From 0628546a0e53e96f4e8694e337c6bf50a824b3d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 17:50:06 -1000 Subject: [PATCH 1014/1544] Add basic tests for powerview scenes (#108818) --- .../hunterdouglas_powerview/__init__.py | 2 + .../hunterdouglas_powerview/conftest.py | 47 +++++++++++++++++++ .../fixtures/scenes.json | 25 ++++++++++ .../test_config_flow.py | 6 ++- .../hunterdouglas_powerview/test_scene.py | 36 ++++++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/components/hunterdouglas_powerview/conftest.py create mode 100644 tests/components/hunterdouglas_powerview/fixtures/scenes.json create mode 100644 tests/components/hunterdouglas_powerview/test_scene.py diff --git a/tests/components/hunterdouglas_powerview/__init__.py b/tests/components/hunterdouglas_powerview/__init__.py index 034d845b110..1cab5f9071e 100644 --- a/tests/components/hunterdouglas_powerview/__init__.py +++ b/tests/components/hunterdouglas_powerview/__init__.py @@ -1 +1,3 @@ """Tests for the Hunter Douglas PowerView integration.""" + +MOCK_MAC = "AA::BB::CC::DD::EE::FF" diff --git a/tests/components/hunterdouglas_powerview/conftest.py b/tests/components/hunterdouglas_powerview/conftest.py new file mode 100644 index 00000000000..e4e56abd56c --- /dev/null +++ b/tests/components/hunterdouglas_powerview/conftest.py @@ -0,0 +1,47 @@ +"""Tests for the Hunter Douglas PowerView integration.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(scope="session") +def powerview_userdata(): + """Return the userdata fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/userdata.json")) + + +@pytest.fixture(scope="session") +def powerview_fwversion(): + """Return the fwversion fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/fwversion.json")) + + +@pytest.fixture(scope="session") +def powerview_scenes(): + """Return the scenes fixture.""" + return json.loads(load_fixture("hunterdouglas_powerview/scenes.json")) + + +@pytest.fixture +def mock_powerview_v2_hub(powerview_userdata, powerview_fwversion, powerview_scenes): + """Mock a Powerview v2 hub.""" + with patch( + "homeassistant.components.hunterdouglas_powerview.UserData.get_resources", + return_value=powerview_userdata, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Rooms.get_resources", + return_value={"roomData": []}, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Scenes.get_resources", + return_value=powerview_scenes, + ), patch( + "homeassistant.components.hunterdouglas_powerview.Shades.get_resources", + return_value={"shadeData": []}, + ), patch( + "homeassistant.components.hunterdouglas_powerview.ApiEntryPoint", + return_value=powerview_fwversion, + ): + yield diff --git a/tests/components/hunterdouglas_powerview/fixtures/scenes.json b/tests/components/hunterdouglas_powerview/fixtures/scenes.json new file mode 100644 index 00000000000..7a9f7d9e8eb --- /dev/null +++ b/tests/components/hunterdouglas_powerview/fixtures/scenes.json @@ -0,0 +1,25 @@ +{ + "sceneIds": [46274, 21015], + "sceneData": [ + { + "roomId": 12538, + "name": "one", + "colorId": 12, + "iconId": 0, + "networkNumber": 250, + "id": 46274, + "order": 0, + "hkAssist": false + }, + { + "roomId": 12538, + "name": "two", + "colorId": 14, + "iconId": 0, + "networkNumber": 231, + "id": 21015, + "order": 1, + "hkAssist": false + } + ] +} diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index f39b4c1f68e..0511e7bf821 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the Logitech Harmony Hub config flow.""" +"""Test the Hunter Douglas Powerview config flow.""" import asyncio from ipaddress import ip_address import json @@ -11,6 +11,8 @@ from homeassistant.components import dhcp, zeroconf from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.core import HomeAssistant +from . import MOCK_MAC + from tests.common import MockConfigEntry, load_fixture ZEROCONF_HOST = "1.2.3.4" @@ -20,7 +22,7 @@ HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( hostname="mock_hostname", name="Hunter Douglas Powerview Hub._hap._tcp.local.", port=None, - properties={zeroconf.ATTR_PROPERTIES_ID: "AA::BB::CC::DD::EE::FF"}, + properties={zeroconf.ATTR_PROPERTIES_ID: MOCK_MAC}, type="mock_type", ) diff --git a/tests/components/hunterdouglas_powerview/test_scene.py b/tests/components/hunterdouglas_powerview/test_scene.py new file mode 100644 index 00000000000..b4dd4491a72 --- /dev/null +++ b/tests/components/hunterdouglas_powerview/test_scene.py @@ -0,0 +1,36 @@ +"""Test the Hunter Douglas Powerview scene platform.""" +from unittest.mock import patch + +from homeassistant.components.hunterdouglas_powerview.const import DOMAIN +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_scenes(hass: HomeAssistant, mock_powerview_v2_hub: None) -> None: + """Test the scenes.""" + entry = MockConfigEntry(domain=DOMAIN, data={"host": "1.2.3.4"}, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("scene.alexanderhd_one").state == STATE_UNKNOWN + assert hass.states.get("scene.alexanderhd_two").state == STATE_UNKNOWN + + with patch( + "homeassistant.components.hunterdouglas_powerview.scene.PvScene.activate" + ) as mock_activate: + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "scene.alexanderhd_one"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_activate.assert_called_once() From 195ef6d7692fc1ffa1b534101db932cc92fa1d0e Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 25 Jan 2024 08:38:57 +0100 Subject: [PATCH 1015/1544] Fix lights reporting unsupported colormodes in deCONZ (#108812) --- homeassistant/components/deconz/light.py | 4 ++++ tests/components/deconz/test_light.py | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 044c9bf203b..27038a07ac3 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -212,6 +212,10 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): color_mode = ColorMode.BRIGHTNESS else: color_mode = ColorMode.ONOFF + if color_mode not in self._attr_supported_color_modes: + # Some lights controlled by ZigBee scenes can get unsupported color mode + return self._attr_color_mode + self._attr_color_mode = color_mode return color_mode @property diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index d38c65526c2..d1d5b983956 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1179,9 +1179,19 @@ async def test_non_color_light_reports_color( await setup_deconz_integration(hass, aioclient_mock) assert len(hass.states.async_all()) == 3 + assert hass.states.get("light.group").attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ] + assert ( + hass.states.get("light.group").attributes[ATTR_COLOR_MODE] + == ColorMode.COLOR_TEMP + ) assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] == 250 - # Updating a scene will return a faulty color value for a non-color light causing an exception in hs_color + # Updating a scene will return a faulty color value + # for a non-color light causing an exception in hs_color event_changed_light = { "e": "changed", "id": "1", @@ -1200,7 +1210,9 @@ async def test_non_color_light_reports_color( await mock_deconz_websocket(data=event_changed_light) await hass.async_block_till_done() - # Bug is fixed if we reach this point, but device won't have neither color temp nor color + assert hass.states.get("light.group").attributes[ATTR_COLOR_MODE] == ColorMode.XY + # Bug is fixed if we reach this point + # device won't have neither color temp nor color with pytest.raises(AssertionError): assert hass.states.get("light.group").attributes.get(ATTR_COLOR_TEMP) is None assert hass.states.get("light.group").attributes.get(ATTR_HS_COLOR) is None From c9bef39c9aaea36c46c74e260076fc0224204820 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 25 Jan 2024 08:47:05 +0100 Subject: [PATCH 1016/1544] Update pytedee_async to 0.2.12 (#108800) --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 2a29b2610b3..09a46441e66 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.11"] + "requirements": ["pytedee-async==0.2.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 848c6948490..64f78e8c5a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2157,7 +2157,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.11 +pytedee-async==0.2.12 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c5cbe5cb8b..96dc2da315d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1663,7 +1663,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.11 +pytedee-async==0.2.12 # homeassistant.components.motionmount python-MotionMount==0.3.1 From fabf8802f5b5db81ff790a42d661dd00099d250f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 25 Jan 2024 09:15:38 +0100 Subject: [PATCH 1017/1544] Fix matter color modes (#108804) * Fix matter light color modes * Make onoff light fixture only onoff * Make dimmable light only a dimmable light * Make color temp light fixture only a color temp light --- homeassistant/components/matter/light.py | 64 ++++++++--------- .../nodes/color-temperature-light.json | 34 +--------- .../matter/fixtures/nodes/dimmable-light.json | 44 +----------- .../matter/fixtures/nodes/onoff-light.json | 68 +------------------ 4 files changed, 34 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 43c47046162..7e6f42f44b4 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -14,6 +14,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityDescription, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -53,30 +54,9 @@ class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" entity_description: LightEntityDescription - - @property - def supports_color(self) -> bool: - """Return if the device supports color control.""" - if not self._attr_supported_color_modes: - return False - return ( - ColorMode.HS in self._attr_supported_color_modes - or ColorMode.XY in self._attr_supported_color_modes - ) - - @property - def supports_color_temperature(self) -> bool: - """Return if the device supports color temperature control.""" - if not self._attr_supported_color_modes: - return False - return ColorMode.COLOR_TEMP in self._attr_supported_color_modes - - @property - def supports_brightness(self) -> bool: - """Return if the device supports bridghtness control.""" - if not self._attr_supported_color_modes: - return False - return ColorMode.BRIGHTNESS in self._attr_supported_color_modes + _supports_brightness = False + _supports_color = False + _supports_color_temperature = False async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: """Set xy color.""" @@ -283,7 +263,7 @@ class MatterLight(MatterEntity, LightEntity): ): await self._set_color_temp(color_temp) - if brightness is not None and self.supports_brightness: + if brightness is not None and self._supports_brightness: await self._set_brightness(brightness) return @@ -302,12 +282,13 @@ class MatterLight(MatterEntity, LightEntity): """Update from device.""" if self._attr_supported_color_modes is None: # work out what (color)features are supported - supported_color_modes: set[ColorMode] = set() + supported_color_modes = {ColorMode.ONOFF} # brightness support if self._entity_info.endpoint.has_attribute( None, clusters.LevelControl.Attributes.CurrentLevel ): supported_color_modes.add(ColorMode.BRIGHTNESS) + self._supports_brightness = True # colormode(s) if self._entity_info.endpoint.has_attribute( None, clusters.ColorControl.Attributes.ColorMode @@ -325,19 +306,23 @@ class MatterLight(MatterEntity, LightEntity): & clusters.ColorControl.Bitmaps.ColorCapabilities.kHueSaturationSupported ): supported_color_modes.add(ColorMode.HS) + self._supports_color = True if ( capabilities & clusters.ColorControl.Bitmaps.ColorCapabilities.kXYAttributesSupported ): supported_color_modes.add(ColorMode.XY) + self._supports_color = True if ( capabilities & clusters.ColorControl.Bitmaps.ColorCapabilities.kColorTemperatureSupported ): supported_color_modes.add(ColorMode.COLOR_TEMP) + self._supports_color_temperature = True + supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes LOGGER.debug( @@ -347,8 +332,17 @@ class MatterLight(MatterEntity, LightEntity): ) # set current values + self._attr_is_on = self.get_matter_attribute_value( + clusters.OnOff.Attributes.OnOff + ) - if self.supports_color: + if self._supports_brightness: + self._attr_brightness = self._get_brightness() + + if self._supports_color_temperature: + self._attr_color_temp = self._get_color_temperature() + + if self._supports_color: self._attr_color_mode = color_mode = self._get_color_mode() if ( ColorMode.HS in self._attr_supported_color_modes @@ -360,16 +354,12 @@ class MatterLight(MatterEntity, LightEntity): and color_mode == ColorMode.XY ): self._attr_xy_color = self._get_xy_color() - - if self.supports_color_temperature: - self._attr_color_temp = self._get_color_temperature() - - self._attr_is_on = self.get_matter_attribute_value( - clusters.OnOff.Attributes.OnOff - ) - - if self.supports_brightness: - self._attr_brightness = self._get_brightness() + elif self._attr_color_temp is not None: + self._attr_color_mode = ColorMode.COLOR_TEMP + elif self._attr_brightness is not None: + self._attr_color_mode = ColorMode.BRIGHTNESS + else: + self._attr_color_mode = ColorMode.ONOFF # Discovery schema(s) to map Matter Attributes to HA entities diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 45d1c18635c..370e028e721 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -206,47 +206,17 @@ "1": 1 } ], - "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], + "1/29/1": [3, 4, 6, 8, 29, 80, 768], "1/29/2": [], "1/29/3": [], "1/29/65533": 1, "1/29/65528": [], "1/29/65529": [], "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65533], - "1/768/0": 36, - "1/768/3": 26515, - "1/768/4": 25657, "1/768/7": 284, "1/768/1": 51, - "1/768/16384": 9305, "1/768/8": 0, - "1/768/15": 0, - "1/768/16385": 0, - "1/768/16386": { - "TLVValue": null, - "Reason": null - }, - "1/768/16387": { - "TLVValue": null, - "Reason": null - }, - "1/768/16388": { - "TLVValue": null, - "Reason": null - }, - "1/768/16389": { - "TLVValue": null, - "Reason": null - }, - "1/768/16390": { - "TLVValue": null, - "Reason": null - }, - "1/768/16394": 25, - "1/768/16": { - "TLVValue": null, - "Reason": null - }, + "1/768/16394": 16, "1/768/16395": 153, "1/768/16396": 500, "1/768/16397": 250, diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index 7ccc3eef3af..74f132a88a9 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -358,54 +358,14 @@ "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "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], - "1/768/0": 0, - "1/768/1": 0, - "1/768/2": 0, - "1/768/3": 24939, - "1/768/4": 24701, - "1/768/7": 0, - "1/768/8": 2, - "1/768/15": 0, - "1/768/16": 0, - "1/768/16384": 0, - "1/768/16385": 2, - "1/768/16386": 0, - "1/768/16387": 0, - "1/768/16388": 25, - "1/768/16389": 8960, - "1/768/16390": 0, - "1/768/16394": 31, - "1/768/16395": 0, - "1/768/16396": 65279, - "1/768/16397": 0, - "1/768/16400": 0, - "1/768/65532": 31, - "1/768/65533": 5, - "1/768/65528": [], - "1/768/65529": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ], - "1/768/65531": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, - 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, - 65533 - ], - "1/1030/0": 0, - "1/1030/1": 0, - "1/1030/2": 1, - "1/1030/65532": 0, - "1/1030/65533": 3, - "1/1030/65528": [], - "1/1030/65529": [], - "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] }, "available": true, "attribute_subscriptions": [] diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index eed404ff85d..15390129dd2 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -330,82 +330,20 @@ "1/6/65531": [ 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 ], - "1/8/0": 52, - "1/8/1": 0, - "1/8/2": 1, - "1/8/3": 254, - "1/8/4": 0, - "1/8/5": 0, - "1/8/6": 0, - "1/8/15": 0, - "1/8/16": 0, - "1/8/17": null, - "1/8/18": 0, - "1/8/19": 0, - "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, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, - 65532, 65533 - ], "1/29/0": [ { - "0": 257, + "0": 256, "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/1": [3, 4, 6, 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], - "1/768/0": 0, - "1/768/1": 0, - "1/768/2": 0, - "1/768/3": 24939, - "1/768/4": 24701, - "1/768/7": 0, - "1/768/8": 2, - "1/768/15": 0, - "1/768/16": 0, - "1/768/16384": 0, - "1/768/16385": 2, - "1/768/16386": 0, - "1/768/16387": 0, - "1/768/16388": 25, - "1/768/16389": 8960, - "1/768/16390": 0, - "1/768/16394": 31, - "1/768/16395": 0, - "1/768/16396": 65279, - "1/768/16397": 0, - "1/768/16400": 0, - "1/768/65532": 31, - "1/768/65533": 5, - "1/768/65528": [], - "1/768/65529": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ], - "1/768/65531": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, - 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, - 65533 - ], - "1/1030/0": 0, - "1/1030/1": 0, - "1/1030/2": 1, - "1/1030/65532": 0, - "1/1030/65533": 3, - "1/1030/65528": [], - "1/1030/65529": [], - "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] }, "available": true, "attribute_subscriptions": [] From da7d2ef228863fb535d6b765ad957acd8f25c8f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Jan 2024 09:46:22 +0100 Subject: [PATCH 1018/1544] Fix light color mode in zwave_js (#108783) --- homeassistant/components/zwave_js/light.py | 41 +++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 8ba50c15e02..2b286240aa3 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -151,7 +151,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): add_to_watched_value_ids=False, ) - self._calculate_color_values() + self._calculate_color_support() if self._supports_rgbw: self._supported_color_modes.add(ColorMode.RGBW) elif self._supports_color: @@ -160,6 +160,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: self._supported_color_modes.add(ColorMode.BRIGHTNESS) + self._calculate_color_values() # Entity class attributes self.supports_brightness_transition = bool( @@ -374,8 +375,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self.async_write_ha_state() @callback - def _calculate_color_values(self) -> None: - """Calculate light colors.""" + def _get_color_values(self) -> tuple[Value | None, ...]: + """Get light colors.""" # NOTE: We lookup all values here (instead of relying on the multicolor one) # to find out what colors are supported # as this is a simple lookup by key, this not heavy @@ -404,6 +405,30 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): CommandClass.SWITCH_COLOR, value_property_key=ColorComponent.COLD_WHITE.value, ) + return (red_val, green_val, blue_val, ww_val, cw_val) + + @callback + def _calculate_color_support(self) -> None: + """Calculate light colors.""" + (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() + # RGB support + if red_val and green_val and blue_val: + self._supports_color = True + # color temperature support + if ww_val and cw_val: + self._supports_color_temp = True + # only one white channel (warm white) = rgbw support + elif red_val and green_val and blue_val and ww_val: + self._supports_rgbw = True + # only one white channel (cool white) = rgbw support + elif cw_val: + self._supports_rgbw = True + + @callback + def _calculate_color_values(self) -> None: + """Calculate light colors.""" + (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() + # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 combined_color_val = self.get_zwave_value( @@ -416,8 +441,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): else: multi_color = {} - # Default: Brightness (no color) - self._color_mode = ColorMode.BRIGHTNESS + # Default: Brightness (no color) or Unknown + if self.supported_color_modes == {ColorMode.BRIGHTNESS}: + self._color_mode = ColorMode.BRIGHTNESS + else: + self._color_mode = ColorMode.UNKNOWN # RGB support if red_val and green_val and blue_val: @@ -425,7 +453,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) - self._supports_color = True if None not in (red, green, blue): # convert to HS self._hs_color = color_util.color_RGB_to_hs(red, green, blue) @@ -434,7 +461,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # color temperature support if ww_val and cw_val: - self._supports_color_temp = True warm_white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) cold_white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) # Calculate color temps based on whites @@ -449,7 +475,6 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._color_temp = None # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: - self._supports_rgbw = True white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw From 82d21136bda1e35698d03506d5178368b983ea02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 22:49:27 -1000 Subject: [PATCH 1019/1544] Do not try to cleanup invalid config entries without an AccessoryPairingID (#108830) --- .../homekit_controller/config_flow.py | 12 ++++-- .../homekit_controller/test_config_flow.py | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 592a2301294..7f0f288400d 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -272,8 +272,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably - # invalid. Remove it automatically. - if not paired and existing_entry: + # invalid. Remove it automatically if it has an accessory pairing id + # (which means it was paired with us at some point) and was not + # ignored by the user. + if ( + not paired + and existing_entry + and (accessory_pairing_id := existing_entry.data.get("AccessoryPairingID")) + ): if self.controller is None: await self._async_setup_controller() @@ -282,7 +288,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assert self.controller pairing = self.controller.load_pairing( - existing_entry.data["AccessoryPairingID"], dict(existing_entry.data) + accessory_pairing_id, dict(existing_entry.data) ) try: diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 3412e41aa17..8da6290d914 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -12,7 +12,7 @@ from aiohomekit.model.services import ServicesTypes from bleak.exc import BleakError import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES @@ -485,6 +485,44 @@ async def test_discovery_invalid_config_entry(hass: HomeAssistant, controller) - assert result["type"] == "form" +async def test_discovery_ignored_config_entry(hass: HomeAssistant, controller) -> None: + """There is already a config entry but it is ignored.""" + pairing = await controller.add_paired_device(Accessories(), "00:00:00:00:00:00") + + MockConfigEntry( + domain="homekit_controller", + data={}, + unique_id="00:00:00:00:00:00", + source=config_entries.SOURCE_IGNORE, + ).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Device is discovered + with patch.object( + pairing, + "list_accessories_and_characteristics", + side_effect=AuthenticationError("Invalid pairing keys"), + ): + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Entry is still ignored + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 1 + + # We should abort since there is no accessory id in the data + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_discovery_already_configured(hass: HomeAssistant, controller) -> None: """Already configured.""" entry = MockConfigEntry( From 7efc82b14c89fa6f7c22cc80b729ecd693da5f27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jan 2024 10:36:13 +0100 Subject: [PATCH 1020/1544] Bump dorny/paths-filter from 2.11.1 to 2.12.0 (#108826) 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 45a100092d3..f2ccc256000 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -103,7 +103,7 @@ jobs: echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v2.12.0 id: core with: filters: .core_files.yaml @@ -118,7 +118,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v2.12.0 id: integrations with: filters: .integration_paths.yaml From 3965f20526aae828c2135e06b4f07bf2dc62d9bf Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 25 Jan 2024 10:50:01 +0100 Subject: [PATCH 1021/1544] Bump python-kasa to 0.6.1 (#108831) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 4873e6db081..0c76b068f59 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -205,5 +205,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.6.0.1"] + "requirements": ["python-kasa[speedups]==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64f78e8c5a8..c5b3a52ccea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.0.1 +python-kasa[speedups]==0.6.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96dc2da315d..1156ba311a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1693,7 +1693,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.0.1 +python-kasa[speedups]==0.6.1 # homeassistant.components.matter python-matter-server==5.1.1 From c54b65fdf0d5f87be2be9fab45038b386c3d5233 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Thu, 25 Jan 2024 11:12:03 +0100 Subject: [PATCH 1022/1544] Add 'last_reset' for 'total' state_class template sensor (#100806) * Add last_reset to trigger based template sensors * Add last_reset to state based template sensors * CI check fixes * Add pytests * Add test cases for last_reset datetime parsing * Add test for static last_reset value * Fix ruff-format --- homeassistant/components/template/sensor.py | 58 +++++- tests/components/template/test_sensor.py | 209 +++++++++++++++++++- 2 files changed, 264 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index e757f561a7e..3a3d0682805 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -2,11 +2,13 @@ from __future__ import annotations from datetime import date, datetime +import logging from typing import Any import voluptuous as vol from homeassistant.components.sensor import ( + ATTR_LAST_RESET, CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, DOMAIN as SENSOR_DOMAIN, @@ -15,6 +17,7 @@ from homeassistant.components.sensor import ( RestoreSensor, SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry @@ -41,6 +44,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import ( @@ -63,14 +67,29 @@ LEGACY_FIELDS = { } -SENSOR_SCHEMA = ( +def validate_last_reset(val): + """Run extra validation checks.""" + if ( + val.get(ATTR_LAST_RESET) is not None + and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL + ): + raise vol.Invalid( + "last_reset is only valid for template sensors with state_class 'total'" + ) + + return val + + +SENSOR_SCHEMA = vol.All( vol.Schema( { vol.Required(CONF_STATE): cv.template, + vol.Optional(ATTR_LAST_RESET): cv.template, } ) .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), + validate_last_reset, ) @@ -138,6 +157,8 @@ PLATFORM_SCHEMA = vol.All( extra_validation_checks, ) +_LOGGER = logging.getLogger(__name__) + @callback def _async_create_template_tracking_entities( @@ -236,6 +257,9 @@ class SensorTemplate(TemplateEntity, SensorEntity): self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) self._template: template.Template = config[CONF_STATE] + self._attr_last_reset_template: None | template.Template = config.get( + ATTR_LAST_RESET + ) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass @@ -247,9 +271,20 @@ class SensorTemplate(TemplateEntity, SensorEntity): self.add_template_attribute( "_attr_native_value", self._template, None, self._update_state ) + if self._attr_last_reset_template is not None: + self.add_template_attribute( + "_attr_last_reset", + self._attr_last_reset_template, + cv.datetime, + self._update_last_reset, + ) super()._async_setup_templates() + @callback + def _update_last_reset(self, result): + self._attr_last_reset = result + @callback def _update_state(self, result): super()._update_state(result) @@ -283,6 +318,13 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + + if (last_reset_template := config.get(ATTR_LAST_RESET)) is not None: + if last_reset_template.is_static: + self._static_rendered[ATTR_LAST_RESET] = last_reset_template.template + else: + self._to_render_simple.append(ATTR_LAST_RESET) + self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -310,6 +352,18 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Process new data.""" super()._process_data() + # Update last_reset + if ATTR_LAST_RESET in self._rendered: + parsed_timestamp = dt_util.parse_datetime(self._rendered[ATTR_LAST_RESET]) + if parsed_timestamp is None: + _LOGGER.warning( + "%s rendered invalid timestamp for last_reset attribute: %s", + self.entity_id, + self._rendered.get(ATTR_LAST_RESET), + ) + else: + self._attr_last_reset = parsed_timestamp + if ( state := self._rendered.get(CONF_STATE) ) is None or self.device_class not in ( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index d25f638cfdb..314218fc849 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,6 +1,6 @@ """The test for the Template sensor platform.""" from asyncio import Event -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import ANY, patch import pytest @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.bootstrap import async_from_config_dict from homeassistant.components import sensor, template +from homeassistant.components.template.sensor import TriggerSensorEntity from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_ICON, @@ -1456,6 +1457,212 @@ async def test_trigger_entity_device_class_errors_works(hass: HomeAssistant) -> assert ts_state.state == STATE_UNKNOWN +async def test_entity_last_reset_total_increasing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset is disallowed for total_increasing state_class.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "TotalIncreasing entity", + "state": "{{ 0 }}", + "state_class": "total_increasing", + "last_reset": "{{ today_at('00:00:00')}}", + }, + ], + }, + ], + }, + ) + await hass.async_block_till_done() + + totalincreasing_state = hass.states.get("sensor.totalincreasing_entity") + assert totalincreasing_state is None + + assert ( + "last_reset is only valid for template sensors with state_class 'total'" + in caplog.text + ) + + +async def test_entity_last_reset_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset works for template sensors.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Total entity", + "state": "{{ states('sensor.test_state') | int(0) + 1 }}", + "state_class": "total", + "last_reset": "{{ now() }}", + }, + { + "name": "Static last_reset entity", + "state": "{{ states('sensor.test_state') | int(0) }}", + "state_class": "total", + "last_reset": "2023-01-01T00:00:00", + }, + ], + }, + { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + ], + }, + "sensor": { + "name": "Total trigger entity", + "state": "{{ states('sensor.test_state') | int(0) + 2 }}", + "state_class": "total", + "last_reset": "{{ as_datetime('2023-01-01') }}", + }, + }, + ], + }, + ) + await hass.async_block_till_done() + + # Trigger update + hass.states.async_set("sensor.test_state", "0") + await hass.async_block_till_done() + await hass.async_block_till_done() + + static_state = hass.states.get("sensor.static_last_reset_entity") + assert static_state is not None + assert static_state.state == "0" + assert static_state.attributes.get("state_class") == "total" + assert ( + static_state.attributes.get("last_reset") + == datetime(2023, 1, 1, 0, 0, 0).isoformat() + ) + + total_state = hass.states.get("sensor.total_entity") + assert total_state is not None + assert total_state.state == "1" + assert total_state.attributes.get("state_class") == "total" + assert total_state.attributes.get("last_reset") == now.isoformat() + + total_trigger_state = hass.states.get("sensor.total_trigger_entity") + assert total_trigger_state is not None + assert total_trigger_state.state == "2" + assert total_trigger_state.attributes.get("state_class") == "total" + assert ( + total_trigger_state.attributes.get("last_reset") + == datetime(2023, 1, 1).isoformat() + ) + + +async def test_entity_last_reset_static_value(hass: HomeAssistant) -> None: + """Test static last_reset marked as static_rendered.""" + + tse = TriggerSensorEntity( + hass, + None, + { + "name": Template("Static last_reset entity", hass), + "state": Template("{{ states('sensor.test_state') | int(0) }}", hass), + "state_class": "total", + "last_reset": Template("2023-01-01T00:00:00", hass), + }, + ) + + assert "last_reset" in tse._static_rendered + + +async def test_entity_last_reset_parsing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test last_reset works for template sensors.""" + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() + + with patch( + "homeassistant.components.template.sensor._LOGGER.warning" + ) as mocked_warning, patch( + "homeassistant.components.template.template_entity._LOGGER.error" + ) as mocked_error, patch("homeassistant.util.dt.now", return_value=now): + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "sensor": [ + { + "name": "Total entity", + "state": "{{ states('sensor.test_state') | int(0) + 1 }}", + "state_class": "total", + "last_reset": "{{ 'not a datetime' }}", + }, + ], + }, + { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + ], + }, + "sensor": { + "name": "Total trigger entity", + "state": "{{ states('sensor.test_state') | int(0) + 2 }}", + "state_class": "total", + "last_reset": "{{ 'not a datetime' }}", + }, + }, + ], + }, + ) + await hass.async_block_till_done() + + # Trigger update + hass.states.async_set("sensor.test_state", "0") + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Trigger based datetime parsing warning: + mocked_warning.assert_called_once_with( + "%s rendered invalid timestamp for last_reset attribute: %s", + "sensor.total_trigger_entity", + "not a datetime", + ) + + # State based datetime parsing error + mocked_error.assert_called_once() + args, _ = mocked_error.call_args + assert len(args) == 6 + assert args[0] == ( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'" + ) + assert args[1] == "not a datetime" + assert args[3] == "_attr_last_reset" + assert args[4] == "sensor.total_entity" + assert args[5] == "Invalid datetime specified: not a datetime" + + async def test_entity_device_class_parsing_works(hass: HomeAssistant) -> None: """Test entity device class parsing works.""" # State of timestamp sensors are always in UTC From 0c9a30ab6993b3c8cc35acaf96d3dd20932ffbab Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 25 Jan 2024 12:51:50 +0100 Subject: [PATCH 1023/1544] Add support for externally connected utility devices in HomeWizard (#100684) * Backport code from #86386 * Add tests * Remove local dev change * Implement device class validation based on unit * Swap sensor and externalsensor classes (based on importance) * Use translations for external sensor entities * Re-add meter identifier as sensor for external devices * Add migration for Gas identifier * Rename HomeWizardExternalIdentifierSensorEntity class * Fix all existing tests * Reimplement tests for extenal devices with smapshots * Remove non-used 'None' type in unit * Add migration test * Clean up parameterize * Add test to fix last coverage issue * Fix non-frozen mypy issue * Set device name via added EntityDescription field * Remove device key translations for external sensors, * Bring back translation keys * Set device unique_id as serial number * Remove meter identifier sensor * Simplify external device initialization * Adjust tests * Remove unused gas_meter_id migration * Remove external_devices redaction * Remove old gas meter id sensor after migration --- homeassistant/components/homewizard/sensor.py | 176 +- .../components/homewizard/strings.json | 7 +- .../homewizard/fixtures/HWE-P1/data.json | 39 +- .../snapshots/test_diagnostics.ambr | 53 +- .../homewizard/snapshots/test_sensor.ambr | 1842 +++++++++++++++++ tests/components/homewizard/test_sensor.py | 85 +- 6 files changed, 2165 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 177fc3ef176..f8a5b3b144a 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -5,9 +5,10 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.models import Data +from homewizard_energy.models import Data, ExternalDevice from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -15,8 +16,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_VIA_DEVICE, PERCENTAGE, EntityCategory, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -25,6 +28,8 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -44,6 +49,14 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Data], StateType] +@dataclass(frozen=True, kw_only=True) +class HomeWizardExternalSensorEntityDescription(SensorEntityDescription): + """Class describing HomeWizard sensor entities.""" + + suggested_device_class: SensorDeviceClass + device_name: str + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", @@ -378,22 +391,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.monthly_power_peak_w is not None, value_fn=lambda data: data.monthly_power_peak_w, ), - HomeWizardSensorEntityDescription( - key="total_gas_m3", - translation_key="total_gas_m3", - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_gas_m3 is not None, - value_fn=lambda data: data.total_gas_m3, - ), - HomeWizardSensorEntityDescription( - key="gas_unique_id", - translation_key="gas_unique_id", - entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.gas_unique_id is not None, - value_fn=lambda data: data.gas_unique_id, - ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", @@ -414,16 +411,86 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ) +EXTERNAL_SENSORS = { + ExternalDevice.DeviceType.GAS_METER: HomeWizardExternalSensorEntityDescription( + key="gas_meter", + translation_key="total_gas_m3", + suggested_device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Gas meter", + ), + ExternalDevice.DeviceType.HEAT_METER: HomeWizardExternalSensorEntityDescription( + key="heat_meter", + translation_key="total_energy_gj", + suggested_device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Heat meter", + ), + ExternalDevice.DeviceType.WARM_WATER_METER: HomeWizardExternalSensorEntityDescription( + key="warm_water_meter", + translation_key="total_liter_m3", + suggested_device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Warm water meter", + ), + ExternalDevice.DeviceType.WATER_METER: HomeWizardExternalSensorEntityDescription( + key="water_meter", + translation_key="total_liter_m3", + suggested_device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Water meter", + ), + ExternalDevice.DeviceType.INLET_HEAT_METER: HomeWizardExternalSensorEntityDescription( + key="inlet_heat_meter", + translation_key="total_energy_gj", + suggested_device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + device_name="Inlet heat meter", + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + + # Migrate original gas meter sensor to ExternalDevice + ent_reg = er.async_get(hass) + + if ( + entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{entry.unique_id}_total_gas_m3" + ) + ) and coordinator.data.data.gas_unique_id is not None: + ent_reg.async_update_entity( + entity_id, + new_unique_id=f"{DOMAIN}_{coordinator.data.data.gas_unique_id}", + ) + + # Remove old gas_unique_id sensor + if entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{entry.unique_id}_gas_unique_id" + ): + ent_reg.async_remove(entity_id) + + # Initialize default sensors + entities: list = [ HomeWizardSensorEntity(coordinator, description) for description in SENSORS if description.has_fn(coordinator.data.data) - ) + ] + + # Initialize external devices + if coordinator.data.data.external_devices is not None: + for unique_id, device in coordinator.data.data.external_devices.items(): + if description := EXTERNAL_SENSORS.get(device.meter_type): + entities.append( + HomeWizardExternalSensorEntity(coordinator, description, unique_id) + ) + + async_add_entities(entities) class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): @@ -452,3 +519,74 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): def available(self) -> bool: """Return availability of meter.""" return super().available and self.native_value is not None + + +class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity): + """Representation of externally connected HomeWizard Sensor.""" + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + description: HomeWizardExternalSensorEntityDescription, + device_unique_id: str, + ) -> None: + """Initialize Externally connected HomeWizard Sensors.""" + super().__init__(coordinator) + self.entity_description = description + self._device_id = device_unique_id + self._suggested_device_class = description.suggested_device_class + self._attr_unique_id = f"{DOMAIN}_{device_unique_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_unique_id)}, + name=description.device_name, + manufacturer="HomeWizard", + model=coordinator.data.device.product_type, + serial_number=device_unique_id, + ) + if coordinator.data.device.serial is not None: + self._attr_device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + coordinator.data.device.serial, + ) + + @property + def native_value(self) -> float | int | str | None: + """Return the sensor value.""" + return self.device.value if self.device is not None else None + + @property + def device(self) -> ExternalDevice | None: + """Return ExternalDevice object.""" + return ( + self.coordinator.data.data.external_devices[self._device_id] + if self.coordinator.data.data.external_devices is not None + else None + ) + + @property + def available(self) -> bool: + """Return availability of meter.""" + return super().available and self.device is not None + + @property + def native_unit_of_measurement(self) -> str | None: + """Return unit of measurement based on device unit.""" + if (device := self.device) is None: + return None + + # API returns 'm3' but we expect m³ + if device.unit == "m3": + return UnitOfVolume.CUBIC_METERS + + return device.unit + + @property + def device_class(self) -> SensorDeviceClass | None: + """Validate unit of measurement and set device class.""" + if ( + self.native_unit_of_measurement + not in DEVICE_CLASS_UNITS[self._suggested_device_class] + ): + return None + + return self._suggested_device_class diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index acdb321d6ff..d3cf8f88bed 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -149,14 +149,17 @@ "total_gas_m3": { "name": "Total gas" }, - "gas_unique_id": { - "name": "Gas meter identifier" + "meter_identifier": { + "name": "Meter identifier" }, "active_liter_lpm": { "name": "Active water usage" }, "total_liter_m3": { "name": "Total water usage" + }, + "total_energy_gj": { + "name": "Total heat energy" } }, "switch": { diff --git a/tests/components/homewizard/fixtures/HWE-P1/data.json b/tests/components/homewizard/fixtures/HWE-P1/data.json index 2eb7e3e430b..b221ad6f804 100644 --- a/tests/components/homewizard/fixtures/HWE-P1/data.json +++ b/tests/components/homewizard/fixtures/HWE-P1/data.json @@ -41,5 +41,42 @@ "montly_power_peak_w": 1111.0, "montly_power_peak_timestamp": 230101080010, "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567 + "total_liter_m3": 1234.567, + "external": [ + { + "unique_id": "47303031", + "type": "gas_meter", + "timestamp": 230125220957, + "value": 111.111, + "unit": "m3" + }, + { + "unique_id": "57303031", + "type": "water_meter", + "timestamp": 230125220957, + "value": 222.222, + "unit": "m3" + }, + { + "unique_id": "5757303031", + "type": "warm_water_meter", + "timestamp": 230125220957, + "value": 333.333, + "unit": "m3" + }, + { + "unique_id": "48303031", + "type": "heat_meter", + "timestamp": 230125220957, + "value": 444.444, + "unit": "GJ" + }, + { + "unique_id": "4948303031", + "type": "inlet_heat_meter", + "timestamp": 230125220957, + "value": 555.555, + "unit": "m3" + } + ] } diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index bddac0a79dc..e1fdfcf7c12 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -32,7 +32,58 @@ 'active_voltage_l3_v': 230.333, 'active_voltage_v': None, 'any_power_fail_count': 4, - 'external_devices': None, + 'external_devices': dict({ + 'G001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 111.111, + }), + 'H001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'GJ', + 'value': 444.444, + }), + 'IH001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 555.555, + }), + 'W001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 222.222, + }), + 'WW001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 333.333, + }), + }), 'gas_timestamp': '2021-03-14T11:22:33', 'gas_unique_id': '**REDACTED**', 'long_power_fail_count': 5, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 032972a7901..7d98a10089f 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,221 @@ # serializer version: 1 +# name: test_gas_meter_migrated[HWE-P1-unique_ids0][sensor.homewizard_aabbccddeeff_gas_unique_id:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_aabbccddeeff_gas_unique_id', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_gas_unique_id', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[HWE-P1-unique_ids0][sensor.homewizard_aabbccddeeff_total_gas_m3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_gas_unique_id][sensor.homewizard_a:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_gas_unique_id][sensor.homewizard_aabbccddeeff_gas_unique_id:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_aabbccddeeff_gas_unique_id', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_gas_unique_id', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_total_gas_m3][sensor.homewizard_a:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[aabbccddeeff_total_gas_m3][sensor.homewizard_aabbccddeeff_total_gas_m3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- +# name: test_gas_meter_migrated[sensor.homewizard_aabbccddeeff_total_gas_m3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homewizard_aabbccddeeff_total_gas_m3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': 'aabbccddeeff_total_gas_m3', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unit_of_measurement': None, + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3227,6 +3444,745 @@ 'state': '100', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_G001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter', + 'last_changed': , + 'last_updated': , + 'state': 'G001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': 'G001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_H001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'H001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': 'H001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inlet_heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_IH001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'IH001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': 'IH001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.warm_water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_WW001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Warm water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'WW001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': 'WW001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_W001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'W001', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': 'W001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7831,3 +8787,889 @@ 'state': '92', }) # --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_G001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter', + 'last_changed': , + 'last_updated': , + 'state': 'G001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'unknown_unit', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_meter_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_unknown_unit_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_2', + 'last_changed': , + 'last_updated': , + 'state': 'unknown_unit', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_total_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'unknown_unit', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_total_gas_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_gas_m3', + 'unique_id': 'homewizard_unknown_unit', + 'unit_of_measurement': 'cats', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.gas_meter_total_gas_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas meter Total gas', + 'state_class': , + 'unit_of_measurement': 'cats', + }), + 'context': , + 'entity_id': 'sensor.gas_meter_total_gas_2', + 'last_changed': , + 'last_updated': , + 'state': '666.666', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_H001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'H001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.inlet_heat_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_IH001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter', + 'last_changed': , + 'last_updated': , + 'state': 'IH001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total heat energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_gj', + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter Total heat energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_total_heat_energy', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.warm_water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_WW001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Warm water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'WW001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.warm_water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_meter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alphabetical-variant', + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_identifier', + 'unique_id': 'homewizard_W001_meter_identifier', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water meter', + 'icon': 'mdi:alphabetical-variant', + }), + 'context': , + 'entity_id': 'sensor.water_meter', + 'last_changed': , + 'last_updated': , + 'state': 'W001', + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_external_devices[HWE-P1-entity_ids0][sensor.water_meter_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_total_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 7e59769a768..214e1db706b 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -3,16 +3,18 @@ from unittest.mock import MagicMock from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.models import Data import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.homewizard import DOMAIN from homeassistant.components.homewizard.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = [ pytest.mark.usefixtures("init_integration"), @@ -63,10 +65,13 @@ pytestmark = [ "sensor.device_long_power_failures_detected", "sensor.device_active_average_demand", "sensor.device_peak_demand_current_month", - "sensor.device_total_gas", - "sensor.device_gas_meter_identifier", "sensor.device_active_water_usage", "sensor.device_total_water_usage", + "sensor.gas_meter_total_gas", + "sensor.water_meter_total_water_usage", + "sensor.warm_water_meter_total_water_usage", + "sensor.heat_meter_total_heat_energy", + "sensor.inlet_heat_meter_total_heat_energy", ], ), ( @@ -102,8 +107,6 @@ pytestmark = [ "sensor.device_power_failures_detected", "sensor.device_long_power_failures_detected", "sensor.device_active_average_demand", - "sensor.device_peak_demand_current_month", - "sensor.device_total_gas", "sensor.device_active_water_usage", "sensor.device_total_water_usage", ], @@ -256,6 +259,22 @@ async def test_sensors_unreachable( assert state.state == STATE_UNAVAILABLE +async def test_external_sensors_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, +) -> None: + """Test external device sensor handles API unreachable.""" + assert (state := hass.states.get("sensor.gas_meter_total_gas")) + assert state.state == "111.111" + + mock_homewizardenergy.data.return_value = Data.from_dict({}) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE + + @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ @@ -275,7 +294,6 @@ async def test_sensors_unreachable( "sensor.device_active_voltage_phase_3", "sensor.device_active_water_usage", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", @@ -289,7 +307,6 @@ async def test_sensors_unreachable( "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_gas", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", @@ -337,8 +354,6 @@ async def test_sensors_unreachable( "sensor.device_long_power_failures_detected", "sensor.device_active_average_demand", "sensor.device_peak_demand_current_month", - "sensor.device_total_gas", - "sensor.device_gas_meter_identifier", ], ), ( @@ -357,7 +372,6 @@ async def test_sensors_unreachable( "sensor.device_active_voltage_phase_3", "sensor.device_active_water_usage", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", @@ -371,7 +385,6 @@ async def test_sensors_unreachable( "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_gas", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", @@ -395,7 +408,6 @@ async def test_sensors_unreachable( "sensor.device_active_voltage_phase_3", "sensor.device_active_water_usage", "sensor.device_dsmr_version", - "sensor.device_gas_meter_identifier", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", @@ -409,7 +421,6 @@ async def test_sensors_unreachable( "sensor.device_total_energy_import_tariff_2", "sensor.device_total_energy_import_tariff_3", "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_gas", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", @@ -428,3 +439,49 @@ async def test_entities_not_created_for_device( """Ensures entities for a specific device are not created.""" for entity_id in entity_ids: assert not hass.states.get(entity_id) + + +async def test_gas_meter_migrated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test old gas meter sensor is migrated.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + "aabbccddeeff_total_gas_m3", + ) + + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.homewizard_aabbccddeeff_total_gas_m3" + + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + # Make really sure this happens + assert entity_entry.previous_unique_id == "aabbccddeeff_total_gas_m3" + + +async def test_gas_unique_id_removed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test old gas meter id sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + "aabbccddeeff_gas_unique_id", + ) + + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + entity_id = "sensor.homewizard_aabbccddeeff_gas_unique_id" + + assert not entity_registry.async_get(entity_id) From 114bf0da3471d5bd91a436f0e180bf1e9abf73e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 25 Jan 2024 12:54:31 +0100 Subject: [PATCH 1024/1544] Update Lutron in callback (#108779) * Update Lutron in callback * Update Lutron in callback * Remove abstractmethod * Don't do IO in constructor * Split fetching and setting --- .../components/lutron/binary_sensor.py | 10 +++--- homeassistant/components/lutron/cover.py | 23 +++++------- homeassistant/components/lutron/entity.py | 12 +++++++ homeassistant/components/lutron/light.py | 27 ++++++-------- homeassistant/components/lutron/scene.py | 7 ++-- homeassistant/components/lutron/switch.py | 35 +++++++------------ 6 files changed, 50 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 3adabfb3c9a..8cae9c9714a 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -52,13 +52,11 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): _lutron_device: OccupancyGroup _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - # Error cases will end up treated as unoccupied. - return self._lutron_device.state == OccupancyGroup.State.OCCUPIED - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} + + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.state == OccupancyGroup.State.OCCUPIED diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 9aace54757f..cdcdf93ccbd 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -53,16 +53,6 @@ class LutronCover(LutronDevice, CoverEntity): _lutron_device: Output _attr_name = None - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._lutron_device.last_level() < 1 - - @property - def current_cover_position(self) -> int: - """Return the current position of cover.""" - return self._lutron_device.last_level() - def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._lutron_device.level = 0 @@ -77,10 +67,15 @@ class LutronCover(LutronDevice, CoverEntity): position = kwargs[ATTR_POSITION] self._lutron_device.level = position - def update(self) -> None: - """Call when forcing a refresh of the device.""" - # Reading the property (rather than last_level()) fetches value - level = self._lutron_device.level + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_closed = level < 1 + self._attr_current_cover_position = level _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property diff --git a/homeassistant/components/lutron/entity.py b/homeassistant/components/lutron/entity.py index 4e6d0066a47..461e5acb56d 100644 --- a/homeassistant/components/lutron/entity.py +++ b/homeassistant/components/lutron/entity.py @@ -27,10 +27,17 @@ class LutronBaseEntity(Entity): """Register callbacks.""" self._lutron_device.subscribe(self._update_callback, None) + def _request_state(self) -> None: + """Request the state.""" + + def _update_attrs(self) -> None: + """Update the entity's attributes.""" + def _update_callback( self, _device: LutronEntity, _context: None, _event: LutronEvent, _params: dict ) -> None: """Run when invoked by pylutron when the device state changes.""" + self._update_attrs() self.schedule_update_ha_state() @property @@ -41,6 +48,11 @@ class LutronBaseEntity(Entity): return None return f"{self._controller.guid}_{self._lutron_device.uuid}" + def update(self) -> None: + """Update the entity's state.""" + self._request_state() + self._update_attrs() + class LutronDevice(LutronBaseEntity): """Representation of a Lutron device entity.""" diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index fa7a45734fe..da991969228 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -54,14 +54,6 @@ class LutronLight(LutronDevice, LightEntity): _prev_brightness: int | None = None _attr_name = None - @property - def brightness(self) -> int: - """Return the brightness of the light.""" - new_brightness = to_hass_level(self._lutron_device.last_level()) - if new_brightness != 0: - self._prev_brightness = new_brightness - return new_brightness - def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: @@ -82,12 +74,15 @@ class LutronLight(LutronDevice, LightEntity): """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._lutron_device.last_level() > 0 + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement - def update(self) -> None: - """Call when forcing a refresh of the device.""" - if self._prev_brightness is None: - self._prev_brightness = to_hass_level(self._lutron_device.level) + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_on = level > 0 + hass_level = to_hass_level(level) + self._attr_brightness = hass_level + if self._prev_brightness is None or hass_level != 0: + self._prev_brightness = hass_level diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index a4a505c8477..9485eddf78b 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -27,11 +27,8 @@ async def async_setup_entry( entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - [ - LutronScene(area_name, keypad, device, entry_data.client) - for area_name, keypad, device, led in entry_data.scenes - ], - True, + LutronScene(area_name, keypad, device, entry_data.client) + for area_name, keypad, device, led in entry_data.scenes ) diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 14331fa500d..0286fdef238 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -44,13 +44,6 @@ class LutronSwitch(LutronDevice, SwitchEntity): _lutron_device: Output - def __init__( - self, area_name: str, lutron_device: Output, controller: Lutron - ) -> None: - """Initialize the switch.""" - self._prev_state = None - super().__init__(area_name, lutron_device, controller) - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._lutron_device.level = 100 @@ -64,15 +57,13 @@ class LutronSwitch(LutronDevice, SwitchEntity): """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._lutron_device.last_level() > 0 + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement - def update(self) -> None: - """Call when forcing a refresh of the device.""" - if self._prev_state is None: - self._prev_state = self._lutron_device.level > 0 + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.last_level() > 0 class LutronLed(LutronKeypad, SwitchEntity): @@ -110,12 +101,10 @@ class LutronLed(LutronKeypad, SwitchEntity): "led": self._lutron_device.name, } - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._lutron_device.last_state - - def update(self) -> None: - """Call when forcing a refresh of the device.""" - # The following property getter actually triggers an update in Lutron + def _request_state(self) -> None: + """Request the state from the device.""" self._lutron_device.state # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + self._attr_is_on = self._lutron_device.last_state From 909cdc2e5ce9e450c6f09e88e8373656534f25cd Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 25 Jan 2024 21:54:47 +1000 Subject: [PATCH 1025/1544] Add Teslemetry Integration (#108147) * Copy Paste Find Replace * Small progress * wip * more wip * Add SSE listen and close * More rework * Fix coordinator * Get working * Bump to 0.1.3 * Push to 0.1.4 * Lots of fixes * Remove stream * Add wakeup * Improve set temp * Be consistent with self * Increase polling until streaming * Work in progress * Move to single climate * bump to 0.2.0 * Update entity * Data handling * fixes * WIP tests * Tests * Delete other tests * Update comment * Fix init * Update homeassistant/components/teslemetry/entity.py Co-authored-by: Joost Lekkerkerker * Add Codeowner * Update coverage * requirements * Add failure for subscription required * Add VIN to model * Add wake * Add context manager * Rename to wake_up_if_asleep * Remove context from coverage * change lock to context Co-authored-by: Joost Lekkerkerker * Improving Logger * Add url to subscription error * Errors cannot markdown * Fix logger Co-authored-by: Joost Lekkerkerker * rename logger * Fix error logging * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 5 + CODEOWNERS | 2 + .../components/teslemetry/__init__.py | 77 +++++++++++ .../components/teslemetry/climate.py | 130 ++++++++++++++++++ .../components/teslemetry/config_flow.py | 63 +++++++++ homeassistant/components/teslemetry/const.py | 31 +++++ .../components/teslemetry/context.py | 16 +++ .../components/teslemetry/coordinator.py | 67 +++++++++ homeassistant/components/teslemetry/entity.py | 62 +++++++++ .../components/teslemetry/manifest.json | 10 ++ homeassistant/components/teslemetry/models.py | 17 +++ .../components/teslemetry/strings.json | 35 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/teslemetry/__init__.py | 1 + tests/components/teslemetry/const.py | 5 + .../components/teslemetry/test_config_flow.py | 87 ++++++++++++ 19 files changed, 621 insertions(+) create mode 100644 homeassistant/components/teslemetry/__init__.py create mode 100644 homeassistant/components/teslemetry/climate.py create mode 100644 homeassistant/components/teslemetry/config_flow.py create mode 100644 homeassistant/components/teslemetry/const.py create mode 100644 homeassistant/components/teslemetry/context.py create mode 100644 homeassistant/components/teslemetry/coordinator.py create mode 100644 homeassistant/components/teslemetry/entity.py create mode 100644 homeassistant/components/teslemetry/manifest.json create mode 100644 homeassistant/components/teslemetry/models.py create mode 100644 homeassistant/components/teslemetry/strings.json create mode 100644 tests/components/teslemetry/__init__.py create mode 100644 tests/components/teslemetry/const.py create mode 100644 tests/components/teslemetry/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a988a50fd9f..9dc665d9c3c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1362,6 +1362,11 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py + homeassistant/components/teslemetry/__init__.py + homeassistant/components/teslemetry/climate.py + homeassistant/components/teslemetry/coordinator.py + homeassistant/components/teslemetry/entity.py + homeassistant/components/teslemetry/context.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* diff --git a/CODEOWNERS b/CODEOWNERS index 339d4aca6ea..a423bbf8f76 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1344,6 +1344,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/teslemetry/ @Bre77 +/tests/components/teslemetry/ @Bre77 /homeassistant/components/tessie/ @Bre77 /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py new file mode 100644 index 00000000000..0c2a16fa15b --- /dev/null +++ b/homeassistant/components/teslemetry/__init__.py @@ -0,0 +1,77 @@ +"""Teslemetry integration.""" +import asyncio +from typing import Final + +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import TeslemetryVehicleDataCoordinator +from .models import TeslemetryVehicleData + +PLATFORMS: Final = [ + Platform.CLIMATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Teslemetry config.""" + + access_token = entry.data[CONF_ACCESS_TOKEN] + + # Create API connection + teslemetry = Teslemetry( + session=async_get_clientsession(hass), + access_token=access_token, + ) + try: + products = (await teslemetry.products())["response"] + except InvalidToken: + LOGGER.error("Access token is invalid, unable to connect to Teslemetry") + return False + except PaymentRequired: + LOGGER.error("Subscription required, unable to connect to Telemetry") + return False + except TeslaFleetError as e: + raise ConfigEntryNotReady from e + + # Create array of classes + data = [] + for product in products: + if "vin" not in product: + continue + vin = product["vin"] + + api = teslemetry.vehicle.specific(vin) + coordinator = TeslemetryVehicleDataCoordinator(hass, api) + data.append( + TeslemetryVehicleData( + api=api, + coordinator=coordinator, + vin=vin, + ) + ) + + # Do all coordinator first refresh simultaneously + await asyncio.gather( + *(vehicle.coordinator.async_config_entry_first_refresh() for vehicle in data) + ) + + # Setup Platforms + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Teslemetry Config.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py new file mode 100644 index 00000000000..cea56f35b15 --- /dev/null +++ b/homeassistant/components/teslemetry/climate.py @@ -0,0 +1,130 @@ +"""Climate platform for Teslemetry integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TeslemetryClimateSide +from .context import handle_command +from .entity import TeslemetryVehicleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Climate platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER) + for vehicle in data + ) + + +class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes = ["off", "keep", "dog", "camp"] + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get(f"climate_state_{self.key}_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_start() + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.auto_conditioning_stop() + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_temps( + driver_temp=temp, + passenger_temp=temp, + ) + + self.set((f"climate_state_{self.key}_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + with handle_command(): + await self.wake_up_if_asleep() + await self.api.set_climate_keeper_mode( + climate_keeper_mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py new file mode 100644 index 00000000000..64a279132ad --- /dev/null +++ b/homeassistant/components/teslemetry/config_flow.py @@ -0,0 +1,63 @@ +"""Config Flow for Teslemetry integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientConnectionError +from tesla_fleet_api import Teslemetry +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +TESLEMETRY_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "short_url": "teslemetry.com/console", + "url": "[teslemetry.com/console](https://teslemetry.com/console)", +} + + +class TeslemetryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Teslemetry API connection.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input: + teslemetry = Teslemetry( + session=async_get_clientsession(self.hass), + access_token=user_input[CONF_ACCESS_TOKEN], + ) + try: + await teslemetry.test() + except InvalidToken: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except PaymentRequired: + errors["base"] = "subscription_required" + except ClientConnectionError: + errors["base"] = "cannot_connect" + except TeslaFleetError as e: + LOGGER.exception(str(e)) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Teslemetry", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESLEMETRY_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py new file mode 100644 index 00000000000..9b31a3270ca --- /dev/null +++ b/homeassistant/components/teslemetry/const.py @@ -0,0 +1,31 @@ +"""Constants used by Teslemetry integration.""" +from __future__ import annotations + +from enum import StrEnum +import logging + +DOMAIN = "teslemetry" + +LOGGER = logging.getLogger(__package__) + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TeslemetryState(StrEnum): + """Teslemetry Vehicle States.""" + + ONLINE = "online" + ASLEEP = "asleep" + OFFLINE = "offline" + + +class TeslemetryClimateSide(StrEnum): + """Teslemetry Climate Keeper Modes.""" + + DRIVER = "driver_temp" + PASSENGER = "passenger_temp" diff --git a/homeassistant/components/teslemetry/context.py b/homeassistant/components/teslemetry/context.py new file mode 100644 index 00000000000..c2c9317f671 --- /dev/null +++ b/homeassistant/components/teslemetry/context.py @@ -0,0 +1,16 @@ +"""Teslemetry context managers.""" + +from contextlib import contextmanager + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + + +@contextmanager +def handle_command(): + """Handle wake up and errors.""" + try: + yield + except TeslaFleetError as e: + raise HomeAssistantError from e diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py new file mode 100644 index 00000000000..4f12b4a3111 --- /dev/null +++ b/homeassistant/components/teslemetry/coordinator.py @@ -0,0 +1,67 @@ +"""Teslemetry Data Coordinator.""" +from datetime import timedelta +from typing import Any + +from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline +from tesla_fleet_api.vehiclespecific import VehicleSpecific + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, TeslemetryState + +SYNC_INTERVAL = 60 + + +class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Teslemetry API.""" + + def __init__(self, hass: HomeAssistant, api: VehicleSpecific) -> None: + """Initialize Teslemetry Data Update Coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Vehicle", + update_interval=timedelta(seconds=SYNC_INTERVAL), + ) + self.api = api + + async def async_config_entry_first_refresh(self) -> None: + """Perform first refresh.""" + try: + response = await self.api.wake_up() + if response["response"]["state"] != TeslemetryState.ONLINE: + # The first refresh will fail, so retry later + raise ConfigEntryNotReady("Vehicle is not online") + except TeslaFleetError as e: + # The first refresh will also fail, so retry later + raise ConfigEntryNotReady from e + await super().async_config_entry_first_refresh() + + async def _async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Teslemetry API.""" + + try: + data = await self.api.vehicle_data() + except VehicleOffline: + self.data["state"] = TeslemetryState.OFFLINE + return self.data + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + return self._flatten(data["response"]) + + def _flatten( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flatten the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}_{key}" + if isinstance(value, dict): + result.update(self._flatten(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py new file mode 100644 index 00000000000..c8fbc5910d8 --- /dev/null +++ b/homeassistant/components/teslemetry/entity.py @@ -0,0 +1,62 @@ +"""Teslemetry parent entity class.""" + +import asyncio +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS, TeslemetryState +from .coordinator import TeslemetryVehicleDataCoordinator +from .models import TeslemetryVehicleData + + +class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]): + """Parent class for Teslemetry Entities.""" + + _attr_has_entity_name = True + _wakelock = asyncio.Lock() + + def __init__( + self, + vehicle: TeslemetryVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + super().__init__(vehicle.coordinator) + self.key = key + self.api = vehicle.api + + car_type = self.coordinator.data["vehicle_config_car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{vehicle.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=self.coordinator.data["vehicle_state_vehicle_name"], + model=MODELS.get(car_type, car_type), + sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0], + hw_version=self.coordinator.data["vehicle_config_driver_assist"], + serial_number=vehicle.vin, + ) + + async def wake_up_if_asleep(self) -> None: + """Wake up the vehicle if its asleep.""" + async with self._wakelock: + while self.coordinator.data["state"] != TeslemetryState.ONLINE: + state = (await self.api.wake_up())["response"]["state"] + self.coordinator.data["state"] = state + if state != TeslemetryState.ONLINE: + await asyncio.sleep(5) + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json new file mode 100644 index 00000000000..be6f6ae634c --- /dev/null +++ b/homeassistant/components/teslemetry/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "teslemetry", + "name": "Teslemetry", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/teslemetry", + "iot_class": "cloud_polling", + "loggers": ["tesla-fleet-api"], + "requirements": ["tesla-fleet-api==0.2.0"] +} diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py new file mode 100644 index 00000000000..9b90c0b0750 --- /dev/null +++ b/homeassistant/components/teslemetry/models.py @@ -0,0 +1,17 @@ +"""The Teslemetry integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from tesla_fleet_api import VehicleSpecific + +from .coordinator import TeslemetryVehicleDataCoordinator + + +@dataclass +class TeslemetryVehicleData: + """Data for a vehicle in the Teslemetry integration.""" + + api: VehicleSpecific + coordinator: TeslemetryVehicleDataCoordinator + vin: str diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json new file mode 100644 index 00000000000..95b2266b2dd --- /dev/null +++ b/homeassistant/components/teslemetry/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "subscription_required": "Subscription required, please visit {short_url}", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter an access token from {url}." + } + } + }, + "entity": { + "climate": { + "driver_temp": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "keep": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a32c30293b9..3f26b6f907b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -515,6 +515,7 @@ FLOWS = { "tedee", "tellduslive", "tesla_wall_connector", + "teslemetry", "tessie", "thermobeacon", "thermopro", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b935fa25fbc..f188201f847 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5947,6 +5947,12 @@ } } }, + "teslemetry": { + "name": "Teslemetry", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tessie": { "name": "Tessie", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c5b3a52ccea..f70bd86f29c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2656,6 +2656,9 @@ temperusb==1.6.1 # homeassistant.components.tensorflow # tensorflow==2.5.0 +# homeassistant.components.teslemetry +tesla-fleet-api==0.2.0 + # homeassistant.components.powerwall tesla-powerwall==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1156ba311a6..479d84bf57a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2015,6 +2015,9 @@ temescal==0.5 # homeassistant.components.temper temperusb==1.6.1 +# homeassistant.components.teslemetry +tesla-fleet-api==0.2.0 + # homeassistant.components.powerwall tesla-powerwall==0.5.0 diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py new file mode 100644 index 00000000000..422a2ecaac9 --- /dev/null +++ b/tests/components/teslemetry/__init__.py @@ -0,0 +1 @@ +"""Tests for the Teslemetry integration.""" diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py new file mode 100644 index 00000000000..527ef98efca --- /dev/null +++ b/tests/components/teslemetry/const.py @@ -0,0 +1,5 @@ +"""Constants for the teslemetry tests.""" + +from homeassistant.const import CONF_ACCESS_TOKEN + +CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py new file mode 100644 index 00000000000..ca9b89bedb3 --- /dev/null +++ b/tests/components/teslemetry/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test the Teslemetry config flow.""" + +from unittest.mock import AsyncMock, patch + +from aiohttp import ClientConnectionError +import pytest +from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError + +from homeassistant import config_entries +from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CONFIG + + +@pytest.fixture(autouse=True) +def teslemetry_config_entry_mock(): + """Mock Teslemetry api class.""" + with patch( + "homeassistant.components.teslemetry.config_flow.Teslemetry", + ) as teslemetry_config_entry_mock: + teslemetry_config_entry_mock.return_value.test = AsyncMock() + yield teslemetry_config_entry_mock + + +async def test_form( + hass: HomeAssistant, +) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.teslemetry.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (InvalidToken, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (PaymentRequired, {"base": "subscription_required"}), + (ClientConnectionError, {"base": "cannot_connect"}), + (TeslaFleetError, {"base": "unknown"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, side_effect, error, teslemetry_config_entry_mock +) -> None: + """Test errors are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + teslemetry_config_entry_mock.return_value.test.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + teslemetry_config_entry_mock.return_value.test.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY From 6f81d21a35c9180b020bb806d6fcefb0cf29fbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 25 Jan 2024 13:55:55 +0200 Subject: [PATCH 1026/1544] Add Huum integration (#106420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Huum integration * Use DeviceInfo instead of name property for huum climate * Simplify entry setup for huum climate entry * Don’t take status as attribute for huum climate init * Remove unused import * Set unique id as entity id in huum init * Remove unused import for huum climate * Use entry ID as unique ID for device entity * Remove extra newline in huum climate * Upgrade pyhuum to 0.7.4 This version no longer users Pydantic * Parameterize error huum tests * Update all requirements after pyhuum upgrade * Use Huum specific naming for ConfigFlow * Use constants for username and password in huum config flow * Use constants for temperature units * Fix typing and pylint issues * Update pyhuum to 0.7.5 * Use correct enums for data entry flow in Huum tests * Remove test for non-thrown CannotConnect in huum flow tests * Refactor failure config test to also test a successful flow after failure * Fix ruff-format issues * Move _status outside of __init__ and type it * Type temperature argument for _turn_on in huum climate * Use constants for auth in huum config flow test * Refactor validate_into into a inline call in huum config flow * Refactor current and target temperature to be able to return None values * Remove unused huum exceptions * Flip if-statment in async_step_user flow setup to simplify code * Change current and target temperature to be more future proof * Log exception instead of error * Use custom pyhuum exceptions * Add checks for duplicate entries * Use min temp if no target temp has been fetched yet when heating huum * Fix tests so that mock config entry also include username and password * Fix ruff styling issues I don’t know why it keeps doing this. I run `ruff` locally, and then it does not complain, but CI must be doing something else here. * Remove unneded setting of unique id * Update requirements * Refactor temperature setting to support settings target temparature properly --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/huum/__init__.py | 46 +++++++ homeassistant/components/huum/climate.py | 128 ++++++++++++++++++ homeassistant/components/huum/config_flow.py | 63 +++++++++ homeassistant/components/huum/const.py | 7 + homeassistant/components/huum/manifest.json | 9 ++ homeassistant/components/huum/strings.json | 22 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/huum/__init__.py | 1 + tests/components/huum/test_config_flow.py | 135 +++++++++++++++++++ 14 files changed, 428 insertions(+) create mode 100644 homeassistant/components/huum/__init__.py create mode 100644 homeassistant/components/huum/climate.py create mode 100644 homeassistant/components/huum/config_flow.py create mode 100644 homeassistant/components/huum/const.py create mode 100644 homeassistant/components/huum/manifest.json create mode 100644 homeassistant/components/huum/strings.json create mode 100644 tests/components/huum/__init__.py create mode 100644 tests/components/huum/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 9dc665d9c3c..d026723d500 100644 --- a/.coveragerc +++ b/.coveragerc @@ -550,6 +550,8 @@ omit = homeassistant/components/hunterdouglas_powerview/shade_data.py homeassistant/components/hunterdouglas_powerview/util.py homeassistant/components/hvv_departures/__init__.py + homeassistant/components/huum/__init__.py + homeassistant/components/huum/climate.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/ialarm/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index a423bbf8f76..9d1d2339d23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -579,6 +579,8 @@ build.json @home-assistant/supervisor /tests/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/homeassistant/components/huum/ @frwickst +/tests/components/huum/ @frwickst /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @ptcryan diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py new file mode 100644 index 00000000000..a5daf471a2d --- /dev/null +++ b/homeassistant/components/huum/__init__.py @@ -0,0 +1,46 @@ +"""The Huum integration.""" +from __future__ import annotations + +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Huum from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + huum = Huum(username, password, session=async_get_clientsession(hass)) + + try: + await huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise ConfigEntryNotReady( + "Could not log in to Huum with given credentials" + ) from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py new file mode 100644 index 00000000000..dcf025082cc --- /dev/null +++ b/homeassistant/components/huum/climate.py @@ -0,0 +1,128 @@ +"""Support for Huum wifi-enabled sauna.""" +from __future__ import annotations + +import logging +from typing import Any + +from huum.const import SaunaStatus +from huum.exceptions import SafetyException +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Huum sauna with config flow.""" + huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] + + async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + + +class HuumDevice(ClimateEntity): + """Representation of a heater.""" + + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_max_temp = 110 + _attr_min_temp = 40 + _attr_has_entity_name = True + _attr_name = None + + _target_temperature: int | None = None + _status: HuumStatusResponse | None = None + + def __init__(self, huum_handler: Huum, unique_id: str) -> None: + """Initialize the heater.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name="Huum sauna", + manufacturer="Huum", + ) + + self._huum_handler = huum_handler + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + return HVACMode.HEAT + return HVACMode.OFF + + @property + def icon(self) -> str: + """Return nice icon for heater.""" + if self.hvac_mode == HVACMode.HEAT: + return "mdi:radiator" + return "mdi:radiator-off" + + @property + def current_temperature(self) -> int | None: + """Return the current temperature.""" + if (status := self._status) is not None: + return status.temperature + return None + + @property + def target_temperature(self) -> int: + """Return the temperature we try to reach.""" + return self._target_temperature or int(self.min_temp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + if hvac_mode == HVACMode.HEAT: + await self._turn_on(self.target_temperature) + elif hvac_mode == HVACMode.OFF: + await self._huum_handler.turn_off() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._target_temperature = temperature + + if self.hvac_mode == HVACMode.HEAT: + await self._turn_on(temperature) + + async def async_update(self) -> None: + """Get the latest status data. + + We get the latest status first from the status endpoints of the sauna. + If that data does not include the temperature, that means that the sauna + is off, we then call the off command which will in turn return the temperature. + This is a workaround for getting the temperature as the Huum API does not + return the target temperature of a sauna that is off, even if it can have + a target temperature at that time. + """ + self._status = await self._huum_handler.status_from_status_or_stop() + if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: + self._target_temperature = self._status.target_temperature + + async def _turn_on(self, temperature: int) -> None: + try: + await self._huum_handler.turn_on(temperature) + except (ValueError, SafetyException) as err: + _LOGGER.error(str(err)) + raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py new file mode 100644 index 00000000000..31f4c9a137c --- /dev/null +++ b/homeassistant/components/huum/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for huum integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HuumConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for huum.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + huum_handler = Huum( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + await huum_handler.status() + except (Forbidden, NotAuthenticated): + # Most likely Forbidden as that is what is returned from `.status()` with bad creds + _LOGGER.error("Could not log in to Huum with given credentials") + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + self._async_abort_entries_match( + {CONF_USERNAME: user_input[CONF_USERNAME]} + ) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py new file mode 100644 index 00000000000..69dea45b218 --- /dev/null +++ b/homeassistant/components/huum/const.py @@ -0,0 +1,7 @@ +"""Constants for the huum integration.""" + +from homeassistant.const import Platform + +DOMAIN = "huum" + +PLATFORMS = [Platform.CLIMATE] diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json new file mode 100644 index 00000000000..46256d15347 --- /dev/null +++ b/homeassistant/components/huum/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "huum", + "name": "Huum", + "codeowners": ["@frwickst"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huum", + "iot_class": "cloud_polling", + "requirements": ["huum==0.7.9"] +} diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json new file mode 100644 index 00000000000..68ab1adde6f --- /dev/null +++ b/homeassistant/components/huum/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Huum", + "description": "Log in with the same username and password that is used in the Huum mobile app.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3f26b6f907b..d63bdc23b12 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -225,6 +225,7 @@ FLOWS = { "hue", "huisbaasje", "hunterdouglas_powerview", + "huum", "hvv_departures", "hydrawise", "hyperion", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f188201f847..0e9b46ea152 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2596,6 +2596,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "huum": { + "name": "Huum", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hvv_departures": { "name": "HVV Departures", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f70bd86f29c..bdc51d6c204 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1073,6 +1073,9 @@ httplib2==0.20.4 # homeassistant.components.huawei_lte huawei-lte-api==1.7.3 +# homeassistant.components.huum +huum==0.7.9 + # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 479d84bf57a..cb817549232 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -863,6 +863,9 @@ httplib2==0.20.4 # homeassistant.components.huawei_lte huawei-lte-api==1.7.3 +# homeassistant.components.huum +huum==0.7.9 + # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py new file mode 100644 index 00000000000..443cbd52c36 --- /dev/null +++ b/tests/components/huum/__init__.py @@ -0,0 +1 @@ +"""Tests for the huum integration.""" diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py new file mode 100644 index 00000000000..7163521b446 --- /dev/null +++ b/tests/components/huum/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the huum config flow.""" +from unittest.mock import patch + +from huum.exceptions import Forbidden +import pytest + +from homeassistant import config_entries +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USERNAME = "test-username" +TEST_PASSWORD = "test-password" + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_USERNAME + assert result2["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: + """Test that we handle already existing entities with same id.""" + mock_config_entry = MockConfigEntry( + title="Huum Sauna", + domain=DOMAIN, + unique_id=TEST_USERNAME, + data={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] == FlowResultType.ABORT + + +@pytest.mark.parametrize( + ( + "raises", + "error_base", + ), + [ + (Exception, "unknown"), + (Forbidden, "invalid_auth"), + ], +) +async def test_huum_errors( + hass: HomeAssistant, raises: Exception, error_base: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + side_effect=raises, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error_base} + + with patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=True, + ), patch( + "homeassistant.components.huum.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY From 6e59568ba36ac4f432e4c27765de30e24afad977 Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Fri, 26 Jan 2024 01:05:07 +1300 Subject: [PATCH 1027/1544] Use feed name as entity name in GeoJSON (#108753) * Add support for entity name in GeoJSON Previously GeoJSON names were just the config entry ID. This is not very user friendly. Particularly so when there are many config entries and many, many entities from those same many config entries. * Update GeoJSON tests to support entity names --- .../components/geo_json_events/geo_location.py | 5 ++++- tests/components/geo_json_events/__init__.py | 3 +++ .../geo_json_events/test_geo_location.py | 15 +++++++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 8cb30535e66..8915962c4ff 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -104,7 +104,10 @@ class GeoJsonLocationEvent(GeolocationEvent): def _update_from_feed(self, feed_entry: GenericFeedEntry) -> None: """Update the internal state from the provided feed entry.""" - self._attr_name = feed_entry.title + if feed_entry.properties and "name" in feed_entry.properties: + self._attr_name = feed_entry.properties.get("name") + else: + self._attr_name = feed_entry.title self._attr_distance = feed_entry.distance_to_home self._attr_latitude = feed_entry.coordinates[0] self._attr_longitude = feed_entry.coordinates[1] diff --git a/tests/components/geo_json_events/__init__.py b/tests/components/geo_json_events/__init__.py index f95ee747bf3..7d7148b3c20 100644 --- a/tests/components/geo_json_events/__init__.py +++ b/tests/components/geo_json_events/__init__.py @@ -1,4 +1,5 @@ """Tests for the geo_json_events component.""" +from typing import Any from unittest.mock import MagicMock @@ -7,6 +8,7 @@ def _generate_mock_feed_entry( title: str, distance_to_home: float, coordinates: tuple[float, float], + properties: dict[str, Any] | None = None, ) -> MagicMock: """Construct a mock feed entry for testing purposes.""" feed_entry = MagicMock() @@ -14,4 +16,5 @@ def _generate_mock_feed_entry( feed_entry.title = title feed_entry.distance_to_home = distance_to_home feed_entry.coordinates = coordinates + feed_entry.properties = properties return feed_entry diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 3875a525e73..3176a37ab74 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_RADIUS, CONF_SCAN_INTERVAL, @@ -50,7 +51,13 @@ async def test_entity_lifecycle( """Test entity lifecycle..""" config_entry.add_to_hass(hass) # Set up a mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) + mock_entry_1 = _generate_mock_feed_entry( + "1234", + "Title 1", + 15.5, + (-31.0, 150.0), + {ATTR_NAME: "Properties 1"}, + ) mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) @@ -69,14 +76,14 @@ async def test_entity_lifecycle( assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 assert len(entity_registry.entities) == 3 - state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_1") + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.properties_1") assert state is not None - assert state.name == "Title 1" + assert state.name == "Properties 1" assert state.attributes == { ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, - ATTR_FRIENDLY_NAME: "Title 1", + ATTR_FRIENDLY_NAME: "Properties 1", ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } From 1fa7ceede390df037fe9bdb1ba4f7939c9acc138 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 25 Jan 2024 13:15:20 +0100 Subject: [PATCH 1028/1544] Use entity translations placeholders in HomeWizard (#108741) * Adopt Entity placeholders * Undo some snapshot changes --- homeassistant/components/homewizard/sensor.py | 69 ++++++++---- .../components/homewizard/strings.json | 76 +++---------- .../homewizard/snapshots/test_sensor.ambr | 102 +++++++++--------- 3 files changed, 111 insertions(+), 136 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index f8a5b3b144a..01ad2d5ea57 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -117,7 +117,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", - translation_key="total_energy_import_t1_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -130,7 +131,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", - translation_key="total_energy_import_t2_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -139,7 +141,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", - translation_key="total_energy_import_t3_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "3"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -148,7 +151,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", - translation_key="total_energy_import_t4_kwh", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "4"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -167,7 +171,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", - translation_key="total_energy_export_t1_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -181,7 +186,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", - translation_key="total_energy_export_t2_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -191,7 +197,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", - translation_key="total_energy_export_t3_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "3"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -201,7 +208,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", - translation_key="total_energy_export_t4_kwh", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "4"}, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -221,7 +229,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l1_w", - translation_key="active_power_l1_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -231,7 +240,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l2_w", - translation_key="active_power_l2_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -241,7 +251,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_l3_w", - translation_key="active_power_l3_w", + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -251,7 +262,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", - translation_key="active_voltage_l1_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -261,7 +273,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", - translation_key="active_voltage_l2_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -271,7 +284,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", - translation_key="active_voltage_l3_v", + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -281,7 +295,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_current_l1_a", - translation_key="active_current_l1_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -291,7 +306,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_current_l2_a", - translation_key="active_current_l2_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -301,7 +317,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_current_l3_a", - translation_key="active_current_l3_a", + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -321,42 +338,48 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", - translation_key="voltage_sag_l1_count", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l1_count is not None, value_fn=lambda data: data.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", - translation_key="voltage_sag_l2_count", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l2_count is not None, value_fn=lambda data: data.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", - translation_key="voltage_sag_l3_count", + translation_key="voltage_sag_phase_count", + translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_sag_l3_count is not None, value_fn=lambda data: data.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", - translation_key="voltage_swell_l1_count", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l1_count is not None, value_fn=lambda data: data.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", - translation_key="voltage_swell_l2_count", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l2_count is not None, value_fn=lambda data: data.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", - translation_key="voltage_swell_l3_count", + translation_key="voltage_swell_phase_count", + translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, has_fn=lambda data: data.voltage_swell_l3_count is not None, value_fn=lambda data: data.voltage_swell_l3_count, diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index d3cf8f88bed..58bdd8c6cb9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -56,83 +56,35 @@ "total_energy_import_kwh": { "name": "Total energy import" }, - "total_energy_import_t1_kwh": { - "name": "Total energy import tariff 1" - }, - "total_energy_import_t2_kwh": { - "name": "Total energy import tariff 2" - }, - "total_energy_import_t3_kwh": { - "name": "Total energy import tariff 3" - }, - "total_energy_import_t4_kwh": { - "name": "Total energy import tariff 4" + "total_energy_import_tariff_kwh": { + "name": "Total energy import tariff {tariff}" }, "total_energy_export_kwh": { "name": "Total energy export" }, - "total_energy_export_t1_kwh": { - "name": "Total energy export tariff 1" - }, - "total_energy_export_t2_kwh": { - "name": "Total energy export tariff 2" - }, - "total_energy_export_t3_kwh": { - "name": "Total energy export tariff 3" - }, - "total_energy_export_t4_kwh": { - "name": "Total energy export tariff 4" + "total_energy_export_tariff_kwh": { + "name": "Total energy export tariff {tariff}" }, "active_power_w": { "name": "Active power" }, - "active_power_l1_w": { - "name": "Active power phase 1" + "active_power_phase_w": { + "name": "Active power phase {phase}" }, - "active_power_l2_w": { - "name": "Active power phase 2" + "active_voltage_phase_v": { + "name": "Active voltage phase {phase}" }, - "active_power_l3_w": { - "name": "Active power phase 3" - }, - "active_voltage_l1_v": { - "name": "Active voltage phase 1" - }, - "active_voltage_l2_v": { - "name": "Active voltage phase 2" - }, - "active_voltage_l3_v": { - "name": "Active voltage phase 3" - }, - "active_current_l1_a": { - "name": "Active current phase 1" - }, - "active_current_l2_a": { - "name": "Active current phase 2" - }, - "active_current_l3_a": { - "name": "Active current phase 3" + "active_current_phase_a": { + "name": "Active current phase {phase}" }, "active_frequency_hz": { "name": "Active frequency" }, - "voltage_sag_l1_count": { - "name": "Voltage sags detected phase 1" + "voltage_sag_phase_count": { + "name": "Voltage sags detected phase {phase}" }, - "voltage_sag_l2_count": { - "name": "Voltage sags detected phase 2" - }, - "voltage_sag_l3_count": { - "name": "Voltage sags detected phase 3" - }, - "voltage_swell_l1_count": { - "name": "Voltage swells detected phase 1" - }, - "voltage_swell_l2_count": { - "name": "Voltage swells detected phase 2" - }, - "voltage_swell_l3_count": { - "name": "Voltage swells detected phase 3" + "voltage_swell_phase_count": { + "name": "Voltage swells detected phase {phase}" }, "any_power_fail_count": { "name": "Power failures detected" diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 7d98a10089f..cc5800acd7f 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -353,7 +353,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l1_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l1_a', 'unit_of_measurement': , }) @@ -433,7 +433,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l2_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l2_a', 'unit_of_measurement': , }) @@ -513,7 +513,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l3_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l3_a', 'unit_of_measurement': , }) @@ -759,7 +759,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -842,7 +842,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -925,7 +925,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) @@ -1094,7 +1094,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l1_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l1_v', 'unit_of_measurement': , }) @@ -1174,7 +1174,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l2_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l2_v', 'unit_of_measurement': , }) @@ -1254,7 +1254,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l3_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l3_v', 'unit_of_measurement': , }) @@ -2020,7 +2020,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', 'unit_of_measurement': , }) @@ -2100,7 +2100,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t2_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', 'unit_of_measurement': , }) @@ -2180,7 +2180,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t3_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', 'unit_of_measurement': , }) @@ -2260,7 +2260,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t4_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', 'unit_of_measurement': , }) @@ -2420,7 +2420,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', 'unit_of_measurement': , }) @@ -2500,7 +2500,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t2_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', 'unit_of_measurement': , }) @@ -2580,7 +2580,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t3_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', 'unit_of_measurement': , }) @@ -2660,7 +2660,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t4_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', 'unit_of_measurement': , }) @@ -2898,7 +2898,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l1_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', 'unit_of_measurement': None, }) @@ -2973,7 +2973,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l2_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', 'unit_of_measurement': None, }) @@ -3048,7 +3048,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l3_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', 'unit_of_measurement': None, }) @@ -3123,7 +3123,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l1_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', 'unit_of_measurement': None, }) @@ -3198,7 +3198,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l2_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', 'unit_of_measurement': None, }) @@ -3273,7 +3273,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l3_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', 'unit_of_measurement': None, }) @@ -4320,7 +4320,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l1_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l1_a', 'unit_of_measurement': , }) @@ -4400,7 +4400,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l2_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l2_a', 'unit_of_measurement': , }) @@ -4480,7 +4480,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_current_l3_a', + 'translation_key': 'active_current_phase_a', 'unique_id': 'aabbccddeeff_active_current_l3_a', 'unit_of_measurement': , }) @@ -4726,7 +4726,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -4809,7 +4809,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -4892,7 +4892,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) @@ -4972,7 +4972,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l1_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l1_v', 'unit_of_measurement': , }) @@ -5052,7 +5052,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l2_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l2_v', 'unit_of_measurement': , }) @@ -5132,7 +5132,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_voltage_l3_v', + 'translation_key': 'active_voltage_phase_v', 'unique_id': 'aabbccddeeff_active_voltage_l3_v', 'unit_of_measurement': , }) @@ -5598,7 +5598,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t1_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', 'unit_of_measurement': , }) @@ -5678,7 +5678,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t2_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', 'unit_of_measurement': , }) @@ -5758,7 +5758,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t3_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', 'unit_of_measurement': , }) @@ -5838,7 +5838,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_export_t4_kwh', + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', 'unit_of_measurement': , }) @@ -5998,7 +5998,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t1_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', 'unit_of_measurement': , }) @@ -6078,7 +6078,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t2_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', 'unit_of_measurement': , }) @@ -6158,7 +6158,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t3_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', 'unit_of_measurement': , }) @@ -6238,7 +6238,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'total_energy_import_t4_kwh', + 'translation_key': 'total_energy_import_tariff_kwh', 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', 'unit_of_measurement': , }) @@ -6476,7 +6476,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l1_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', 'unit_of_measurement': None, }) @@ -6551,7 +6551,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l2_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', 'unit_of_measurement': None, }) @@ -6626,7 +6626,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_sag_l3_count', + 'translation_key': 'voltage_sag_phase_count', 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', 'unit_of_measurement': None, }) @@ -6701,7 +6701,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l1_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', 'unit_of_measurement': None, }) @@ -6776,7 +6776,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l2_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', 'unit_of_measurement': None, }) @@ -6851,7 +6851,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'voltage_swell_l3_count', + 'translation_key': 'voltage_swell_phase_count', 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', 'unit_of_measurement': None, }) @@ -7014,7 +7014,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -7807,7 +7807,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -8287,7 +8287,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l1_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l1_w', 'unit_of_measurement': , }) @@ -8370,7 +8370,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l2_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l2_w', 'unit_of_measurement': , }) @@ -8453,7 +8453,7 @@ 'platform': 'homewizard', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'active_power_l3_w', + 'translation_key': 'active_power_phase_w', 'unique_id': 'aabbccddeeff_active_power_l3_w', 'unit_of_measurement': , }) From 53b73bd0bd371b0868832293ad3f31341b1459fa Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 25 Jan 2024 13:25:17 +0100 Subject: [PATCH 1029/1544] Make device tracker latitude and longitude optional (#108838) * Make device tracker latitude and longitude optional * Update test --- homeassistant/components/device_tracker/config_entry.py | 4 ++-- tests/components/device_tracker/test_config_entry.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index c169c78cacc..20ac365b33b 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -241,12 +241,12 @@ class TrackerEntity(BaseTrackerEntity): @property def latitude(self) -> float | None: """Return latitude value of the device.""" - raise NotImplementedError + return None @property def longitude(self) -> float | None: """Return longitude value of the device.""" - raise NotImplementedError + return None @property def state(self) -> str | None: diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index fe52ec1219a..ba258af068e 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -506,13 +506,10 @@ def test_tracker_entity() -> None: entity = TrackerEntity() with pytest.raises(NotImplementedError): assert entity.source_type is None - with pytest.raises(NotImplementedError): - assert entity.latitude is None - with pytest.raises(NotImplementedError): - assert entity.longitude is None + assert entity.latitude is None + assert entity.longitude is None assert entity.location_name is None - with pytest.raises(NotImplementedError): - assert entity.state is None + assert entity.state is None assert entity.battery_level is None assert entity.should_poll is False assert entity.force_update is True From 4138b5c3085e95e8720b96e401db3bfc1ad9a1fc Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 25 Jan 2024 14:45:11 +0100 Subject: [PATCH 1030/1544] Reduce log level for creating ZHA cluster handler (#108809) --- homeassistant/components/zha/core/endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index eb91ec96c59..490a4e05ea2 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -132,7 +132,7 @@ class Endpoint: if not cluster_handler_class.matches(cluster, self): cluster_handler_class = ClusterHandler - _LOGGER.info( + _LOGGER.debug( "Creating cluster handler for cluster id: %s class: %s", cluster_id, cluster_handler_class, From 74a60929e422ea8df20289f1df72c13c2b563580 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 25 Jan 2024 08:47:26 -0500 Subject: [PATCH 1031/1544] Use Zigpy definition objects in ZHA cluster handlers (#108383) * use zigpy def objects in ZHA cluster handlers * shorten with direct imports * shorten with rename due to clash --- .../zha/core/cluster_handlers/__init__.py | 2 +- .../zha/core/cluster_handlers/closures.py | 14 +- .../zha/core/cluster_handlers/general.py | 211 +++++++++++++----- .../core/cluster_handlers/homeautomation.py | 130 ++++++++--- .../zha/core/cluster_handlers/hvac.py | 172 +++++++++----- .../zha/core/cluster_handlers/lighting.py | 101 +++++---- .../zha/core/cluster_handlers/measurement.py | 41 ++-- .../zha/core/cluster_handlers/security.py | 61 +++-- .../zha/core/cluster_handlers/smartenergy.py | 69 ++++-- 9 files changed, 538 insertions(+), 263 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 00439343e81..c72d84adecd 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -553,7 +553,7 @@ class ClusterHandler(LogMixin): class ZDOClusterHandler(LogMixin): """Cluster handler for ZDO events.""" - def __init__(self, device): + def __init__(self, device) -> None: """Initialize ZDOClusterHandler.""" self.name = CLUSTER_HANDLER_ZDO self._cluster = device.device.endpoints[0] diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 16c7aef89ad..46fb6d5a538 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -22,15 +22,23 @@ class DoorLockClusterHandler(ClusterHandler): _value_attribute = 0 REPORT_CONFIG = ( - AttrReportConfig(attr="lock_state", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig( + attr=closures.DoorLock.AttributeDefs.lock_state.name, + config=REPORT_CONFIG_IMMEDIATE, + ), ) async def async_update(self): """Retrieve latest state.""" - result = await self.get_attribute_value("lock_state", from_cache=True) + result = await self.get_attribute_value( + closures.DoorLock.AttributeDefs.lock_state.name, from_cache=True + ) if result is not None: self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + closures.DoorLock.AttributeDefs.lock_state.id, + closures.DoorLock.AttributeDefs.lock_state.name, + result, ) @callback diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 8bc6902b4ff..aee66748461 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -50,7 +50,10 @@ class AnalogInput(ClusterHandler): """Analog Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.AnalogInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -60,61 +63,76 @@ class AnalogOutput(ClusterHandler): """Analog Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.AnalogOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) ZCL_INIT_ATTRS = { - "min_present_value": True, - "max_present_value": True, - "resolution": True, - "relinquish_default": True, - "description": True, - "engineering_units": True, - "application_type": True, + general.AnalogOutput.AttributeDefs.min_present_value.name: True, + general.AnalogOutput.AttributeDefs.max_present_value.name: True, + general.AnalogOutput.AttributeDefs.resolution.name: True, + general.AnalogOutput.AttributeDefs.relinquish_default.name: True, + general.AnalogOutput.AttributeDefs.description.name: True, + general.AnalogOutput.AttributeDefs.engineering_units.name: True, + general.AnalogOutput.AttributeDefs.application_type.name: True, } @property def present_value(self) -> float | None: """Return cached value of present_value.""" - return self.cluster.get("present_value") + return self.cluster.get(general.AnalogOutput.AttributeDefs.present_value.name) @property def min_present_value(self) -> float | None: """Return cached value of min_present_value.""" - return self.cluster.get("min_present_value") + return self.cluster.get( + general.AnalogOutput.AttributeDefs.min_present_value.name + ) @property def max_present_value(self) -> float | None: """Return cached value of max_present_value.""" - return self.cluster.get("max_present_value") + return self.cluster.get( + general.AnalogOutput.AttributeDefs.max_present_value.name + ) @property def resolution(self) -> float | None: """Return cached value of resolution.""" - return self.cluster.get("resolution") + return self.cluster.get(general.AnalogOutput.AttributeDefs.resolution.name) @property def relinquish_default(self) -> float | None: """Return cached value of relinquish_default.""" - return self.cluster.get("relinquish_default") + return self.cluster.get( + general.AnalogOutput.AttributeDefs.relinquish_default.name + ) @property def description(self) -> str | None: """Return cached value of description.""" - return self.cluster.get("description") + return self.cluster.get(general.AnalogOutput.AttributeDefs.description.name) @property def engineering_units(self) -> int | None: """Return cached value of engineering_units.""" - return self.cluster.get("engineering_units") + return self.cluster.get( + general.AnalogOutput.AttributeDefs.engineering_units.name + ) @property def application_type(self) -> int | None: """Return cached value of application_type.""" - return self.cluster.get("application_type") + return self.cluster.get( + general.AnalogOutput.AttributeDefs.application_type.name + ) async def async_set_present_value(self, value: float) -> None: """Update present_value.""" - await self.write_attributes_safe({"present_value": value}) + await self.write_attributes_safe( + {general.AnalogOutput.AttributeDefs.present_value.name: value} + ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) @@ -122,7 +140,10 @@ class AnalogValue(ClusterHandler): """Analog Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.AnalogValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -171,7 +192,10 @@ class BinaryInput(ClusterHandler): """Binary Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.BinaryInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -180,7 +204,10 @@ class BinaryOutput(ClusterHandler): """Binary Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.BinaryOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -189,7 +216,10 @@ class BinaryValue(ClusterHandler): """Binary Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.BinaryValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -206,7 +236,7 @@ class DeviceTemperature(ClusterHandler): REPORT_CONFIG = ( { - "attr": "current_temperature", + "attr": general.DeviceTemperature.AttributeDefs.current_temperature.name, "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), }, ) @@ -237,7 +267,7 @@ class Identify(ClusterHandler): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd == "trigger_effect": + if cmd == general.Identify.ServerCommandDefs.trigger_effect.name: self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) @@ -252,35 +282,49 @@ class LevelControlClusterHandler(ClusterHandler): """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 - REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) + REPORT_CONFIG = ( + AttrReportConfig( + attr=general.LevelControl.AttributeDefs.current_level.name, + config=REPORT_CONFIG_ASAP, + ), + ) ZCL_INIT_ATTRS = { - "on_off_transition_time": True, - "on_level": True, - "on_transition_time": True, - "off_transition_time": True, - "default_move_rate": True, - "start_up_current_level": True, + general.LevelControl.AttributeDefs.on_off_transition_time.name: True, + general.LevelControl.AttributeDefs.on_level.name: True, + general.LevelControl.AttributeDefs.on_transition_time.name: True, + general.LevelControl.AttributeDefs.off_transition_time.name: True, + general.LevelControl.AttributeDefs.default_move_rate.name: True, + general.LevelControl.AttributeDefs.start_up_current_level.name: True, } @property def current_level(self) -> int | None: """Return cached value of the current_level attribute.""" - return self.cluster.get("current_level") + return self.cluster.get(general.LevelControl.AttributeDefs.current_level.name) @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd in ("move_to_level", "move_to_level_with_on_off"): + if cmd in ( + general.LevelControl.ServerCommandDefs.move_to_level.name, + general.LevelControl.ServerCommandDefs.move_to_level_with_on_off.name, + ): self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) - elif cmd in ("move", "move_with_on_off"): + elif cmd in ( + general.LevelControl.ServerCommandDefs.move.name, + general.LevelControl.ServerCommandDefs.move_with_on_off.name, + ): # We should dim slowly -- for now, just step once rate = args[1] if args[0] == 0xFF: rate = 10 # Should read default move rate self.dispatch_level_change(SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) - elif cmd in ("step", "step_with_on_off"): + elif cmd in ( + general.LevelControl.ServerCommandDefs.step.name, + general.LevelControl.ServerCommandDefs.step_with_on_off.name, + ): # Step (technically may change on/off) self.dispatch_level_change( SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1] @@ -303,7 +347,10 @@ class MultistateInput(ClusterHandler): """Multistate Input cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.MultistateInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -314,7 +361,10 @@ class MultistateOutput(ClusterHandler): """Multistate Output cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.MultistateOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -323,7 +373,10 @@ class MultistateValue(ClusterHandler): """Multistate Value cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=general.MultistateValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -337,10 +390,13 @@ class OnOffClientClusterHandler(ClientClusterHandler): class OnOffClusterHandler(ClusterHandler): """Cluster handler for the OnOff Zigbee cluster.""" - ON_OFF = general.OnOff.attributes_by_name["on_off"].id - REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) + REPORT_CONFIG = ( + AttrReportConfig( + attr=general.OnOff.AttributeDefs.on_off.name, config=REPORT_CONFIG_IMMEDIATE + ), + ) ZCL_INIT_ATTRS = { - "start_up_on_off": True, + general.OnOff.AttributeDefs.start_up_on_off.name: True, } def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -366,32 +422,46 @@ class OnOffClusterHandler(ClusterHandler): @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" - return self.cluster.get("on_off") + return self.cluster.get(general.OnOff.AttributeDefs.on_off.name) async def turn_on(self) -> None: """Turn the on off cluster on.""" result = await self.on() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn on: {result[1]}") - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.true + ) async def turn_off(self) -> None: """Turn the on off cluster off.""" result = await self.off() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn off: {result[1]}") - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.false + ) @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd in ("off", "off_with_effect"): - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) - elif cmd in ("on", "on_with_recall_global_scene"): - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) - elif cmd == "on_with_timed_off": + if cmd in ( + general.OnOff.ServerCommandDefs.off.name, + general.OnOff.ServerCommandDefs.off_with_effect.name, + ): + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.false + ) + elif cmd in ( + general.OnOff.ServerCommandDefs.on.name, + general.OnOff.ServerCommandDefs.on_with_recall_global_scene.name, + ): + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.true + ) + elif cmd == general.OnOff.ServerCommandDefs.on_with_timed_off.name: should_accept = args[0] on_time = args[1] # 0 is always accept 1 is only accept when already on @@ -399,7 +469,9 @@ class OnOffClusterHandler(ClusterHandler): if self._off_listener is not None: self._off_listener() self._off_listener = None - self.cluster.update_attribute(self.ON_OFF, t.Bool.true) + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.true + ) if on_time > 0: self._off_listener = async_call_later( self._endpoint.device.hass, @@ -407,20 +479,27 @@ class OnOffClusterHandler(ClusterHandler): self.set_to_off, ) elif cmd == "toggle": - self.cluster.update_attribute(self.ON_OFF, not bool(self.on_off)) + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, not bool(self.on_off) + ) @callback def set_to_off(self, *_): """Set the state to off.""" self._off_listener = None - self.cluster.update_attribute(self.ON_OFF, t.Bool.false) + self.cluster.update_attribute( + general.OnOff.AttributeDefs.on_off.id, t.Bool.false + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" - if attrid == self.ON_OFF: + if attrid == general.OnOff.AttributeDefs.on_off.id: self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, "on_off", value + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + general.OnOff.AttributeDefs.on_off.name, + value, ) async def async_update(self): @@ -429,7 +508,9 @@ class OnOffClusterHandler(ClusterHandler): return from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + await self.get_attribute_value( + general.OnOff.AttributeDefs.on_off.id, from_cache=from_cache + ) await super().async_update() @@ -482,7 +563,11 @@ class PollControl(ClusterHandler): async def async_configure_cluster_handler_specific(self) -> None: """Configure cluster handler: set check-in interval.""" - await self.write_attributes_safe({"checkin_interval": self.CHECKIN_INTERVAL}) + await self.write_attributes_safe( + { + general.PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL + } + ) @callback def cluster_command( @@ -496,7 +581,7 @@ class PollControl(ClusterHandler): self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) self.zha_send_event(cmd_name, args) - if cmd_name == "checkin": + if cmd_name == general.PollControl.ClientCommandDefs.checkin.name: self.cluster.create_catching_task(self.check_in_response(tsn)) async def check_in_response(self, tsn: int) -> None: @@ -519,17 +604,21 @@ class PowerConfigurationClusterHandler(ClusterHandler): """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( - AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), AttrReportConfig( - attr="battery_percentage_remaining", config=REPORT_CONFIG_BATTERY_SAVE + attr=general.PowerConfiguration.AttributeDefs.battery_voltage.name, + config=REPORT_CONFIG_BATTERY_SAVE, + ), + AttrReportConfig( + attr=general.PowerConfiguration.AttributeDefs.battery_percentage_remaining.name, + config=REPORT_CONFIG_BATTERY_SAVE, ), ) def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: """Initialize cluster handler specific attrs.""" attributes = [ - "battery_size", - "battery_quantity", + general.PowerConfiguration.AttributeDefs.battery_size.name, + general.PowerConfiguration.AttributeDefs.battery_quantity.name, ] return self.get_attributes( attributes, from_cache=from_cache, only_cache=from_cache diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index a379db54dac..484ec9f423e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -4,6 +4,7 @@ from __future__ import annotations import enum from zigpy.zcl.clusters import homeautomation +from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from .. import registries from ..const import ( @@ -43,9 +44,7 @@ class Diagnostic(ClusterHandler): """Diagnostic cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ElectricalMeasurement.cluster_id -) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ElectricalMeasurement.cluster_id) class ElectricalMeasurementClusterHandler(ClusterHandler): """Cluster handler that polls active power level.""" @@ -65,29 +64,56 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): POWER_QUALITY_MEASUREMENT = 256 REPORT_CONFIG = ( - AttrReportConfig(attr="active_power", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="active_power_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="apparent_power", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_current", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_current_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="rms_voltage", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="rms_voltage_max", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="ac_frequency", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="ac_frequency_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.apparent_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency_max.name, + config=REPORT_CONFIG_DEFAULT, + ), ) ZCL_INIT_ATTRS = { - "ac_current_divisor": True, - "ac_current_multiplier": True, - "ac_power_divisor": True, - "ac_power_multiplier": True, - "ac_voltage_divisor": True, - "ac_voltage_multiplier": True, - "ac_frequency_divisor": True, - "ac_frequency_multiplier": True, - "measurement_type": True, - "power_divisor": True, - "power_multiplier": True, - "power_factor": True, + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.measurement_type.name: True, + ElectricalMeasurement.AttributeDefs.power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.power_factor.name: True, } async def async_update(self): @@ -113,51 +139,89 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): @property def ac_current_divisor(self) -> int: """Return ac current divisor.""" - return self.cluster.get("ac_current_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name + ) + or 1 + ) @property def ac_current_multiplier(self) -> int: """Return ac current multiplier.""" - return self.cluster.get("ac_current_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name + ) + or 1 + ) @property def ac_voltage_divisor(self) -> int: """Return ac voltage divisor.""" - return self.cluster.get("ac_voltage_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name + ) + or 1 + ) @property def ac_voltage_multiplier(self) -> int: """Return ac voltage multiplier.""" - return self.cluster.get("ac_voltage_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name + ) + or 1 + ) @property def ac_frequency_divisor(self) -> int: """Return ac frequency divisor.""" - return self.cluster.get("ac_frequency_divisor") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name + ) + or 1 + ) @property def ac_frequency_multiplier(self) -> int: """Return ac frequency multiplier.""" - return self.cluster.get("ac_frequency_multiplier") or 1 + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name + ) + or 1 + ) @property def ac_power_divisor(self) -> int: """Return active power divisor.""" return self.cluster.get( - "ac_power_divisor", self.cluster.get("power_divisor") or 1 + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_divisor.name) + or 1, ) @property def ac_power_multiplier(self) -> int: """Return active power divisor.""" return self.cluster.get( - "ac_power_multiplier", self.cluster.get("power_multiplier") or 1 + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_multiplier.name) + or 1, ) @property def measurement_type(self) -> str | None: """Return Measurement type.""" - if (meas_type := self.cluster.get("measurement_type")) is None: + if ( + meas_type := self.cluster.get( + ElectricalMeasurement.AttributeDefs.measurement_type.name + ) + ) is None: return None meas_type = self.MeasurementType(meas_type) diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 5e41785a6d8..f5b70798c2d 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Any from zigpy.zcl.clusters import hvac +from zigpy.zcl.clusters.hvac import Fan, Thermostat from homeassistant.core import callback @@ -30,32 +31,36 @@ class Dehumidification(ClusterHandler): """Dehumidification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Fan.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Fan.cluster_id) class FanClusterHandler(ClusterHandler): """Fan cluster handler.""" _value_attribute = 0 - REPORT_CONFIG = (AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_OP),) - ZCL_INIT_ATTRS = {"fan_mode_sequence": True} + REPORT_CONFIG = ( + AttrReportConfig(attr=Fan.AttributeDefs.fan_mode.name, config=REPORT_CONFIG_OP), + ) + ZCL_INIT_ATTRS = {Fan.AttributeDefs.fan_mode_sequence.name: True} @property def fan_mode(self) -> int | None: """Return current fan mode.""" - return self.cluster.get("fan_mode") + return self.cluster.get(Fan.AttributeDefs.fan_mode.name) @property def fan_mode_sequence(self) -> int | None: """Return possible fan mode speeds.""" - return self.cluster.get("fan_mode_sequence") + return self.cluster.get(Fan.AttributeDefs.fan_mode_sequence.name) async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" - await self.write_attributes_safe({"fan_mode": value}) + await self.write_attributes_safe({Fan.AttributeDefs.fan_mode.name: value}) async def async_update(self) -> None: """Retrieve latest state.""" - await self.get_attribute_value("fan_mode", from_cache=False) + await self.get_attribute_value( + Fan.AttributeDefs.fan_mode.name, from_cache=False + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -75,73 +80,110 @@ class Pump(ClusterHandler): """Pump cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Thermostat.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Thermostat.cluster_id) class ThermostatClusterHandler(ClusterHandler): """Thermostat cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), AttrReportConfig( - attr="occupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.local_temperature.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="occupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.occupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="unoccupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.occupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, ), AttrReportConfig( - attr="unoccupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + attr=Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.unoccupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_state.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.system_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_CLIMATE_DISCRETE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_cooling_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_heating_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, ), - AttrReportConfig(attr="running_mode", config=REPORT_CONFIG_CLIMATE), - AttrReportConfig(attr="running_state", config=REPORT_CONFIG_CLIMATE_DEMAND), - AttrReportConfig(attr="system_mode", config=REPORT_CONFIG_CLIMATE), - AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_CLIMATE_DISCRETE), - AttrReportConfig(attr="pi_cooling_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), - AttrReportConfig(attr="pi_heating_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), ) ZCL_INIT_ATTRS: dict[str, bool] = { - "abs_min_heat_setpoint_limit": True, - "abs_max_heat_setpoint_limit": True, - "abs_min_cool_setpoint_limit": True, - "abs_max_cool_setpoint_limit": True, - "ctrl_sequence_of_oper": False, - "max_cool_setpoint_limit": True, - "max_heat_setpoint_limit": True, - "min_cool_setpoint_limit": True, - "min_heat_setpoint_limit": True, - "local_temperature_calibration": True, + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name: False, + Thermostat.AttributeDefs.max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.local_temperature_calibration.name: True, } @property def abs_max_cool_setpoint_limit(self) -> int: """Absolute maximum cooling setpoint.""" - return self.cluster.get("abs_max_cool_setpoint_limit", 3200) + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name, 3200 + ) @property def abs_min_cool_setpoint_limit(self) -> int: """Absolute minimum cooling setpoint.""" - return self.cluster.get("abs_min_cool_setpoint_limit", 1600) + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name, 1600 + ) @property def abs_max_heat_setpoint_limit(self) -> int: """Absolute maximum heating setpoint.""" - return self.cluster.get("abs_max_heat_setpoint_limit", 3000) + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name, 3000 + ) @property def abs_min_heat_setpoint_limit(self) -> int: """Absolute minimum heating setpoint.""" - return self.cluster.get("abs_min_heat_setpoint_limit", 700) + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name, 700 + ) @property def ctrl_sequence_of_oper(self) -> int: """Control Sequence of operations attribute.""" - return self.cluster.get("ctrl_sequence_of_oper", 0xFF) + return self.cluster.get( + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name, 0xFF + ) @property def max_cool_setpoint_limit(self) -> int: """Maximum cooling setpoint.""" - sp_limit = self.cluster.get("max_cool_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_cool_setpoint_limit.name + ) if sp_limit is None: return self.abs_max_cool_setpoint_limit return sp_limit @@ -149,7 +191,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def min_cool_setpoint_limit(self) -> int: """Minimum cooling setpoint.""" - sp_limit = self.cluster.get("min_cool_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_cool_setpoint_limit.name + ) if sp_limit is None: return self.abs_min_cool_setpoint_limit return sp_limit @@ -157,7 +201,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def max_heat_setpoint_limit(self) -> int: """Maximum heating setpoint.""" - sp_limit = self.cluster.get("max_heat_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_heat_setpoint_limit.name + ) if sp_limit is None: return self.abs_max_heat_setpoint_limit return sp_limit @@ -165,7 +211,9 @@ class ThermostatClusterHandler(ClusterHandler): @property def min_heat_setpoint_limit(self) -> int: """Minimum heating setpoint.""" - sp_limit = self.cluster.get("min_heat_setpoint_limit") + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_heat_setpoint_limit.name + ) if sp_limit is None: return self.abs_min_heat_setpoint_limit return sp_limit @@ -173,57 +221,61 @@ class ThermostatClusterHandler(ClusterHandler): @property def local_temperature(self) -> int | None: """Thermostat temperature.""" - return self.cluster.get("local_temperature") + return self.cluster.get(Thermostat.AttributeDefs.local_temperature.name) @property def occupancy(self) -> int | None: """Is occupancy detected.""" - return self.cluster.get("occupancy") + return self.cluster.get(Thermostat.AttributeDefs.occupancy.name) @property def occupied_cooling_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self.cluster.get("occupied_cooling_setpoint") + return self.cluster.get(Thermostat.AttributeDefs.occupied_cooling_setpoint.name) @property def occupied_heating_setpoint(self) -> int | None: """Temperature when room is occupied.""" - return self.cluster.get("occupied_heating_setpoint") + return self.cluster.get(Thermostat.AttributeDefs.occupied_heating_setpoint.name) @property def pi_cooling_demand(self) -> int: """Cooling demand.""" - return self.cluster.get("pi_cooling_demand") + return self.cluster.get(Thermostat.AttributeDefs.pi_cooling_demand.name) @property def pi_heating_demand(self) -> int: """Heating demand.""" - return self.cluster.get("pi_heating_demand") + return self.cluster.get(Thermostat.AttributeDefs.pi_heating_demand.name) @property def running_mode(self) -> int | None: """Thermostat running mode.""" - return self.cluster.get("running_mode") + return self.cluster.get(Thermostat.AttributeDefs.running_mode.name) @property def running_state(self) -> int | None: """Thermostat running state, state of heat, cool, fan relays.""" - return self.cluster.get("running_state") + return self.cluster.get(Thermostat.AttributeDefs.running_state.name) @property def system_mode(self) -> int | None: """System mode.""" - return self.cluster.get("system_mode") + return self.cluster.get(Thermostat.AttributeDefs.system_mode.name) @property def unoccupied_cooling_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self.cluster.get("unoccupied_cooling_setpoint") + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + ) @property def unoccupied_heating_setpoint(self) -> int | None: """Temperature when room is not occupied.""" - return self.cluster.get("unoccupied_heating_setpoint") + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -241,14 +293,20 @@ class ThermostatClusterHandler(ClusterHandler): async def async_set_operation_mode(self, mode) -> bool: """Set Operation mode.""" - await self.write_attributes_safe({"system_mode": mode}) + await self.write_attributes_safe( + {Thermostat.AttributeDefs.system_mode.name: mode} + ) return True async def async_set_heating_setpoint( self, temperature: int, is_away: bool = False ) -> bool: """Set heating setpoint.""" - attr = "unoccupied_heating_setpoint" if is_away else "occupied_heating_setpoint" + attr = ( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_heating_setpoint.name + ) await self.write_attributes_safe({attr: temperature}) return True @@ -256,15 +314,21 @@ class ThermostatClusterHandler(ClusterHandler): self, temperature: int, is_away: bool = False ) -> bool: """Set cooling setpoint.""" - attr = "unoccupied_cooling_setpoint" if is_away else "occupied_cooling_setpoint" + attr = ( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_cooling_setpoint.name + ) await self.write_attributes_safe({attr: temperature}) return True async def get_occupancy(self) -> bool | None: """Get unreportable occupancy attribute.""" - res, fail = await self.read_attributes(["occupancy"]) + res, fail = await self.read_attributes( + [Thermostat.AttributeDefs.occupancy.name] + ) self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) - if "occupancy" not in res: + if Thermostat.AttributeDefs.occupancy.name not in res: return None return bool(self.occupancy) diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 5f1e52fa241..515c2e88d10 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -2,6 +2,7 @@ from __future__ import annotations from zigpy.zcl.clusters import lighting +from zigpy.zcl.clusters.lighting import Color from homeassistant.backports.functools import cached_property @@ -15,88 +16,107 @@ class Ballast(ClusterHandler): """Ballast cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) class ColorClientClusterHandler(ClientClusterHandler): """Color client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(lighting.Color.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Color.cluster_id) +@registries.BINDABLE_CLUSTERS.register(Color.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) class ColorClusterHandler(ClusterHandler): """Color cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=Color.AttributeDefs.current_x.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_y.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_hue.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_saturation.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.color_temperature.name, + config=REPORT_CONFIG_DEFAULT, + ), ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 ZCL_INIT_ATTRS = { - "color_mode": False, - "color_temp_physical_min": True, - "color_temp_physical_max": True, - "color_capabilities": True, - "color_loop_active": False, - "enhanced_current_hue": False, - "start_up_color_temperature": True, - "options": True, + Color.AttributeDefs.color_mode.name: False, + Color.AttributeDefs.color_temp_physical_min.name: True, + Color.AttributeDefs.color_temp_physical_max.name: True, + Color.AttributeDefs.color_capabilities.name: True, + Color.AttributeDefs.color_loop_active.name: False, + Color.AttributeDefs.enhanced_current_hue.name: False, + Color.AttributeDefs.start_up_color_temperature.name: True, + Color.AttributeDefs.options.name: True, } @cached_property - def color_capabilities(self) -> lighting.Color.ColorCapabilities: + def color_capabilities(self) -> Color.ColorCapabilities: """Return ZCL color capabilities of the light.""" - color_capabilities = self.cluster.get("color_capabilities") + color_capabilities = self.cluster.get( + Color.AttributeDefs.color_capabilities.name + ) if color_capabilities is None: - return lighting.Color.ColorCapabilities.XY_attributes - return lighting.Color.ColorCapabilities(color_capabilities) + return Color.ColorCapabilities.XY_attributes + return Color.ColorCapabilities(color_capabilities) @property def color_mode(self) -> int | None: """Return cached value of the color_mode attribute.""" - return self.cluster.get("color_mode") + return self.cluster.get(Color.AttributeDefs.color_mode.name) @property def color_loop_active(self) -> int | None: """Return cached value of the color_loop_active attribute.""" - return self.cluster.get("color_loop_active") + return self.cluster.get(Color.AttributeDefs.color_loop_active.name) @property def color_temperature(self) -> int | None: """Return cached value of color temperature.""" - return self.cluster.get("color_temperature") + return self.cluster.get(Color.AttributeDefs.color_temperature.name) @property def current_x(self) -> int | None: """Return cached value of the current_x attribute.""" - return self.cluster.get("current_x") + return self.cluster.get(Color.AttributeDefs.current_x.name) @property def current_y(self) -> int | None: """Return cached value of the current_y attribute.""" - return self.cluster.get("current_y") + return self.cluster.get(Color.AttributeDefs.current_y.name) @property def current_hue(self) -> int | None: """Return cached value of the current_hue attribute.""" - return self.cluster.get("current_hue") + return self.cluster.get(Color.AttributeDefs.current_hue.name) @property def enhanced_current_hue(self) -> int | None: """Return cached value of the enhanced_current_hue attribute.""" - return self.cluster.get("enhanced_current_hue") + return self.cluster.get(Color.AttributeDefs.enhanced_current_hue.name) @property def current_saturation(self) -> int | None: """Return cached value of the current_saturation attribute.""" - return self.cluster.get("current_saturation") + return self.cluster.get(Color.AttributeDefs.current_saturation.name) @property def min_mireds(self) -> int: """Return the coldest color_temp that this cluster handler supports.""" - min_mireds = self.cluster.get("color_temp_physical_min", self.MIN_MIREDS) + min_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_min.name, self.MIN_MIREDS + ) if min_mireds == 0: self.warning( ( @@ -111,7 +131,9 @@ class ColorClusterHandler(ClusterHandler): @property def max_mireds(self) -> int: """Return the warmest color_temp that this cluster handler supports.""" - max_mireds = self.cluster.get("color_temp_physical_max", self.MAX_MIREDS) + max_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_max.name, self.MAX_MIREDS + ) if max_mireds == 0: self.warning( ( @@ -128,8 +150,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports hue and saturation.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Hue_and_saturation - in self.color_capabilities + and Color.ColorCapabilities.Hue_and_saturation in self.color_capabilities ) @property @@ -137,7 +158,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports enhanced hue and saturation.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Enhanced_hue in self.color_capabilities + and Color.ColorCapabilities.Enhanced_hue in self.color_capabilities ) @property @@ -145,8 +166,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports xy.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.XY_attributes - in self.color_capabilities + and Color.ColorCapabilities.XY_attributes in self.color_capabilities ) @property @@ -154,8 +174,7 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports color temperature.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Color_temperature - in self.color_capabilities + and Color.ColorCapabilities.Color_temperature in self.color_capabilities ) or self.color_temperature is not None @property @@ -163,15 +182,15 @@ class ColorClusterHandler(ClusterHandler): """Return True if the cluster handler supports color loop.""" return ( self.color_capabilities is not None - and lighting.Color.ColorCapabilities.Color_loop in self.color_capabilities + and Color.ColorCapabilities.Color_loop in self.color_capabilities ) @property - def options(self) -> lighting.Color.Options: + def options(self) -> Color.Options: """Return ZCL options of the cluster handler.""" - return lighting.Color.Options(self.cluster.get("options", 0)) + return Color.Options(self.cluster.get(Color.AttributeDefs.options.name, 0)) @property def execute_if_off_supported(self) -> bool: """Return True if the cluster handler can execute commands when off.""" - return lighting.Color.Options.Execute_if_off in self.options + return Color.Options.Execute_if_off in self.options diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index 5249c196864..4df24c32fad 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -27,7 +27,10 @@ class FlowMeasurement(ClusterHandler): """Flow Measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=measurement.FlowMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -38,7 +41,10 @@ class IlluminanceLevelSensing(ClusterHandler): """Illuminance Level Sensing cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=measurement.IlluminanceLevelSensing.AttributeDefs.level_status.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -49,7 +55,10 @@ class IlluminanceMeasurement(ClusterHandler): """Illuminance Measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=measurement.IlluminanceMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -60,7 +69,10 @@ class OccupancySensing(ClusterHandler): """Occupancy Sensing cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig( + attr=measurement.OccupancySensing.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_IMMEDIATE, + ), ) def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -82,7 +94,10 @@ class PressureMeasurement(ClusterHandler): """Pressure measurement cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig( + attr=measurement.PressureMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), ) @@ -94,7 +109,7 @@ class RelativeHumidity(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.RelativeHumidity.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) @@ -108,7 +123,7 @@ class SoilMoisture(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.SoilMoisture.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) @@ -120,7 +135,7 @@ class LeafWetness(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.LeafWetness.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) @@ -134,7 +149,7 @@ class TemperatureMeasurement(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.TemperatureMeasurement.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), ), ) @@ -148,7 +163,7 @@ class CarbonMonoxideConcentration(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.CarbonMonoxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) @@ -162,7 +177,7 @@ class CarbonDioxideConcentration(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.CarbonDioxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) @@ -174,7 +189,7 @@ class PM25(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.PM25.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), ), ) @@ -188,7 +203,7 @@ class FormaldehydeConcentration(ClusterHandler): REPORT_CONFIG = ( AttrReportConfig( - attr="measured_value", + attr=measurement.FormaldehydeConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index 9c74a14daa8..ac28c5a72da 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -29,19 +29,6 @@ from . import ClusterHandler, ClusterHandlerStatus if TYPE_CHECKING: from ..endpoint import Endpoint -IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), -IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), -IAS_ACE_EMERGENCY = 0x0002 # ("emergency", (), False), -IAS_ACE_FIRE = 0x0003 # ("fire", (), False), -IAS_ACE_PANIC = 0x0004 # ("panic", (), False), -IAS_ACE_GET_ZONE_ID_MAP = 0x0005 # ("get_zone_id_map", (), False), -IAS_ACE_GET_ZONE_INFO = 0x0006 # ("get_zone_info", (t.uint8_t,), False), -IAS_ACE_GET_PANEL_STATUS = 0x0007 # ("get_panel_status", (), False), -IAS_ACE_GET_BYPASSED_ZONE_LIST = 0x0008 # ("get_bypassed_zone_list", (), False), -IAS_ACE_GET_ZONE_STATUS = ( - 0x0009 # ("get_zone_status", (t.uint8_t, t.uint8_t, t.Bool, t.bitmap16), False) -) -NAME = 0 SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" @@ -54,16 +41,16 @@ class IasAce(ClusterHandler): """Initialize IAS Ancillary Control Equipment cluster handler.""" super().__init__(cluster, endpoint) self.command_map: dict[int, Callable[..., Any]] = { - IAS_ACE_ARM: self.arm, - IAS_ACE_BYPASS: self._bypass, - IAS_ACE_EMERGENCY: self._emergency, - IAS_ACE_FIRE: self._fire, - IAS_ACE_PANIC: self._panic, - IAS_ACE_GET_ZONE_ID_MAP: self._get_zone_id_map, - IAS_ACE_GET_ZONE_INFO: self._get_zone_info, - IAS_ACE_GET_PANEL_STATUS: self._send_panel_status_response, - IAS_ACE_GET_BYPASSED_ZONE_LIST: self._get_bypassed_zone_list, - IAS_ACE_GET_ZONE_STATUS: self._get_zone_status, + AceCluster.ServerCommandDefs.arm.id: self.arm, + AceCluster.ServerCommandDefs.bypass.id: self._bypass, + AceCluster.ServerCommandDefs.emergency.id: self._emergency, + AceCluster.ServerCommandDefs.fire.id: self._fire, + AceCluster.ServerCommandDefs.panic.id: self._panic, + AceCluster.ServerCommandDefs.get_zone_id_map.id: self._get_zone_id_map, + AceCluster.ServerCommandDefs.get_zone_info.id: self._get_zone_info, + AceCluster.ServerCommandDefs.get_panel_status.id: self._send_panel_status_response, + AceCluster.ServerCommandDefs.get_bypassed_zone_list.id: self._get_bypassed_zone_list, + AceCluster.ServerCommandDefs.get_zone_status.id: self._get_zone_status, } self.arm_map: dict[AceCluster.ArmMode, Callable[..., Any]] = { AceCluster.ArmMode.Disarm: self._disarm, @@ -95,7 +82,7 @@ class IasAce(ClusterHandler): mode = AceCluster.ArmMode(arm_mode) self.zha_send_event( - self._cluster.server_commands[IAS_ACE_ARM].name, + AceCluster.ServerCommandDefs.arm.name, { "arm_mode": mode.value, "arm_mode_description": mode.name, @@ -191,7 +178,7 @@ class IasAce(ClusterHandler): def _bypass(self, zone_list, code) -> None: """Handle the IAS ACE bypass command.""" self.zha_send_event( - self._cluster.server_commands[IAS_ACE_BYPASS].name, + AceCluster.ServerCommandDefs.bypass.name, {"zone_list": zone_list, "code": code}, ) @@ -336,19 +323,23 @@ class IasWd(ClusterHandler): class IASZoneClusterHandler(ClusterHandler): """Cluster handler for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} + ZCL_INIT_ATTRS = { + IasZone.AttributeDefs.zone_status.name: False, + IasZone.AttributeDefs.zone_state.name: True, + IasZone.AttributeDefs.zone_type.name: True, + } @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" - if command_id == 0: + if command_id == IasZone.ClientCommandDefs.status_change_notification.id: zone_status = args[0] # update attribute cache with new zone status self.cluster.update_attribute( - IasZone.attributes_by_name["zone_status"].id, zone_status + IasZone.AttributeDefs.zone_status.id, zone_status ) self.debug("Updated alarm state: %s", zone_status) - elif command_id == 1: + elif command_id == IasZone.ClientCommandDefs.enroll.id: self.debug("Enroll requested") self._cluster.create_catching_task( self.enroll_response( @@ -358,7 +349,9 @@ class IASZoneClusterHandler(ClusterHandler): async def async_configure(self): """Configure IAS device.""" - await self.get_attribute_value("zone_type", from_cache=False) + await self.get_attribute_value( + IasZone.AttributeDefs.zone_type.name, from_cache=False + ) if self._endpoint.device.skip_configuration: self.debug("skipping IASZoneClusterHandler configuration") return @@ -369,7 +362,9 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe( + {IasZone.AttributeDefs.cie_addr.name: ieee} + ) self.debug( "wrote cie_addr: %s to '%s' cluster", str(ieee), @@ -396,10 +391,10 @@ class IASZoneClusterHandler(ClusterHandler): @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" - if attrid == IasZone.attributes_by_name["zone_status"].id: + if attrid == IasZone.AttributeDefs.zone_status.id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - "zone_status", + IasZone.AttributeDefs.zone_status.name, value, ) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 32e7899d413..d52d62897bc 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -67,41 +67,62 @@ class Messaging(ClusterHandler): """Messaging cluster handler.""" +SEAttrs = smartenergy.Metering.AttributeDefs + + @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Metering.cluster_id) class Metering(ClusterHandler): """Metering cluster handler.""" REPORT_CONFIG = ( - AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), - AttrReportConfig(attr="current_summ_delivered", config=REPORT_CONFIG_DEFAULT), AttrReportConfig( - attr="current_tier1_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.instantaneous_demand.name, + config=REPORT_CONFIG_OP, ), AttrReportConfig( - attr="current_tier2_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.current_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier3_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.current_tier1_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier4_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.current_tier2_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier5_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.current_tier3_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr="current_tier6_summ_delivered", config=REPORT_CONFIG_DEFAULT + attr=SEAttrs.current_tier4_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=SEAttrs.current_tier5_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=SEAttrs.current_tier6_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=SEAttrs.current_summ_received.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=SEAttrs.status.name, + config=REPORT_CONFIG_ASAP, ), - AttrReportConfig(attr="current_summ_received", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="status", config=REPORT_CONFIG_ASAP), ) ZCL_INIT_ATTRS = { - "demand_formatting": True, - "divisor": True, - "metering_device_type": True, - "multiplier": True, - "summation_formatting": True, - "unit_of_measure": True, + SEAttrs.demand_formatting.name: True, + SEAttrs.divisor.name: True, + SEAttrs.metering_device_type.name: True, + SEAttrs.multiplier.name: True, + SEAttrs.summation_formatting.name: True, + SEAttrs.unit_of_measure.name: True, } metering_device_type = { @@ -153,12 +174,12 @@ class Metering(ClusterHandler): @property def divisor(self) -> int: """Return divisor for the value.""" - return self.cluster.get("divisor") or 1 + return self.cluster.get(SEAttrs.divisor.name) or 1 @property def device_type(self) -> str | int | None: """Return metering device type.""" - dev_type = self.cluster.get("metering_device_type") + dev_type = self.cluster.get(SEAttrs.metering_device_type.name) if dev_type is None: return None return self.metering_device_type.get(dev_type, dev_type) @@ -166,14 +187,14 @@ class Metering(ClusterHandler): @property def multiplier(self) -> int: """Return multiplier for the value.""" - return self.cluster.get("multiplier") or 1 + return self.cluster.get(SEAttrs.multiplier.name) or 1 @property def status(self) -> int | None: """Return metering device status.""" - if (status := self.cluster.get("status")) is None: + if (status := self.cluster.get(SEAttrs.status.name)) is None: return None - if self.cluster.get("metering_device_type") == 0: + if self.cluster.get(SEAttrs.metering_device_type.name) == 0: # Electric metering device type return self.DeviceStatusElectric(status) return self.DeviceStatusDefault(status) @@ -181,18 +202,18 @@ class Metering(ClusterHandler): @property def unit_of_measurement(self) -> int: """Return unit of measurement.""" - return self.cluster.get("unit_of_measure") + return self.cluster.get(SEAttrs.unit_of_measure.name) async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( - "demand_formatting", 0xF9 + SEAttrs.demand_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._format_spec = self.get_formatting(fmting) fmting = self.cluster.get( - "summation_formatting", 0xF9 + SEAttrs.summation_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) From 24c9bddae07b4d1ab3fbe085edc386d7392bc9a9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 25 Jan 2024 09:23:37 -0500 Subject: [PATCH 1032/1544] Bump blinkpy to 0.22.6 (#108727) Remove update after snap - now handled in library --- homeassistant/components/blink/camera.py | 1 - homeassistant/components/blink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index f9c72e7e682..c90a44ad990 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -126,7 +126,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() - await self.coordinator.api.refresh() self.async_write_ha_state() def camera_image( diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 6e9d912f332..445a469b141 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.5"] + "requirements": ["blinkpy==0.22.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index bdc51d6c204..03d97116883 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -553,7 +553,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.5 +blinkpy==0.22.6 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb817549232..985dd79a868 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.5 +blinkpy==0.22.6 # homeassistant.components.blue_current bluecurrent-api==1.0.6 From 1b7109fb9557bbabda622d9892d88b3acb456857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 25 Jan 2024 18:52:03 +0200 Subject: [PATCH 1033/1544] Bump pyhuum to 0.7.10 (#108853) * Upgrade pyhuum 0.7.9 -> 0.7.10 This fixes dependency issues with Black and ruff * Update requirements_all.txt * Update requirements_test_all.txt --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 46256d15347..7629f529b91 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.9"] + "requirements": ["huum==0.7.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 03d97116883..96984837274 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ httplib2==0.20.4 huawei-lte-api==1.7.3 # homeassistant.components.huum -huum==0.7.9 +huum==0.7.10 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 985dd79a868..77159190742 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -864,7 +864,7 @@ httplib2==0.20.4 huawei-lte-api==1.7.3 # homeassistant.components.huum -huum==0.7.9 +huum==0.7.10 # homeassistant.components.hyperion hyperion-py==0.7.5 From bb8828c86f2c559d3cec734f8be665bc326aae33 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:15:35 +0100 Subject: [PATCH 1034/1544] Address late review on auth (#108852) --- homeassistant/auth/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 15094681454..f99e90dbc05 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -514,7 +514,7 @@ class AuthManager: def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None: """Remove expired refresh tokens.""" now = time.time() - for token in self._store.async_get_refresh_tokens()[:]: + for token in self._store.async_get_refresh_tokens(): if (expire_at := token.expire_at) is not None and expire_at <= now: self.async_remove_refresh_token(token) self._async_track_next_refresh_token_expiration() From faad9a75841a88992cd8cf26d2d3cb56f485f48b Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:52:30 +0100 Subject: [PATCH 1035/1544] Add ConfigFlow for Lupusec (#108740) * init support for config flow for lupusec * correctly iterate over BinarySensorDeviceClass values for device class * bump lupupy to 0.3.2 * Updated device info for lupusec * revert bump lupupy for separate pr * fixed lupusec test-cases * Change setup to async_setup * remove redundant check for hass.data.setdefault * init support for config flow for lupusec * correctly iterate over BinarySensorDeviceClass values for device class * bump lupupy to 0.3.2 * Updated device info for lupusec * revert bump lupupy for separate pr * fixed lupusec test-cases * Change setup to async_setup * remove redundant check for hass.data.setdefault * resolve merge error lupupy * connection check when setting up config entry * removed unique_id and device_info for separate pr * changed name to friendly name * renamed LUPUSEC_PLATFORMS to PLATFORMS * preparation for code review * necessary changes for pr * changed config access * duplicate entry check * types added for setup_entry and test_host_connection * removed name for lupusec system * removed config entry from LupusecDevice * fixes for sensors * added else block for try * added integration warning * pass config to config_flow * fix test cases for new config flow * added error strings * changed async_create_entry invocation * added tests for exception handling * use parametrize * use parametrize for tests * recover test * test unique id * import from yaml tests * import error test cases * Update tests/components/lupusec/test_config_flow.py Co-authored-by: Joost Lekkerkerker * fixed test case * removed superfluous test cases * self._async_abort_entries_match added * lib patching call * _async_abort_entries_match * patch lupupy lib instead of test connection * removed statements * test_flow_source_import_already_configured * Update homeassistant/components/lupusec/config_flow.py Co-authored-by: Joost Lekkerkerker * removed unique_id from mockentry * added __init__.py to .coveragerc --------- Co-authored-by: suaveolent Co-authored-by: Joost Lekkerkerker --- .coveragerc | 5 +- CODEOWNERS | 3 +- homeassistant/components/lupusec/__init__.py | 107 ++++++-- .../components/lupusec/alarm_control_panel.py | 16 +- .../components/lupusec/binary_sensor.py | 35 +-- .../components/lupusec/config_flow.py | 110 +++++++++ homeassistant/components/lupusec/const.py | 6 + .../components/lupusec/manifest.json | 3 +- homeassistant/components/lupusec/strings.json | 31 +++ homeassistant/components/lupusec/switch.py | 21 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/lupusec/__init__.py | 1 + tests/components/lupusec/test_config_flow.py | 231 ++++++++++++++++++ 15 files changed, 510 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/lupusec/config_flow.py create mode 100644 homeassistant/components/lupusec/const.py create mode 100644 homeassistant/components/lupusec/strings.json create mode 100644 tests/components/lupusec/__init__.py create mode 100644 tests/components/lupusec/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index d026723d500..0d02af162fb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -705,7 +705,10 @@ omit = homeassistant/components/loqed/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/sensor.py - homeassistant/components/lupusec/* + homeassistant/components/lupusec/__init__.py + homeassistant/components/lupusec/alarm_control_panel.py + homeassistant/components/lupusec/binary_sensor.py + homeassistant/components/lupusec/switch.py homeassistant/components/lutron/__init__.py homeassistant/components/lutron/binary_sensor.py homeassistant/components/lutron/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 9d1d2339d23..89e689b4325 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -757,7 +757,8 @@ build.json @home-assistant/supervisor /homeassistant/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck -/homeassistant/components/lupusec/ @majuss +/homeassistant/components/lupusec/ @majuss @suaveolent +/tests/components/lupusec/ @majuss @suaveolent /homeassistant/components/lutron/ @cdheiser @wilburCForce /tests/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron_caseta/ @swails @bdraco @danaues diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index 9beeb0f20ee..b55c203b0e7 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -5,19 +5,24 @@ import lupupy from lupupy.exceptions import LupusecException import voluptuous as vol -from homeassistant.components import persistent_notification +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .const import INTEGRATION_TITLE, ISSUE_PLACEHOLDER + _LOGGER = logging.getLogger(__name__) DOMAIN = "lupusec" @@ -39,36 +44,91 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -LUPUSEC_PLATFORMS = [ +PLATFORMS: list[Platform] = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH, ] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Lupusec component.""" +async def handle_async_init_result(hass: HomeAssistant, domain: str, conf: dict): + """Handle the result of the async_init to issue deprecated warnings.""" + flow = hass.config_entries.flow + result = await flow.async_init(domain, context={"source": SOURCE_IMPORT}, data=conf) + + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the lupusec integration.""" + + if DOMAIN not in config: + return True + conf = config[DOMAIN] - username = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] - ip_address = conf[CONF_IP_ADDRESS] - name = conf.get(CONF_NAME) + + hass.async_create_task(handle_async_init_result(hass, DOMAIN, conf)) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + + host = entry.data[CONF_HOST] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] try: - hass.data[DOMAIN] = LupusecSystem(username, password, ip_address, name) - except LupusecException as ex: - _LOGGER.error(ex) - - persistent_notification.create( - hass, - f"Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, + lupusec_system = await hass.async_add_executor_job( + LupusecSystem, + username, + password, + host, + ) + except LupusecException: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) + return False + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "Unknown error while trying to connect to Lupusec device at %s: %s", + host, + ex, ) return False - for platform in LUPUSEC_PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -76,16 +136,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class LupusecSystem: """Lupusec System class.""" - def __init__(self, username, password, ip_address, name): + def __init__(self, username, password, ip_address) -> None: """Initialize the system.""" self.lupusec = lupupy.Lupusec(username, password, ip_address) - self.name = name class LupusecDevice(Entity): """Representation of a Lupusec device.""" - def __init__(self, data, device): + def __init__(self, data, device) -> None: """Initialize a sensor for Lupusec device.""" self._data = data self._device = device diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 2ae0b5944bd..8dd2ecb8b9c 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -7,6 +7,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -15,28 +16,23 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" - if discovery_info is None: - return - - data = hass.data[LUPUSEC_DOMAIN] + data = hass.data[LUPUSEC_DOMAIN][config_entry.entry_id] alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())] - add_entities(alarm_devices) + async_add_devices(alarm_devices) class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index ee369baf8dd..0819d30e1fc 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -2,38 +2,41 @@ from __future__ import annotations from datetime import timedelta +import logging import lupupy.constants as CONST -from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN, LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) +_LOGGER = logging.getLogger(__name__) -def setup_platform( + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: - """Set up a sensor for an Lupusec device.""" - if discovery_info is None: - return + """Set up a binary sensors for a Lupusec device.""" - data = hass.data[LUPUSEC_DOMAIN] + data = hass.data[DOMAIN][config_entry.entry_id] device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR - devices = [] + sensors = [] for device in data.lupusec.get_devices(generic_type=device_types): - devices.append(LupusecBinarySensor(data, device)) + sensors.append(LupusecBinarySensor(data, device)) - add_entities(devices) + async_add_devices(sensors) class LupusecBinarySensor(LupusecDevice, BinarySensorEntity): @@ -47,6 +50,8 @@ class LupusecBinarySensor(LupusecDevice, BinarySensorEntity): @property def device_class(self): """Return the class of the binary sensor.""" - if self._device.generic_type not in DEVICE_CLASSES: + if self._device.generic_type not in ( + item.value for item in BinarySensorDeviceClass + ): return None return self._device.generic_type diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py new file mode 100644 index 00000000000..64d53ce51f4 --- /dev/null +++ b/homeassistant/components/lupusec/config_flow.py @@ -0,0 +1,110 @@ +""""Config flow for Lupusec integration.""" + +import logging +from typing import Any + +import lupupy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Lupusec config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + try: + await test_host_connection(self.hass, host, username, password) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + return self.async_create_entry( + title=host, + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_IP_ADDRESS], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + host = user_input[CONF_IP_ADDRESS] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + await test_host_connection(self.hass, host, username, password) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=user_input.get(CONF_NAME, host), + data={ + CONF_HOST: host, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + +async def test_host_connection( + hass: HomeAssistant, host: str, username: str, password: str +): + """Test if the host is reachable and is actually a Lupusec device.""" + + try: + await hass.async_add_executor_job(lupupy.Lupusec, username, password, host) + except lupupy.LupusecException: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) + raise CannotConnect + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/lupusec/const.py b/homeassistant/components/lupusec/const.py new file mode 100644 index 00000000000..08aee718440 --- /dev/null +++ b/homeassistant/components/lupusec/const.py @@ -0,0 +1,6 @@ +"""Constants for the Lupusec component.""" + +DOMAIN = "lupusec" + +INTEGRATION_TITLE = "Lupus Electronics LUPUSEC" +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=lupusec"} diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index 13a5ac62fee..630ca71410e 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -1,7 +1,8 @@ { "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", - "codeowners": ["@majuss"], + "codeowners": ["@majuss", "@suaveolent"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], diff --git a/homeassistant/components/lupusec/strings.json b/homeassistant/components/lupusec/strings.json new file mode 100644 index 00000000000..53f84c8b872 --- /dev/null +++ b/homeassistant/components/lupusec/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Lupus Electronics LUPUSEC connection", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", + "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", + "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 37a3b2ec969..582d72b7cfe 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -7,34 +7,31 @@ from typing import Any import lupupy.constants as CONST from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN, LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" - if discovery_info is None: - return - data = hass.data[LUPUSEC_DOMAIN] + data = hass.data[DOMAIN][config_entry.entry_id] device_types = CONST.TYPE_SWITCH - devices = [] + switches = [] for device in data.lupusec.get_devices(generic_type=device_types): - devices.append(LupusecSwitch(data, device)) + switches.append(LupusecSwitch(data, device)) - add_entities(devices) + async_add_devices(switches) class LupusecSwitch(LupusecDevice, SwitchEntity): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d63bdc23b12..17d4e6bcfa7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -289,6 +289,7 @@ FLOWS = { "lookin", "loqed", "luftdaten", + "lupusec", "lutron", "lutron_caseta", "lyric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0e9b46ea152..43bd3aa4c5d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3312,7 +3312,7 @@ "lupusec": { "name": "Lupus Electronics LUPUSEC", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "lutron": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77159190742..be8bee8e4e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -979,6 +979,9 @@ loqedAPI==2.1.8 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lupusec +lupupy==0.3.2 + # homeassistant.components.scrape lxml==5.1.0 diff --git a/tests/components/lupusec/__init__.py b/tests/components/lupusec/__init__.py new file mode 100644 index 00000000000..32d708e986b --- /dev/null +++ b/tests/components/lupusec/__init__.py @@ -0,0 +1 @@ +"""Define tests for the lupusec component.""" diff --git a/tests/components/lupusec/test_config_flow.py b/tests/components/lupusec/test_config_flow.py new file mode 100644 index 00000000000..5ef5f98ea00 --- /dev/null +++ b/tests/components/lupusec/test_config_flow.py @@ -0,0 +1,231 @@ +""""Unit tests for the Lupusec config flow.""" + +from unittest.mock import patch + +from lupupy import LupusecException +import pytest + +from homeassistant import config_entries +from homeassistant.components.lupusec.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_HOST: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +MOCK_IMPORT_STEP = { + CONF_IP_ADDRESS: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +MOCK_IMPORT_STEP_NAME = { + CONF_IP_ADDRESS: "test-host.lan", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_NAME: "test-name", +} + + +async def test_form_valid_input(hass: HomeAssistant) -> None: + """Test handling valid user input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + return_value=None, + ) as mock_initialize_lupusec: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == MOCK_DATA_STEP[CONF_HOST] + assert result2["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (LupusecException("Test lupusec exception"), "cannot_connect"), + (Exception("Test unknown exception"), "unknown"), + ], +) +async def test_flow_user_init_data_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test exceptions and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + side_effect=raise_error, + ) as mock_initialize_lupusec: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": text_error} + + assert len(mock_initialize_lupusec.mock_calls) == 1 + + # Recover + with patch( + "homeassistant.components.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + return_value=None, + ) as mock_initialize_lupusec: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == MOCK_DATA_STEP[CONF_HOST] + assert result3["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test duplicate config entry..""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DATA_STEP[CONF_HOST], + data=MOCK_DATA_STEP, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA_STEP, + ) + + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("mock_import_step", "mock_title"), + [ + (MOCK_IMPORT_STEP, MOCK_IMPORT_STEP[CONF_IP_ADDRESS]), + (MOCK_IMPORT_STEP_NAME, MOCK_IMPORT_STEP_NAME[CONF_NAME]), + ], +) +async def test_flow_source_import( + hass: HomeAssistant, mock_import_step, mock_title +) -> None: + """Test configuration import from YAML.""" + with patch( + "homeassistant.components.lupusec.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + return_value=None, + ) as mock_initialize_lupusec: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=mock_import_step, + ) + + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == mock_title + assert result["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (LupusecException("Test lupusec exception"), "cannot_connect"), + (Exception("Test unknown exception"), "unknown"), + ], +) +async def test_flow_source_import_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test exceptions and recovery.""" + + with patch( + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + side_effect=raise_error, + ) as mock_initialize_lupusec: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT_STEP, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + assert len(mock_initialize_lupusec.mock_calls) == 1 + + +async def test_flow_source_import_already_configured(hass: HomeAssistant) -> None: + """Test duplicate config entry..""" + + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DATA_STEP[CONF_HOST], + data=MOCK_DATA_STEP, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_IMPORT_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 7713cf377d0dae74bacec69b166bca0641432205 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Thu, 25 Jan 2024 16:46:33 -0300 Subject: [PATCH 1036/1544] Add utility meter option for the sensor to always be available (#103481) * Adds option for the sensor to always be available * Remove logger debug * Add migration config entry version * Update homeassistant/components/utility_meter/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/utility_meter/sensor.py Co-authored-by: Robert Resch * Remove migration config entry version * Change CONF_SENSOR_ALWAYS_AVAILABLE optional in CONFIG_SCHEMA * Remove CONF_SENSOR_ALWAYS_AVAILABLE in tests * Remove CONF_SENSOR_ALWAYS_AVAILABLE in tests * Remove CONF_SENSOR_ALWAYS_AVAILABLE in tests * Add option in yaml * Update homeassistant/components/utility_meter/strings.json Co-authored-by: Robert Resch * Update homeassistant/components/utility_meter/strings.json Co-authored-by: Robert Resch * Changes tests * Add test_always_available * Use freezegun * Update homeassistant/components/utility_meter/strings.json --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- .../components/utility_meter/__init__.py | 2 + .../components/utility_meter/config_flow.py | 9 ++ .../components/utility_meter/const.py | 1 + .../components/utility_meter/sensor.py | 17 ++- .../components/utility_meter/strings.json | 4 + .../utility_meter/test_config_flow.py | 68 +++++++++++- tests/components/utility_meter/test_sensor.py | 101 ++++++++++++++++++ 7 files changed, 199 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index ffe6d7f5433..4b99611684a 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFF, CONF_TARIFF_ENTITY, @@ -93,6 +94,7 @@ METER_CONFIG_SCHEMA = vol.Schema( cv.ensure_list, vol.Unique(), [cv.string] ), vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern, + vol.Optional(CONF_SENSOR_ALWAYS_AVAILABLE, default=False): cv.boolean, }, period_or_cron, ) diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index eb5c19941dc..0ca9ee12f58 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -23,6 +23,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFFS, DAILY, @@ -68,6 +69,10 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required( CONF_METER_PERIODICALLY_RESETTING, ): selector.BooleanSelector(), + vol.Optional( + CONF_SENSOR_ALWAYS_AVAILABLE, + default=False, + ): selector.BooleanSelector(), } ) @@ -103,6 +108,10 @@ CONFIG_SCHEMA = vol.Schema( CONF_METER_PERIODICALLY_RESETTING, default=True, ): selector.BooleanSelector(), + vol.Optional( + CONF_SENSOR_ALWAYS_AVAILABLE, + default=False, + ): selector.BooleanSelector(), } ) diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index f8a4c2d4b75..6e1cabac509 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -38,6 +38,7 @@ CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" CONF_TARIFF_ENTITY = "tariff_entity" CONF_CRON_PATTERN = "cron" +CONF_SENSOR_ALWAYS_AVAILABLE = "always_available" ATTR_TARIFF = "tariff" ATTR_TARIFFS = "tariffs" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 794a65db03a..ee0d5f85b3b 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -58,6 +58,7 @@ from .const import ( CONF_METER_OFFSET, CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, + CONF_SENSOR_ALWAYS_AVAILABLE, CONF_SOURCE_SENSOR, CONF_TARIFF, CONF_TARIFF_ENTITY, @@ -158,6 +159,9 @@ async def async_setup_entry( net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] + sensor_always_available = config_entry.options.get( + CONF_SENSOR_ALWAYS_AVAILABLE, False + ) meters = [] tariffs = config_entry.options[CONF_TARIFFS] @@ -178,6 +182,7 @@ async def async_setup_entry( tariff=None, unique_id=entry_id, device_info=device_info, + sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -198,6 +203,7 @@ async def async_setup_entry( tariff=tariff, unique_id=f"{entry_id}_{tariff}", device_info=device_info, + sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) @@ -264,6 +270,9 @@ async def async_setup_platform( CONF_TARIFF_ENTITY ) conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) + conf_sensor_always_available = hass.data[DATA_UTILITY][meter][ + CONF_SENSOR_ALWAYS_AVAILABLE + ] meter_sensor = UtilityMeterSensor( cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, @@ -278,6 +287,7 @@ async def async_setup_platform( tariff=conf_sensor_tariff, unique_id=conf_sensor_unique_id, suggested_entity_id=suggested_entity_id, + sensor_always_available=conf_sensor_always_available, ) meters.append(meter_sensor) @@ -370,6 +380,7 @@ class UtilityMeterSensor(RestoreSensor): tariff_entity, tariff, unique_id, + sensor_always_available, suggested_entity_id=None, device_info=None, ): @@ -397,6 +408,7 @@ class UtilityMeterSensor(RestoreSensor): _LOGGER.debug("CRON pattern: %s", self._cron_pattern) else: self._cron_pattern = cron_pattern + self._sensor_always_available = sensor_always_available self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption self._sensor_periodically_resetting = periodically_resetting @@ -458,8 +470,9 @@ class UtilityMeterSensor(RestoreSensor): if ( source_state := self.hass.states.get(self._sensor_source_id) ) is None or source_state.state == STATE_UNAVAILABLE: - self._attr_available = False - self.async_write_ha_state() + if not self._sensor_always_available: + self._attr_available = False + self.async_write_ha_state() return self._attr_available = True diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index f38989b536e..fc1c727fb0a 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -6,6 +6,7 @@ "title": "Add Utility Meter", "description": "Create a sensor which tracks consumption of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor optionally supports splitting the consumption by tariffs, in that case one sensor for each tariff is created as well as a select entity to choose the current tariff.", "data": { + "always_available": "Sensor always available", "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "[%key:common::config_flow::data::name%]", @@ -16,6 +17,7 @@ "tariffs": "Supported tariffs" }, "data_description": { + "always_available": "If activated, the sensor will always be show the last known value, even if the source entity is unavailable or unknown.", "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", @@ -29,10 +31,12 @@ "step": { "init": { "data": { + "always_available": "[%key:component::utility_meter::config::step::user::data::always_available%]", "source": "[%key:component::utility_meter::config::step::user::data::source%]", "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" }, "data_description": { + "always_available": "[%key:component::utility_meter::config::step::user::data_description::always_available%]", "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 262dbf36306..75ea6d3a4d2 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -49,6 +49,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": [], } @@ -63,6 +64,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": [], } @@ -100,6 +102,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "periodically_resetting": True, + "always_available": False, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -114,6 +117,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": True, + "always_available": False, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -173,6 +177,7 @@ async def test_non_periodically_resetting(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "periodically_resetting": False, + "always_available": False, "offset": 0, "source": input_sensor_entity_id, "tariffs": [], @@ -187,6 +192,61 @@ async def test_non_periodically_resetting(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": False, + "source": input_sensor_entity_id, + "tariffs": [], + } + + +async def test_always_available(hass: HomeAssistant) -> None: + """Test sensor always available.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + "always_available": True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "periodically_resetting": False, + "always_available": True, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": [], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "always_available": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -237,7 +297,11 @@ async def test_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, + user_input={ + "source": input_sensor2_entity_id, + "periodically_resetting": False, + "always_available": True, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -247,6 +311,7 @@ async def test_options(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": True, "source": input_sensor2_entity_id, "tariffs": "", } @@ -258,6 +323,7 @@ async def test_options(hass: HomeAssistant) -> None: "net_consumption": False, "offset": 0, "periodically_resetting": False, + "always_available": True, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 37127363614..fa1e3aa8785 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -231,6 +231,106 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.state == "unavailable" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "always_available": True, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": [], + "always_available": True, + }, + ), + ), +) +async def test_state_always_available( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility sensor state.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + now = dt_util.utcnow() + timedelta(seconds=10) + with freeze_time(now): + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + assert state.attributes.get("status") == COLLECTING + + # test unavailable state + hass.states.async_set( + entity_id, + "unavailable", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + + # test unknown state + hass.states.async_set( + entity_id, None, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "1" + + @pytest.mark.parametrize( "yaml_config", ( @@ -1460,6 +1560,7 @@ def test_calculate_adjustment_invalid_new_state( net_consumption=False, parent_meter="sensor.test", periodically_resetting=True, + sensor_always_available=False, unique_id="test_utility_meter", source_entity="sensor.test", tariff=None, From 12289f172d89a27f32a65f93d15a48e239b355ac Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 25 Jan 2024 19:46:45 +0000 Subject: [PATCH 1037/1544] Filter only utility_meter select entities in reset service (#108855) filter reset service to only utility_meters --- homeassistant/components/utility_meter/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index 918c51cee39..4e8eb23d318 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -4,6 +4,7 @@ reset: target: entity: domain: select + integration: utility_meter calibrate: target: From 2b799830dbb12efb2a8dd26f7262747fa8fe8ede Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 25 Jan 2024 20:59:36 +0100 Subject: [PATCH 1038/1544] Add switch to flexit bacnet integration (#108866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add platform switch to flecit_bacnet integration * Move testing of the switch to it’s own test * Assert correct method is called one time * Test switch on/off error recovery * Review comment --- .../components/flexit_bacnet/__init__.py | 7 +- .../components/flexit_bacnet/strings.json | 5 + .../components/flexit_bacnet/switch.py | 96 +++++++++++++ tests/components/flexit_bacnet/conftest.py | 1 + .../flexit_bacnet/snapshots/test_switch.ambr | 60 ++++++++ tests/components/flexit_bacnet/test_switch.py | 135 ++++++++++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/flexit_bacnet/switch.py create mode 100644 tests/components/flexit_bacnet/snapshots/test_switch.ambr create mode 100644 tests/components/flexit_bacnet/test_switch.py diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 27800af6626..ba7134d7e50 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -8,7 +8,12 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import FlexitCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index aeb349dd1d4..d9efd1fc411 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -68,6 +68,11 @@ "heat_exchanger_speed": { "name": "Heat exchanger speed" } + }, + "switch": { + "electric_heater": { + "name": "Electric heater" + } } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py new file mode 100644 index 00000000000..151bd9d96ec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -0,0 +1,96 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.switch import ( + SwitchDeviceClass, + 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 FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitSwitchEntityDescription(SwitchEntityDescription): + """Describes a Flexit switch entity.""" + + is_on_fn: Callable[[FlexitBACnet], bool] + + +SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( + FlexitSwitchEntityDescription( + key="electric_heater", + translation_key="electric_heater", + icon="mdi:radiator", + is_on_fn=lambda data: data.electric_heater, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) switch from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitSwitch(coordinator, description) for description in SWITCHES + ) + + +class FlexitSwitch(FlexitEntity, SwitchEntity): + """Representation of a Flexit Switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + entity_description: FlexitSwitchEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitSwitchEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) switch.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return value of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn electric heater on.""" + try: + await self.device.enable_electric_heater() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn electric heater off.""" + try: + await self.device.disable_electric_heater() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index f0117b41536..c192489805f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -60,6 +60,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: flexit_bacnet.heat_exchanger_efficiency = 81 flexit_bacnet.heat_exchanger_speed = 100 flexit_bacnet.air_filter_polluted = False + flexit_bacnet.electric_heater = True yield flexit_bacnet diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr new file mode 100644 index 00000000000..4db770917b0 --- /dev/null +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_switches[switch.device_name_electric_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_electric_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Electric heater', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater', + 'unique_id': '0000-0001-electric_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches_implementation[switch.device_name_electric_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Electric heater', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.device_name_electric_heater', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/flexit_bacnet/test_switch.py b/tests/components/flexit_bacnet/test_switch.py new file mode 100644 index 00000000000..7c08fc2a024 --- /dev/null +++ b/tests/components/flexit_bacnet/test_switch.py @@ -0,0 +1,135 @@ +"""Tests for the Flexit Nordic (BACnet) switch entities.""" +from unittest.mock import AsyncMock + +from flexit_bacnet import DecodingError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.flexit_bacnet import setup_with_selected_platforms + +ENTITY_ID = "switch.device_name_electric_heater" + + +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch states are correctly collected from library.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_switches_implementation( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the switch can be turned on and off.""" + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") + + # Set to off + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Set to on + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 1 + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Error recovery, when turning off + mock_flexit_bacnet.disable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "disable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.disable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + # Error recovery, when turning on + mock_flexit_bacnet.enable_electric_heater.side_effect = DecodingError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mocked_method = getattr(mock_flexit_bacnet, "enable_electric_heater") + assert len(mocked_method.mock_calls) == 2 + + mock_flexit_bacnet.enable_electric_heater.side_effect = None + mock_flexit_bacnet.electric_heater = True + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == STATE_ON From 3447e7fddbb9bc1d5ebebb4da4db9b34b874e83c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Jan 2024 10:18:53 -1000 Subject: [PATCH 1039/1544] Fix ESPHome color modes for older firmwares (#108870) --- homeassistant/components/esphome/light.py | 12 ++++++++++++ tests/components/esphome/test_light.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index f9fb8b8fb6d..2771e0ccc6b 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -402,12 +402,24 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): self._attr_supported_features = flags supported = set(map(_color_mode_to_ha, self._native_supported_color_modes)) + + # If we don't know the supported color modes, ESPHome lights + # are always at least ONOFF so we can safely discard UNKNOWN + supported.discard(ColorMode.UNKNOWN) + if ColorMode.ONOFF in supported and len(supported) > 1: supported.remove(ColorMode.ONOFF) if ColorMode.BRIGHTNESS in supported and len(supported) > 1: supported.remove(ColorMode.BRIGHTNESS) if ColorMode.WHITE in supported and len(supported) == 1: supported.remove(ColorMode.WHITE) + + # If we don't know the supported color modes, its a very old + # legacy device, and since ESPHome lights are always at least ONOFF + # we can safely assume that it supports ONOFF + if not supported: + supported.add(ColorMode.ONOFF) + self._attr_supported_color_modes = supported self._attr_effect_list = static_info.effects self._attr_min_mireds = round(static_info.min_mireds) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 3d0c1cc63eb..fc63508a836 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -1865,7 +1865,7 @@ async def test_light_no_color_modes( state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.UNKNOWN] + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] await hass.services.async_call( LIGHT_DOMAIN, From e1b1bb070dae8be6e5c0b4809c562ea06e71a792 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 25 Jan 2024 21:40:06 +0100 Subject: [PATCH 1040/1544] Bump aiocomelit to 0.8.2 (#108862) * bump aicomelit to 0.8.1 * bump to 0.8.2 --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8c47564b165..f1b2cea9e73 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.7.3"] + "requirements": ["aiocomelit==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 96984837274..2367926f605 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.7.3 +aiocomelit==0.8.2 # homeassistant.components.dhcp aiodiscover==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be8bee8e4e6..5153ae5cd92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.7.3 +aiocomelit==0.8.2 # homeassistant.components.dhcp aiodiscover==1.6.0 From eb85f469e955b83143cd25899b8e9426cade3700 Mon Sep 17 00:00:00 2001 From: Massimo Savazzi Date: Thu, 25 Jan 2024 22:49:03 +0100 Subject: [PATCH 1041/1544] Add binary sensor platform to JVC Projector (#108668) * JVC Projector Binary Sensor * Fixed PR as per request, removed Name, removed Read_Only. * Fixed as per Joostlek suggestions * Update homeassistant/components/jvc_projector/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/jvc_projector/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Added changes as per requests * fixed docstring * Update homeassistant/components/jvc_projector/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/jvc_projector/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Added icon property to binary sensor. Removed icons.json file as not used anymore * Fixed tests * Added icons file * Update homeassistant/components/jvc_projector/icons.json Co-authored-by: Joost Lekkerkerker * Update test_binary_sensor.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- .../components/jvc_projector/__init__.py | 2 +- .../components/jvc_projector/binary_sensor.py | 44 +++++++++++++++++++ .../components/jvc_projector/icons.json | 12 +++++ .../components/jvc_projector/manifest.json | 2 +- .../components/jvc_projector/strings.json | 7 +++ .../jvc_projector/test_binary_sensor.py | 22 ++++++++++ 7 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/jvc_projector/binary_sensor.py create mode 100644 homeassistant/components/jvc_projector/icons.json create mode 100644 tests/components/jvc_projector/test_binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 89e689b4325..56148d9e1be 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -664,8 +664,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen -/homeassistant/components/jvc_projector/ @SteveEasley -/tests/components/jvc_projector/ @SteveEasley +/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi +/tests/components/jvc_projector/ @SteveEasley @msavazzi /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py index 996d745a1d5..33af1d315f7 100644 --- a/homeassistant/components/jvc_projector/__init__.py +++ b/homeassistant/components/jvc_projector/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .coordinator import JvcProjectorDataUpdateCoordinator -PLATFORMS = [Platform.REMOTE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py new file mode 100644 index 00000000000..7e8788aa0a6 --- /dev/null +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -0,0 +1,44 @@ +"""Binary Sensor platform for JVC Projector integration.""" + +from __future__ import annotations + +from jvcprojector import const + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import JvcProjectorDataUpdateCoordinator +from .const import DOMAIN +from .entity import JvcProjectorEntity + +ON_STATUS = (const.ON, const.WARMING) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator: JvcProjectorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([JvcBinarySensor(coordinator)]) + + +class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity): + """The entity class for JVC Projector Binary Sensor.""" + + _attr_translation_key = "jvc_power" + + def __init__( + self, + coordinator: JvcProjectorDataUpdateCoordinator, + ) -> None: + """Initialize the JVC Projector sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.device.mac}_power" + + @property + def is_on(self) -> bool: + """Return true if the JVC is on.""" + return self.coordinator.data["power"] in ON_STATUS diff --git a/homeassistant/components/jvc_projector/icons.json b/homeassistant/components/jvc_projector/icons.json new file mode 100644 index 00000000000..94e2ec41cf6 --- /dev/null +++ b/homeassistant/components/jvc_projector/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "jvc_power": { + "default": "mdi:projector-off", + "state": { + "on": "mdi:projector" + } + } + } + } +} diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index a7c08bb9f51..de7e77197f2 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -1,7 +1,7 @@ { "domain": "jvc_projector", "name": "JVC Projector", - "codeowners": ["@SteveEasley"], + "codeowners": ["@SteveEasley", "@msavazzi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jvc_projector", "integration_type": "device", diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 6fdc5b4d12f..06efdc8f9aa 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -31,5 +31,12 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "entity": { + "binary_sensor": { + "jvc_power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + } + } } } diff --git a/tests/components/jvc_projector/test_binary_sensor.py b/tests/components/jvc_projector/test_binary_sensor.py new file mode 100644 index 00000000000..b327538991c --- /dev/null +++ b/tests/components/jvc_projector/test_binary_sensor.py @@ -0,0 +1,22 @@ +"""Tests for the JVC Projector binary sensor device.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "binary_sensor.jvc_projector_power" + + +async def test_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Tests entity state is registered.""" + entity = hass.states.get(ENTITY_ID) + assert entity + assert entity_registry.async_get(entity.entity_id) From 3f31a76692a6625079866a7eaa694e9c063ea3ca Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 25 Jan 2024 20:09:38 -0500 Subject: [PATCH 1042/1544] Apply consistent naming to ZHA cluster handler implementations (#108851) * Apply consistent naming to ZHA cluster handler implentations * remove import alias * remove if statement around assert in test --- .../components/zha/alarm_control_panel.py | 4 +- .../zha/core/cluster_handlers/closures.py | 38 +-- .../zha/core/cluster_handlers/general.py | 315 +++++++++--------- .../core/cluster_handlers/homeautomation.py | 40 +-- .../zha/core/cluster_handlers/hvac.py | 21 +- .../zha/core/cluster_handlers/lighting.py | 7 +- .../zha/core/cluster_handlers/lightlink.py | 8 +- .../cluster_handlers/manufacturerspecific.py | 20 +- .../zha/core/cluster_handlers/measurement.py | 110 +++--- .../zha/core/cluster_handlers/protocol.py | 139 ++++---- .../zha/core/cluster_handlers/security.py | 41 ++- .../zha/core/cluster_handlers/smartenergy.py | 120 +++---- homeassistant/components/zha/siren.py | 6 +- tests/components/zha/test_cluster_handlers.py | 24 +- 14 files changed, 450 insertions(+), 443 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index bb7cfe67fb3..7f1f6a85d15 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -28,7 +28,7 @@ from .core import discovery from .core.cluster_handlers.security import ( SIGNAL_ALARM_TRIGGERED, SIGNAL_ARMED_STATE_CHANGED, - IasAce as AceClusterHandler, + IasAceClusterHandler, ) from .core.const import ( CLUSTER_HANDLER_IAS_ACE, @@ -96,7 +96,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Initialize the ZHA alarm control device.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) cfg_entry = zha_device.gateway.config_entry - self._cluster_handler: AceClusterHandler = cluster_handlers[0] + self._cluster_handler: IasAceClusterHandler = cluster_handlers[0] self._cluster_handler.panel_code = async_get_zha_config_value( cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" ) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 46fb6d5a538..a7056fe9a9f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any import zigpy.zcl -from zigpy.zcl.clusters import closures +from zigpy.zcl.clusters.closures import DoorLock, Shade, WindowCovering from homeassistant.core import callback @@ -16,14 +16,14 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.DoorLock.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): """Door lock cluster handler.""" _value_attribute = 0 REPORT_CONFIG = ( AttrReportConfig( - attr=closures.DoorLock.AttributeDefs.lock_state.name, + attr=DoorLock.AttributeDefs.lock_state.name, config=REPORT_CONFIG_IMMEDIATE, ), ) @@ -31,13 +31,13 @@ class DoorLockClusterHandler(ClusterHandler): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value( - closures.DoorLock.AttributeDefs.lock_state.name, from_cache=True + DoorLock.AttributeDefs.lock_state.name, from_cache=True ) if result is not None: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - closures.DoorLock.AttributeDefs.lock_state.id, - closures.DoorLock.AttributeDefs.lock_state.name, + DoorLock.AttributeDefs.lock_state.id, + DoorLock.AttributeDefs.lock_state.name, result, ) @@ -80,20 +80,20 @@ class DoorLockClusterHandler(ClusterHandler): await self.set_pin_code( code_slot - 1, # start code slots at 1, Zigbee internals use 0 - closures.DoorLock.UserStatus.Enabled, - closures.DoorLock.UserType.Unrestricted, + DoorLock.UserStatus.Enabled, + DoorLock.UserType.Unrestricted, user_code, ) async def async_enable_user_code(self, code_slot: int) -> None: """Enable the code slot.""" - await self.set_user_status(code_slot - 1, closures.DoorLock.UserStatus.Enabled) + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Enabled) async def async_disable_user_code(self, code_slot: int) -> None: """Disable the code slot.""" - await self.set_user_status(code_slot - 1, closures.DoorLock.UserStatus.Disabled) + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Disabled) async def async_get_user_code(self, code_slot: int) -> int: """Get the user code from the code slot.""" @@ -123,26 +123,26 @@ class DoorLockClusterHandler(ClusterHandler): return result -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.Shade.cluster_id) -class Shade(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Shade.cluster_id) +class ShadeClusterHandler(ClusterHandler): """Shade cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCoveringClient(ClientClusterHandler): +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClientClusterHandler(ClientClusterHandler): """Window client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id) -class WindowCovering(ClusterHandler): +@registries.BINDABLE_CLUSTERS.register(WindowCovering.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClusterHandler(ClusterHandler): """Window cluster handler.""" _value_attribute_lift = ( - closures.WindowCovering.AttributeDefs.current_position_lift_percentage.id + WindowCovering.AttributeDefs.current_position_lift_percentage.id ) _value_attribute_tilt = ( - closures.WindowCovering.AttributeDefs.current_position_tilt_percentage.id + WindowCovering.AttributeDefs.current_position_tilt_percentage.id ) REPORT_CONFIG = ( AttrReportConfig( diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index aee66748461..3cb450cc270 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -8,7 +8,36 @@ from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t import zigpy.zcl -from zigpy.zcl.clusters import general +from zigpy.zcl.clusters.general import ( + Alarms, + AnalogInput, + AnalogOutput, + AnalogValue, + ApplianceControl, + Basic, + BinaryInput, + BinaryOutput, + BinaryValue, + Commissioning, + DeviceTemperature, + GreenPowerProxy, + Groups, + Identify, + LevelControl, + MultistateInput, + MultistateOutput, + MultistateValue, + OnOff, + OnOffConfiguration, + Ota, + Partition, + PollControl, + PowerConfiguration, + PowerProfile, + RSSILocation, + Scenes, + Time, +) from zigpy.zcl.foundation import Status from homeassistant.core import callback @@ -40,122 +69,110 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Alarms.cluster_id) -class Alarms(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Alarms.cluster_id) +class AlarmsClusterHandler(ClusterHandler): """Alarms cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInput.cluster_id) +class AnalogInputClusterHandler(ClusterHandler): """Analog Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.AnalogInput.AttributeDefs.present_value.name, + attr=AnalogInput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(ClusterHandler): +@registries.BINDABLE_CLUSTERS.register(AnalogOutput.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutput.cluster_id) +class AnalogOutputClusterHandler(ClusterHandler): """Analog Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.AnalogOutput.AttributeDefs.present_value.name, + attr=AnalogOutput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) ZCL_INIT_ATTRS = { - general.AnalogOutput.AttributeDefs.min_present_value.name: True, - general.AnalogOutput.AttributeDefs.max_present_value.name: True, - general.AnalogOutput.AttributeDefs.resolution.name: True, - general.AnalogOutput.AttributeDefs.relinquish_default.name: True, - general.AnalogOutput.AttributeDefs.description.name: True, - general.AnalogOutput.AttributeDefs.engineering_units.name: True, - general.AnalogOutput.AttributeDefs.application_type.name: True, + AnalogOutput.AttributeDefs.min_present_value.name: True, + AnalogOutput.AttributeDefs.max_present_value.name: True, + AnalogOutput.AttributeDefs.resolution.name: True, + AnalogOutput.AttributeDefs.relinquish_default.name: True, + AnalogOutput.AttributeDefs.description.name: True, + AnalogOutput.AttributeDefs.engineering_units.name: True, + AnalogOutput.AttributeDefs.application_type.name: True, } @property def present_value(self) -> float | None: """Return cached value of present_value.""" - return self.cluster.get(general.AnalogOutput.AttributeDefs.present_value.name) + return self.cluster.get(AnalogOutput.AttributeDefs.present_value.name) @property def min_present_value(self) -> float | None: """Return cached value of min_present_value.""" - return self.cluster.get( - general.AnalogOutput.AttributeDefs.min_present_value.name - ) + return self.cluster.get(AnalogOutput.AttributeDefs.min_present_value.name) @property def max_present_value(self) -> float | None: """Return cached value of max_present_value.""" - return self.cluster.get( - general.AnalogOutput.AttributeDefs.max_present_value.name - ) + return self.cluster.get(AnalogOutput.AttributeDefs.max_present_value.name) @property def resolution(self) -> float | None: """Return cached value of resolution.""" - return self.cluster.get(general.AnalogOutput.AttributeDefs.resolution.name) + return self.cluster.get(AnalogOutput.AttributeDefs.resolution.name) @property def relinquish_default(self) -> float | None: """Return cached value of relinquish_default.""" - return self.cluster.get( - general.AnalogOutput.AttributeDefs.relinquish_default.name - ) + return self.cluster.get(AnalogOutput.AttributeDefs.relinquish_default.name) @property def description(self) -> str | None: """Return cached value of description.""" - return self.cluster.get(general.AnalogOutput.AttributeDefs.description.name) + return self.cluster.get(AnalogOutput.AttributeDefs.description.name) @property def engineering_units(self) -> int | None: """Return cached value of engineering_units.""" - return self.cluster.get( - general.AnalogOutput.AttributeDefs.engineering_units.name - ) + return self.cluster.get(AnalogOutput.AttributeDefs.engineering_units.name) @property def application_type(self) -> int | None: """Return cached value of application_type.""" - return self.cluster.get( - general.AnalogOutput.AttributeDefs.application_type.name - ) + return self.cluster.get(AnalogOutput.AttributeDefs.application_type.name) async def async_set_present_value(self, value: float) -> None: """Update present_value.""" await self.write_attributes_safe( - {general.AnalogOutput.AttributeDefs.present_value.name: value} + {AnalogOutput.AttributeDefs.present_value.name: value} ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValue.cluster_id) +class AnalogValueClusterHandler(ClusterHandler): """Analog Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.AnalogValue.AttributeDefs.present_value.name, + attr=AnalogValue.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.ApplianceControl.cluster_id -) -class ApplianceContorl(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceControl.cluster_id) +class ApplianceControlClusterHandler(ClusterHandler): """Appliance Control cluster handler.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.Basic.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Basic.cluster_id) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Basic.cluster_id) class BasicClusterHandler(ClusterHandler): """Cluster handler to interact with the basic cluster.""" @@ -187,77 +204,75 @@ class BasicClusterHandler(ClusterHandler): self.ZCL_INIT_ATTRS["transmit_power"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) +class BinaryInputClusterHandler(ClusterHandler): """Binary Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.BinaryInput.AttributeDefs.present_value.name, + attr=BinaryInput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutput.cluster_id) +class BinaryOutputClusterHandler(ClusterHandler): """Binary Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.BinaryOutput.AttributeDefs.present_value.name, + attr=BinaryOutput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) +class BinaryValueClusterHandler(ClusterHandler): """Binary Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.BinaryValue.AttributeDefs.present_value.name, + attr=BinaryValue.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Commissioning.cluster_id) -class Commissioning(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Commissioning.cluster_id) +class CommissioningClusterHandler(ClusterHandler): """Commissioning cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.DeviceTemperature.cluster_id -) -class DeviceTemperature(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceTemperature.cluster_id) +class DeviceTemperatureClusterHandler(ClusterHandler): """Device Temperature cluster handler.""" REPORT_CONFIG = ( { - "attr": general.DeviceTemperature.AttributeDefs.current_temperature.name, + "attr": DeviceTemperature.AttributeDefs.current_temperature.name, "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), }, ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.GreenPowerProxy.cluster_id) -class GreenPowerProxy(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GreenPowerProxy.cluster_id) +class GreenPowerProxyClusterHandler(ClusterHandler): """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Groups.cluster_id) -class Groups(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Groups.cluster_id) +class GroupsClusterHandler(ClusterHandler): """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Identify.cluster_id) -class Identify(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Identify.cluster_id) +class IdentifyClusterHandler(ClusterHandler): """Identify cluster handler.""" BIND: bool = False @@ -267,40 +282,40 @@ class Identify(ClusterHandler): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) - if cmd == general.Identify.ServerCommandDefs.trigger_effect.name: + if cmd == Identify.ServerCommandDefs.trigger_effect.name: self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) class LevelControlClientClusterHandler(ClientClusterHandler): """LevelControl client cluster.""" -@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.LevelControl.cluster_id) +@registries.BINDABLE_CLUSTERS.register(LevelControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) class LevelControlClusterHandler(ClusterHandler): """Cluster handler for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 REPORT_CONFIG = ( AttrReportConfig( - attr=general.LevelControl.AttributeDefs.current_level.name, + attr=LevelControl.AttributeDefs.current_level.name, config=REPORT_CONFIG_ASAP, ), ) ZCL_INIT_ATTRS = { - general.LevelControl.AttributeDefs.on_off_transition_time.name: True, - general.LevelControl.AttributeDefs.on_level.name: True, - general.LevelControl.AttributeDefs.on_transition_time.name: True, - general.LevelControl.AttributeDefs.off_transition_time.name: True, - general.LevelControl.AttributeDefs.default_move_rate.name: True, - general.LevelControl.AttributeDefs.start_up_current_level.name: True, + LevelControl.AttributeDefs.on_off_transition_time.name: True, + LevelControl.AttributeDefs.on_level.name: True, + LevelControl.AttributeDefs.on_transition_time.name: True, + LevelControl.AttributeDefs.off_transition_time.name: True, + LevelControl.AttributeDefs.default_move_rate.name: True, + LevelControl.AttributeDefs.start_up_current_level.name: True, } @property def current_level(self) -> int | None: """Return cached value of the current_level attribute.""" - return self.cluster.get(general.LevelControl.AttributeDefs.current_level.name) + return self.cluster.get(LevelControl.AttributeDefs.current_level.name) @callback def cluster_command(self, tsn, command_id, args): @@ -308,13 +323,13 @@ class LevelControlClusterHandler(ClusterHandler): cmd = parse_and_log_command(self, tsn, command_id, args) if cmd in ( - general.LevelControl.ServerCommandDefs.move_to_level.name, - general.LevelControl.ServerCommandDefs.move_to_level_with_on_off.name, + LevelControl.ServerCommandDefs.move_to_level.name, + LevelControl.ServerCommandDefs.move_to_level_with_on_off.name, ): self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) elif cmd in ( - general.LevelControl.ServerCommandDefs.move.name, - general.LevelControl.ServerCommandDefs.move_with_on_off.name, + LevelControl.ServerCommandDefs.move.name, + LevelControl.ServerCommandDefs.move_with_on_off.name, ): # We should dim slowly -- for now, just step once rate = args[1] @@ -322,8 +337,8 @@ class LevelControlClusterHandler(ClusterHandler): rate = 10 # Should read default move rate self.dispatch_level_change(SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) elif cmd in ( - general.LevelControl.ServerCommandDefs.step.name, - general.LevelControl.ServerCommandDefs.step_with_on_off.name, + LevelControl.ServerCommandDefs.step.name, + LevelControl.ServerCommandDefs.step_with_on_off.name, ): # Step (technically may change on/off) self.dispatch_level_change( @@ -342,61 +357,59 @@ class LevelControlClusterHandler(ClusterHandler): self.async_send_signal(f"{self.unique_id}_{command}", level) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInput.cluster_id) +class MultistateInputClusterHandler(ClusterHandler): """Multistate Input cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.MultistateInput.AttributeDefs.present_value.name, + attr=MultistateInput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.MultistateOutput.cluster_id -) -class MultistateOutput(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutput.cluster_id) +class MultistateOutputClusterHandler(ClusterHandler): """Multistate Output cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.MultistateOutput.AttributeDefs.present_value.name, + attr=MultistateOutput.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValue.cluster_id) +class MultistateValueClusterHandler(ClusterHandler): """Multistate Value cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.MultistateValue.AttributeDefs.present_value.name, + attr=MultistateValue.AttributeDefs.present_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) class OnOffClientClusterHandler(ClientClusterHandler): """OnOff client cluster handler.""" -@registries.BINDABLE_CLUSTERS.register(general.OnOff.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.OnOff.cluster_id) +@registries.BINDABLE_CLUSTERS.register(OnOff.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) class OnOffClusterHandler(ClusterHandler): """Cluster handler for the OnOff Zigbee cluster.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.OnOff.AttributeDefs.on_off.name, config=REPORT_CONFIG_IMMEDIATE + attr=OnOff.AttributeDefs.on_off.name, config=REPORT_CONFIG_IMMEDIATE ), ) ZCL_INIT_ATTRS = { - general.OnOff.AttributeDefs.start_up_on_off.name: True, + OnOff.AttributeDefs.start_up_on_off.name: True, } def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -422,25 +435,21 @@ class OnOffClusterHandler(ClusterHandler): @property def on_off(self) -> bool | None: """Return cached value of on/off attribute.""" - return self.cluster.get(general.OnOff.AttributeDefs.on_off.name) + return self.cluster.get(OnOff.AttributeDefs.on_off.name) async def turn_on(self) -> None: """Turn the on off cluster on.""" result = await self.on() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn on: {result[1]}") - self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.true - ) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) async def turn_off(self) -> None: """Turn the on off cluster off.""" result = await self.off() if result[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to turn off: {result[1]}") - self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.false - ) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) @callback def cluster_command(self, tsn, command_id, args): @@ -448,20 +457,16 @@ class OnOffClusterHandler(ClusterHandler): cmd = parse_and_log_command(self, tsn, command_id, args) if cmd in ( - general.OnOff.ServerCommandDefs.off.name, - general.OnOff.ServerCommandDefs.off_with_effect.name, + OnOff.ServerCommandDefs.off.name, + OnOff.ServerCommandDefs.off_with_effect.name, ): - self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.false - ) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) elif cmd in ( - general.OnOff.ServerCommandDefs.on.name, - general.OnOff.ServerCommandDefs.on_with_recall_global_scene.name, + OnOff.ServerCommandDefs.on.name, + OnOff.ServerCommandDefs.on_with_recall_global_scene.name, ): - self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.true - ) - elif cmd == general.OnOff.ServerCommandDefs.on_with_timed_off.name: + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) + elif cmd == OnOff.ServerCommandDefs.on_with_timed_off.name: should_accept = args[0] on_time = args[1] # 0 is always accept 1 is only accept when already on @@ -470,7 +475,7 @@ class OnOffClusterHandler(ClusterHandler): self._off_listener() self._off_listener = None self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.true + OnOff.AttributeDefs.on_off.id, t.Bool.true ) if on_time > 0: self._off_listener = async_call_later( @@ -480,25 +485,23 @@ class OnOffClusterHandler(ClusterHandler): ) elif cmd == "toggle": self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, not bool(self.on_off) + OnOff.AttributeDefs.on_off.id, not bool(self.on_off) ) @callback def set_to_off(self, *_): """Set the state to off.""" self._off_listener = None - self.cluster.update_attribute( - general.OnOff.AttributeDefs.on_off.id, t.Bool.false - ) + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" - if attrid == general.OnOff.AttributeDefs.on_off.id: + if attrid == OnOff.AttributeDefs.on_off.id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - general.OnOff.AttributeDefs.on_off.name, + OnOff.AttributeDefs.on_off.name, value, ) @@ -509,21 +512,19 @@ class OnOffClusterHandler(ClusterHandler): from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) await self.get_attribute_value( - general.OnOff.AttributeDefs.on_off.id, from_cache=from_cache + OnOff.AttributeDefs.on_off.id, from_cache=from_cache ) await super().async_update() -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.OnOffConfiguration.cluster_id -) -class OnOffConfiguration(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOffConfiguration.cluster_id) +class OnOffConfigurationClusterHandler(ClusterHandler): """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Ota.cluster_id) -class Ota(ClientClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClientClusterHandler(ClientClusterHandler): """OTA cluster handler.""" BIND: bool = False @@ -544,14 +545,14 @@ class Ota(ClientClusterHandler): self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Partition.cluster_id) -class Partition(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) +class PartitionClusterHandler(ClusterHandler): """Partition cluster handler.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(general.PollControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PollControl.cluster_id) -class PollControl(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PollControl.cluster_id) +class PollControlClusterHandler(ClusterHandler): """Poll Control cluster handler.""" CHECKIN_INTERVAL = 55 * 60 * 4 # 55min @@ -564,9 +565,7 @@ class PollControl(ClusterHandler): async def async_configure_cluster_handler_specific(self) -> None: """Configure cluster handler: set check-in interval.""" await self.write_attributes_safe( - { - general.PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL - } + {PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL} ) @callback @@ -581,7 +580,7 @@ class PollControl(ClusterHandler): self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) self.zha_send_event(cmd_name, args) - if cmd_name == general.PollControl.ClientCommandDefs.checkin.name: + if cmd_name == PollControl.ClientCommandDefs.checkin.name: self.cluster.create_catching_task(self.check_in_response(tsn)) async def check_in_response(self, tsn: int) -> None: @@ -597,19 +596,17 @@ class PollControl(ClusterHandler): self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - general.PowerConfiguration.cluster_id -) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerConfiguration.cluster_id) class PowerConfigurationClusterHandler(ClusterHandler): """Cluster handler for the zigbee power configuration cluster.""" REPORT_CONFIG = ( AttrReportConfig( - attr=general.PowerConfiguration.AttributeDefs.battery_voltage.name, + attr=PowerConfiguration.AttributeDefs.battery_voltage.name, config=REPORT_CONFIG_BATTERY_SAVE, ), AttrReportConfig( - attr=general.PowerConfiguration.AttributeDefs.battery_percentage_remaining.name, + attr=PowerConfiguration.AttributeDefs.battery_percentage_remaining.name, config=REPORT_CONFIG_BATTERY_SAVE, ), ) @@ -617,34 +614,34 @@ class PowerConfigurationClusterHandler(ClusterHandler): def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: """Initialize cluster handler specific attrs.""" attributes = [ - general.PowerConfiguration.AttributeDefs.battery_size.name, - general.PowerConfiguration.AttributeDefs.battery_quantity.name, + PowerConfiguration.AttributeDefs.battery_size.name, + PowerConfiguration.AttributeDefs.battery_quantity.name, ] return self.get_attributes( attributes, from_cache=from_cache, only_cache=from_cache ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.PowerProfile.cluster_id) -class PowerProfile(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerProfile.cluster_id) +class PowerProfileClusterHandler(ClusterHandler): """Power Profile cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.RSSILocation.cluster_id) -class RSSILocation(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RSSILocation.cluster_id) +class RSSILocationClusterHandler(ClusterHandler): """RSSI Location cluster handler.""" -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) class ScenesClientClusterHandler(ClientClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Scenes.cluster_id) -class Scenes(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) +class ScenesClusterHandler(ClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.Time.cluster_id) -class Time(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Time.cluster_id) +class TimeClusterHandler(ClusterHandler): """Time cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 484ec9f423e..bb7b96d367e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -3,8 +3,14 @@ from __future__ import annotations import enum -from zigpy.zcl.clusters import homeautomation -from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement +from zigpy.zcl.clusters.homeautomation import ( + ApplianceEventAlerts, + ApplianceIdentification, + ApplianceStatistics, + Diagnostic, + ElectricalMeasurement, + MeterIdentification, +) from .. import registries from ..const import ( @@ -16,31 +22,23 @@ from ..const import ( from . import AttrReportConfig, ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceEventAlerts.cluster_id -) -class ApplianceEventAlerts(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceEventAlerts.cluster_id) +class ApplianceEventAlertsClusterHandler(ClusterHandler): """Appliance Event Alerts cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceIdentification.cluster_id -) -class ApplianceIdentification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceIdentification.cluster_id) +class ApplianceIdentificationClusterHandler(ClusterHandler): """Appliance Identification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.ApplianceStatistics.cluster_id -) -class ApplianceStatistics(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceStatistics.cluster_id) +class ApplianceStatisticsClusterHandler(ClusterHandler): """Appliance Statistics cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.Diagnostic.cluster_id -) -class Diagnostic(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Diagnostic.cluster_id) +class DiagnosticClusterHandler(ClusterHandler): """Diagnostic cluster handler.""" @@ -232,8 +230,6 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - homeautomation.MeterIdentification.cluster_id -) -class MeterIdentification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MeterIdentification.cluster_id) +class MeterIdentificationClusterHandler(ClusterHandler): """Metering Identification cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index f5b70798c2d..8c9ee07c6f1 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -7,8 +7,13 @@ from __future__ import annotations from typing import Any -from zigpy.zcl.clusters import hvac -from zigpy.zcl.clusters.hvac import Fan, Thermostat +from zigpy.zcl.clusters.hvac import ( + Dehumidification, + Fan, + Pump, + Thermostat, + UserInterface, +) from homeassistant.core import callback @@ -26,8 +31,8 @@ REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Dehumidification.cluster_id) -class Dehumidification(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Dehumidification.cluster_id) +class DehumidificationClusterHandler(ClusterHandler): """Dehumidification cluster handler.""" @@ -75,8 +80,8 @@ class FanClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.Pump.cluster_id) -class Pump(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Pump.cluster_id) +class PumpClusterHandler(ClusterHandler): """Pump cluster handler.""" @@ -333,6 +338,6 @@ class ThermostatClusterHandler(ClusterHandler): return bool(self.occupancy) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.UserInterface.cluster_id) -class UserInterface(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) +class UserInterfaceClusterHandler(ClusterHandler): """User interface (thermostat) cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 515c2e88d10..bb3ac3c80e3 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -1,8 +1,7 @@ """Lighting cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -from zigpy.zcl.clusters import lighting -from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.lighting import Ballast, Color from homeassistant.backports.functools import cached_property @@ -11,8 +10,8 @@ from ..const import REPORT_CONFIG_DEFAULT from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lighting.Ballast.cluster_id) -class Ballast(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ballast.cluster_id) +class BallastClusterHandler(ClusterHandler): """Ballast cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py index bac4d8c09a9..e2ed36bdc83 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -2,16 +2,16 @@ import asyncio import zigpy.exceptions -from zigpy.zcl.clusters import lightlink +from zigpy.zcl.clusters.lightlink import LightLink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand from .. import registries from . import ClusterHandler, ClusterHandlerStatus -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(lightlink.LightLink.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(lightlink.LightLink.cluster_id) -class LightLink(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(LightLink.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LightLink.cluster_id) +class LightLinkClusterHandler(ClusterHandler): """Lightlink cluster handler.""" BIND: bool = False diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 2acf6b7e5e4..732a580759e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -25,7 +25,7 @@ from ..const import ( UNKNOWN, ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -from .general import MultistateInput +from .general import MultistateInputClusterHandler if TYPE_CHECKING: from ..endpoint import Endpoint @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_HUMIDITY_CLUSTER ) -class SmartThingsHumidity(ClusterHandler): +class SmartThingsHumidityClusterHandler(ClusterHandler): """Smart Things Humidity cluster handler.""" REPORT_CONFIG = ( @@ -49,7 +49,7 @@ class SmartThingsHumidity(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) -class OsramButton(ClusterHandler): +class OsramButtonClusterHandler(ClusterHandler): """Osram button cluster handler.""" REPORT_CONFIG = () @@ -57,7 +57,7 @@ class OsramButton(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) -class PhillipsRemote(ClusterHandler): +class PhillipsRemoteClusterHandler(ClusterHandler): """Phillips remote cluster handler.""" REPORT_CONFIG = () @@ -84,7 +84,7 @@ class TuyaClusterHandler(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) -class OppleRemote(ClusterHandler): +class OppleRemoteClusterHandler(ClusterHandler): """Opple cluster handler.""" REPORT_CONFIG = () @@ -173,7 +173,7 @@ class OppleRemote(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) -class SmartThingsAcceleration(ClusterHandler): +class SmartThingsAccelerationClusterHandler(ClusterHandler): """Smart Things Acceleration cluster handler.""" REPORT_CONFIG = ( @@ -220,7 +220,7 @@ class SmartThingsAcceleration(ClusterHandler): @registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) -class InovelliNotificationClusterHandler(ClientClusterHandler): +class InovelliNotificationClientClusterHandler(ClientClusterHandler): """Inovelli Notification cluster handler.""" @callback @@ -412,7 +412,7 @@ class IkeaAirPurifierClusterHandler(ClusterHandler): @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) -class IkeaRemote(ClusterHandler): +class IkeaRemoteClusterHandler(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () @@ -421,13 +421,13 @@ class IkeaRemote(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 ) -class XiaomiVibrationAQ1ClusterHandler(MultistateInput): +class XiaomiVibrationAQ1ClusterHandler(MultistateInputClusterHandler): """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC11) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC11) -class SonoffPresenceSenor(ClusterHandler): +class SonoffPresenceSenorClusterHandler(ClusterHandler): """SonoffPresenceSensor cluster handler.""" ZCL_INIT_ATTRS = {"last_illumination_state": True} diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index 4df24c32fad..be079328228 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -4,7 +4,21 @@ from __future__ import annotations from typing import TYPE_CHECKING import zigpy.zcl -from zigpy.zcl.clusters import measurement +from zigpy.zcl.clusters.measurement import ( + PM25, + CarbonDioxideConcentration, + CarbonMonoxideConcentration, + FlowMeasurement, + FormaldehydeConcentration, + IlluminanceLevelSensing, + IlluminanceMeasurement, + LeafWetness, + OccupancySensing, + PressureMeasurement, + RelativeHumidity, + SoilMoisture, + TemperatureMeasurement, +) from .. import registries from ..const import ( @@ -20,57 +34,49 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.FlowMeasurement.cluster_id -) -class FlowMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(FlowMeasurement.cluster_id) +class FlowMeasurementClusterHandler(ClusterHandler): """Flow Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.FlowMeasurement.AttributeDefs.measured_value.name, + attr=FlowMeasurement.AttributeDefs.measured_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.IlluminanceLevelSensing.cluster_id -) -class IlluminanceLevelSensing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceLevelSensing.cluster_id) +class IlluminanceLevelSensingClusterHandler(ClusterHandler): """Illuminance Level Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.IlluminanceLevelSensing.AttributeDefs.level_status.name, + attr=IlluminanceLevelSensing.AttributeDefs.level_status.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.IlluminanceMeasurement.cluster_id -) -class IlluminanceMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceMeasurement.cluster_id) +class IlluminanceMeasurementClusterHandler(ClusterHandler): """Illuminance Measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.IlluminanceMeasurement.AttributeDefs.measured_value.name, + attr=IlluminanceMeasurement.AttributeDefs.measured_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.OccupancySensing.cluster_id -) -class OccupancySensing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OccupancySensing.cluster_id) +class OccupancySensingClusterHandler(ClusterHandler): """Occupancy Sensing cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.OccupancySensing.AttributeDefs.occupancy.name, + attr=OccupancySensing.AttributeDefs.occupancy.name, config=REPORT_CONFIG_IMMEDIATE, ), ) @@ -87,123 +93,115 @@ class OccupancySensing(ClusterHandler): self.ZCL_INIT_ATTRS["ultrasonic_u_to_o_threshold"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.PressureMeasurement.cluster_id -) -class PressureMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PressureMeasurement.cluster_id) +class PressureMeasurementClusterHandler(ClusterHandler): """Pressure measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.PressureMeasurement.AttributeDefs.measured_value.name, + attr=PressureMeasurement.AttributeDefs.measured_value.name, config=REPORT_CONFIG_DEFAULT, ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.RelativeHumidity.cluster_id -) -class RelativeHumidity(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RelativeHumidity.cluster_id) +class RelativeHumidityClusterHandler(ClusterHandler): """Relative Humidity measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.RelativeHumidity.AttributeDefs.measured_value.name, + attr=RelativeHumidity.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.SoilMoisture.cluster_id -) -class SoilMoisture(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(SoilMoisture.cluster_id) +class SoilMoistureClusterHandler(ClusterHandler): """Soil Moisture measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.SoilMoisture.AttributeDefs.measured_value.name, + attr=SoilMoisture.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.LeafWetness.cluster_id) -class LeafWetness(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LeafWetness.cluster_id) +class LeafWetnessClusterHandler(ClusterHandler): """Leaf Wetness measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.LeafWetness.AttributeDefs.measured_value.name, + attr=LeafWetness.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.TemperatureMeasurement.cluster_id -) -class TemperatureMeasurement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(TemperatureMeasurement.cluster_id) +class TemperatureMeasurementClusterHandler(ClusterHandler): """Temperature measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.TemperatureMeasurement.AttributeDefs.measured_value.name, + attr=TemperatureMeasurement.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.CarbonMonoxideConcentration.cluster_id + CarbonMonoxideConcentration.cluster_id ) -class CarbonMonoxideConcentration(ClusterHandler): +class CarbonMonoxideConcentrationClusterHandler(ClusterHandler): """Carbon Monoxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.CarbonMonoxideConcentration.AttributeDefs.measured_value.name, + attr=CarbonMonoxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.CarbonDioxideConcentration.cluster_id + CarbonDioxideConcentration.cluster_id ) -class CarbonDioxideConcentration(ClusterHandler): +class CarbonDioxideConcentrationClusterHandler(ClusterHandler): """Carbon Dioxide measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.CarbonDioxideConcentration.AttributeDefs.measured_value.name, + attr=CarbonDioxideConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(measurement.PM25.cluster_id) -class PM25(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PM25.cluster_id) +class PM25ClusterHandler(ClusterHandler): """Particulate Matter 2.5 microns or less measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.PM25.AttributeDefs.measured_value.name, + attr=PM25.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), ), ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - measurement.FormaldehydeConcentration.cluster_id + FormaldehydeConcentration.cluster_id ) -class FormaldehydeConcentration(ClusterHandler): +class FormaldehydeConcentrationClusterHandler(ClusterHandler): """Formaldehyde measurement cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=measurement.FormaldehydeConcentration.AttributeDefs.measured_value.name, + attr=FormaldehydeConcentration.AttributeDefs.measured_value.name, config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), ), ) diff --git a/homeassistant/components/zha/core/cluster_handlers/protocol.py b/homeassistant/components/zha/core/cluster_handlers/protocol.py index 1643fe031cd..14f01a55b6a 100644 --- a/homeassistant/components/zha/core/cluster_handlers/protocol.py +++ b/homeassistant/components/zha/core/cluster_handlers/protocol.py @@ -1,143 +1,128 @@ """Protocol cluster handlers module for Zigbee Home Automation.""" -from zigpy.zcl.clusters import protocol +from zigpy.zcl.clusters.protocol import ( + AnalogInputExtended, + AnalogInputRegular, + AnalogOutputExtended, + AnalogOutputRegular, + AnalogValueExtended, + AnalogValueRegular, + BacnetProtocolTunnel, + BinaryInputExtended, + BinaryInputRegular, + BinaryOutputExtended, + BinaryOutputRegular, + BinaryValueExtended, + BinaryValueRegular, + GenericTunnel, + MultistateInputExtended, + MultistateInputRegular, + MultistateOutputExtended, + MultistateOutputRegular, + MultistateValueExtended, + MultistateValueRegular, +) from .. import registries from . import ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogInputExtended.cluster_id -) -class AnalogInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputExtended.cluster_id) +class AnalogInputExtendedClusterHandler(ClusterHandler): """Analog Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogInputRegular.cluster_id -) -class AnalogInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputRegular.cluster_id) +class AnalogInputRegularClusterHandler(ClusterHandler): """Analog Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogOutputExtended.cluster_id -) -class AnalogOutputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputExtended.cluster_id) +class AnalogOutputExtendedClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogOutputRegular.cluster_id -) -class AnalogOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputRegular.cluster_id) +class AnalogOutputRegularClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogValueExtended.cluster_id -) -class AnalogValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueExtended.cluster_id) +class AnalogValueExtendedClusterHandler(ClusterHandler): """Analog Value Extended edition cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.AnalogValueRegular.cluster_id -) -class AnalogValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueRegular.cluster_id) +class AnalogValueRegularClusterHandler(ClusterHandler): """Analog Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BacnetProtocolTunnel.cluster_id -) -class BacnetProtocolTunnel(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BacnetProtocolTunnel.cluster_id) +class BacnetProtocolTunnelClusterHandler(ClusterHandler): """Bacnet Protocol Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryInputExtended.cluster_id -) -class BinaryInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputExtended.cluster_id) +class BinaryInputExtendedClusterHandler(ClusterHandler): """Binary Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryInputRegular.cluster_id -) -class BinaryInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputRegular.cluster_id) +class BinaryInputRegularClusterHandler(ClusterHandler): """Binary Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryOutputExtended.cluster_id -) -class BinaryOutputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputExtended.cluster_id) +class BinaryOutputExtendedClusterHandler(ClusterHandler): """Binary Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryOutputRegular.cluster_id -) -class BinaryOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputRegular.cluster_id) +class BinaryOutputRegularClusterHandler(ClusterHandler): """Binary Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryValueExtended.cluster_id -) -class BinaryValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueExtended.cluster_id) +class BinaryValueExtendedClusterHandler(ClusterHandler): """Binary Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.BinaryValueRegular.cluster_id -) -class BinaryValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueRegular.cluster_id) +class BinaryValueRegularClusterHandler(ClusterHandler): """Binary Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(protocol.GenericTunnel.cluster_id) -class GenericTunnel(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GenericTunnel.cluster_id) +class GenericTunnelClusterHandler(ClusterHandler): """Generic Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateInputExtended.cluster_id -) -class MultiStateInputExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputExtended.cluster_id) +class MultiStateInputExtendedClusterHandler(ClusterHandler): """Multistate Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateInputRegular.cluster_id -) -class MultiStateInputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputRegular.cluster_id) +class MultiStateInputRegularClusterHandler(ClusterHandler): """Multistate Input Regular cluster handler.""" @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateOutputExtended.cluster_id + MultistateOutputExtended.cluster_id ) -class MultiStateOutputExtended(ClusterHandler): +class MultiStateOutputExtendedClusterHandler(ClusterHandler): """Multistate Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateOutputRegular.cluster_id -) -class MultiStateOutputRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutputRegular.cluster_id) +class MultiStateOutputRegularClusterHandler(ClusterHandler): """Multistate Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateValueExtended.cluster_id -) -class MultiStateValueExtended(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueExtended.cluster_id) +class MultiStateValueExtendedClusterHandler(ClusterHandler): """Multistate Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - protocol.MultistateValueRegular.cluster_id -) -class MultiStateValueRegular(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueRegular.cluster_id) +class MultiStateValueRegularClusterHandler(ClusterHandler): """Multistate Value Regular cluster handler.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index ac28c5a72da..c37fdc43766 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -9,8 +9,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any import zigpy.zcl -from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasWd, IasZone from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -34,7 +33,7 @@ SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) -class IasAce(ClusterHandler): +class IasAceClusterHandler(ClusterHandler): """IAS Ancillary Control Equipment cluster handler.""" def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: @@ -236,16 +235,16 @@ class IasAce(ClusterHandler): """Handle the IAS ACE zone status command.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(security.IasWd.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(security.IasWd.cluster_id) -class IasWd(ClusterHandler): +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IasWd.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasWd.cluster_id) +class IasWdClusterHandler(ClusterHandler): """IAS Warning Device cluster handler.""" @staticmethod def set_bit(destination_value, destination_bit, source_value, source_bit): """Set the specified bit in the value.""" - if IasWd.get_bit(source_value, source_bit): + if IasWdClusterHandler.get_bit(source_value, source_bit): return destination_value | (1 << destination_bit) return destination_value @@ -267,15 +266,15 @@ class IasWd(ClusterHandler): is currently active (warning in progress). """ value = 0 - value = IasWd.set_bit(value, 0, squawk_level, 0) - value = IasWd.set_bit(value, 1, squawk_level, 1) + value = IasWdClusterHandler.set_bit(value, 0, squawk_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, squawk_level, 1) - value = IasWd.set_bit(value, 3, strobe, 0) + value = IasWdClusterHandler.set_bit(value, 3, strobe, 0) - value = IasWd.set_bit(value, 4, mode, 0) - value = IasWd.set_bit(value, 5, mode, 1) - value = IasWd.set_bit(value, 6, mode, 2) - value = IasWd.set_bit(value, 7, mode, 3) + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) await self.squawk(value) @@ -304,15 +303,15 @@ class IasWd(ClusterHandler): and then turn OFF for 6/10ths of a second. """ value = 0 - value = IasWd.set_bit(value, 0, siren_level, 0) - value = IasWd.set_bit(value, 1, siren_level, 1) + value = IasWdClusterHandler.set_bit(value, 0, siren_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, siren_level, 1) - value = IasWd.set_bit(value, 2, strobe, 0) + value = IasWdClusterHandler.set_bit(value, 2, strobe, 0) - value = IasWd.set_bit(value, 4, mode, 0) - value = IasWd.set_bit(value, 5, mode, 1) - value = IasWd.set_bit(value, 6, mode, 2) - value = IasWd.set_bit(value, 7, mode, 3) + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) await self.start_warning( value, warning_duration, strobe_duty_cycle, strobe_intensity diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index d52d62897bc..4d3c1759cdc 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -6,7 +6,20 @@ from functools import partialmethod from typing import TYPE_CHECKING import zigpy.zcl -from zigpy.zcl.clusters import smartenergy +from zigpy.zcl.clusters.smartenergy import ( + Calendar, + DeviceManagement, + Drlc, + EnergyManagement, + Events, + KeyEstablishment, + MduPairing, + Messaging, + Metering, + Prepayment, + Price, + Tunneling, +) from .. import registries from ..const import ( @@ -21,108 +34,99 @@ if TYPE_CHECKING: from ..endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Calendar.cluster_id) -class Calendar(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Calendar.cluster_id) +class CalendarClusterHandler(ClusterHandler): """Calendar cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.DeviceManagement.cluster_id -) -class DeviceManagement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceManagement.cluster_id) +class DeviceManagementClusterHandler(ClusterHandler): """Device Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Drlc.cluster_id) -class Drlc(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Drlc.cluster_id) +class DrlcClusterHandler(ClusterHandler): """Demand Response and Load Control cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.EnergyManagement.cluster_id -) -class EnergyManagement(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(EnergyManagement.cluster_id) +class EnergyManagementClusterHandler(ClusterHandler): """Energy Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Events.cluster_id) -class Events(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Events.cluster_id) +class EventsClusterHandler(ClusterHandler): """Event cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - smartenergy.KeyEstablishment.cluster_id -) -class KeyEstablishment(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(KeyEstablishment.cluster_id) +class KeyEstablishmentClusterHandler(ClusterHandler): """Key Establishment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.MduPairing.cluster_id) -class MduPairing(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MduPairing.cluster_id) +class MduPairingClusterHandler(ClusterHandler): """Pairing cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Messaging.cluster_id) -class Messaging(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Messaging.cluster_id) +class MessagingClusterHandler(ClusterHandler): """Messaging cluster handler.""" -SEAttrs = smartenergy.Metering.AttributeDefs - - -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Metering.cluster_id) -class Metering(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Metering.cluster_id) +class MeteringClusterHandler(ClusterHandler): """Metering cluster handler.""" REPORT_CONFIG = ( AttrReportConfig( - attr=SEAttrs.instantaneous_demand.name, + attr=Metering.AttributeDefs.instantaneous_demand.name, config=REPORT_CONFIG_OP, ), AttrReportConfig( - attr=SEAttrs.current_summ_delivered.name, + attr=Metering.AttributeDefs.current_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier1_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier1_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier2_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier2_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier3_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier3_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier4_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier4_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier5_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier5_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_tier6_summ_delivered.name, + attr=Metering.AttributeDefs.current_tier6_summ_delivered.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.current_summ_received.name, + attr=Metering.AttributeDefs.current_summ_received.name, config=REPORT_CONFIG_DEFAULT, ), AttrReportConfig( - attr=SEAttrs.status.name, + attr=Metering.AttributeDefs.status.name, config=REPORT_CONFIG_ASAP, ), ) ZCL_INIT_ATTRS = { - SEAttrs.demand_formatting.name: True, - SEAttrs.divisor.name: True, - SEAttrs.metering_device_type.name: True, - SEAttrs.multiplier.name: True, - SEAttrs.summation_formatting.name: True, - SEAttrs.unit_of_measure.name: True, + Metering.AttributeDefs.demand_formatting.name: True, + Metering.AttributeDefs.divisor.name: True, + Metering.AttributeDefs.metering_device_type.name: True, + Metering.AttributeDefs.multiplier.name: True, + Metering.AttributeDefs.summation_formatting.name: True, + Metering.AttributeDefs.unit_of_measure.name: True, } metering_device_type = { @@ -174,12 +178,12 @@ class Metering(ClusterHandler): @property def divisor(self) -> int: """Return divisor for the value.""" - return self.cluster.get(SEAttrs.divisor.name) or 1 + return self.cluster.get(Metering.AttributeDefs.divisor.name) or 1 @property def device_type(self) -> str | int | None: """Return metering device type.""" - dev_type = self.cluster.get(SEAttrs.metering_device_type.name) + dev_type = self.cluster.get(Metering.AttributeDefs.metering_device_type.name) if dev_type is None: return None return self.metering_device_type.get(dev_type, dev_type) @@ -187,14 +191,14 @@ class Metering(ClusterHandler): @property def multiplier(self) -> int: """Return multiplier for the value.""" - return self.cluster.get(SEAttrs.multiplier.name) or 1 + return self.cluster.get(Metering.AttributeDefs.multiplier.name) or 1 @property def status(self) -> int | None: """Return metering device status.""" - if (status := self.cluster.get(SEAttrs.status.name)) is None: + if (status := self.cluster.get(Metering.AttributeDefs.status.name)) is None: return None - if self.cluster.get(SEAttrs.metering_device_type.name) == 0: + if self.cluster.get(Metering.AttributeDefs.metering_device_type.name) == 0: # Electric metering device type return self.DeviceStatusElectric(status) return self.DeviceStatusDefault(status) @@ -202,18 +206,18 @@ class Metering(ClusterHandler): @property def unit_of_measurement(self) -> int: """Return unit of measurement.""" - return self.cluster.get(SEAttrs.unit_of_measure.name) + return self.cluster.get(Metering.AttributeDefs.unit_of_measure.name) async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( - SEAttrs.demand_formatting.name, 0xF9 + Metering.AttributeDefs.demand_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._format_spec = self.get_formatting(fmting) fmting = self.cluster.get( - SEAttrs.summation_formatting.name, 0xF9 + Metering.AttributeDefs.summation_formatting.name, 0xF9 ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) @@ -277,16 +281,16 @@ class Metering(ClusterHandler): summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Prepayment.cluster_id) -class Prepayment(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Prepayment.cluster_id) +class PrepaymentClusterHandler(ClusterHandler): """Prepayment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Price.cluster_id) -class Price(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Price.cluster_id) +class PriceClusterHandler(ClusterHandler): """Price cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(smartenergy.Tunneling.cluster_id) -class Tunneling(ClusterHandler): +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Tunneling.cluster_id) +class TunnelingClusterHandler(ClusterHandler): """Tunneling cluster handler.""" diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 86cadb62519..717eb2df033 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .core import discovery -from .core.cluster_handlers.security import IasWd +from .core.cluster_handlers.security import IasWdClusterHandler from .core.const import ( CLUSTER_HANDLER_IAS_WD, SIGNAL_ADD_ENTITIES, @@ -101,7 +101,9 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cluster_handler: IasWd = cast(IasWd, cluster_handlers[0]) + self._cluster_handler: IasWdClusterHandler = cast( + IasWdClusterHandler, cluster_handlers[0] + ) self._attr_is_on: bool = False self._off_listener: Callable[[], None] | None = None diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 7d5b46406cc..b248244e243 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -713,7 +713,9 @@ async def test_zll_device_groups( """Test adding coordinator to ZLL groups.""" cluster = zigpy_zll_device.endpoints[1].lightlink - cluster_handler = cluster_handlers.lightlink.LightLink(cluster, endpoint) + cluster_handler = cluster_handlers.lightlink.LightLinkClusterHandler( + cluster, endpoint + ) get_group_identifiers_rsp = zigpy.zcl.clusters.lightlink.LightLink.commands_by_name[ "get_group_identifiers_rsp" @@ -980,3 +982,23 @@ async def test_retry_request( assert func.await_count == 3 assert isinstance(exc.value, HomeAssistantError) assert str(exc.value) == expected_error + + +async def test_cluster_handler_naming() -> None: + """Test that all cluster handlers are named appropriately.""" + for client_cluster_handler in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.values(): + assert issubclass(client_cluster_handler, cluster_handlers.ClientClusterHandler) + assert client_cluster_handler.__name__.endswith("ClientClusterHandler") + + server_cluster_handlers = [] + for cluster_handler_dict in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.values(): + # remove this filter in the update platform PR + server_cluster_handlers += [ + cluster_handler + for cluster_handler in cluster_handler_dict.values() + if cluster_handler.__name__ != "OtaClientClusterHandler" + ] + + for cluster_handler in server_cluster_handlers: + assert not issubclass(cluster_handler, cluster_handlers.ClientClusterHandler) + assert cluster_handler.__name__.endswith("ClusterHandler") From 617e8dd8a5a1eb38239ba0d3c119706b1a607a80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Jan 2024 19:14:44 -1000 Subject: [PATCH 1043/1544] Small cleanup to entity platform translation fetching (#108890) * Small cleanup to entity platform translation fetching While I could not realize the performance improvemnet I had hoped in #108800, I pulled this out since its a nice cleanup to avoid constructing the inner function over and over. * stale docstring --- homeassistant/helpers/entity_platform.py | 48 ++++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index b9336a62e6e..7cf7ab62495 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -390,6 +390,22 @@ class EntityPlatform: finally: warn_task.cancel() + async def _async_get_translations( + self, language: str, category: str, integration: str + ) -> dict[str, Any]: + """Get translations for a language, category, and integration.""" + try: + return await translation.async_get_translations( + self.hass, language, category, {integration} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + async def async_load_translations(self) -> None: """Load translations.""" hass = self.hass @@ -398,37 +414,21 @@ class EntityPlatform: if hass.config.language in languages.NATIVE_ENTITY_IDS else languages.DEFAULT_LANGUAGE ) - - async def get_translations( - language: str, category: str, integration: str - ) -> dict[str, Any]: - """Get entity translations.""" - try: - return await translation.async_get_translations( - hass, language, category, {integration} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - self.component_translations = await get_translations( - hass.config.language, "entity_component", self.domain + config_language = hass.config.language + self.component_translations = await self._async_get_translations( + config_language, "entity_component", self.domain ) - self.platform_translations = await get_translations( - hass.config.language, "entity", self.platform_name + self.platform_translations = await self._async_get_translations( + config_language, "entity", self.platform_name ) - if object_id_language == hass.config.language: + if object_id_language == config_language: self.object_id_component_translations = self.component_translations self.object_id_platform_translations = self.platform_translations else: - self.object_id_component_translations = await get_translations( + self.object_id_component_translations = await self._async_get_translations( object_id_language, "entity_component", self.domain ) - self.object_id_platform_translations = await get_translations( + self.object_id_platform_translations = await self._async_get_translations( object_id_language, "entity", self.platform_name ) From 9de8409f484c6a500c18fdcef95023fbeee23507 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Jan 2024 19:17:18 -1000 Subject: [PATCH 1044/1544] Speed up security filter middleware (#108703) * Speed up security filter middleware Check the path and query string with the filter expression once instead of checking the path and query string seperately. If we get a hit than we check the query string to ensure we give a more verbose error about where the filter hit. Additionally since we see the same urls over and over, cache the unquote * request.url is to expensive, cheaper to join * aiohttp has a path_qs fast path * construct the string outselves so it functions exactly as before --- .../components/http/security_filter.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index e8e3aa4699c..4d71334f1cf 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from functools import lru_cache import logging import re from typing import Final @@ -43,6 +44,7 @@ UNSAFE_URL_BYTES = ["\t", "\r", "\n"] def setup_security_filter(app: Application) -> None: """Create security filter middleware for the app.""" + @lru_cache def _recursive_unquote(value: str) -> str: """Handle values that are encoded multiple times.""" if (unquoted := unquote(value)) != value: @@ -54,34 +56,38 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + path_with_query_string = f"{request.path}?{request.query_string}" + for unsafe_byte in UNSAFE_URL_BYTES: - if unsafe_byte in request.path: + if unsafe_byte in path_with_query_string: + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest _LOGGER.warning( "Filtered a request with an unsafe byte in path: %s", request.raw_path, ) raise HTTPBadRequest - if unsafe_byte in request.query_string: + if FILTERS.search(_recursive_unquote(path_with_query_string)): + # Check the full path with query string first, if its + # a hit, than check just the query string to give a more + # specific warning. + if FILTERS.search(_recursive_unquote(request.query_string)): _LOGGER.warning( - "Filtered a request with unsafe byte query string: %s", + "Filtered a request with a potential harmful query string: %s", request.raw_path, ) raise HTTPBadRequest - if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path ) raise HTTPBadRequest - if FILTERS.search(_recursive_unquote(request.query_string)): - _LOGGER.warning( - "Filtered a request with a potential harmful query string: %s", - request.raw_path, - ) - raise HTTPBadRequest - return await handler(request) app.middlewares.append(security_filter_middleware) From dff5e457614513eca71a016e36e136a5f76d14e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Jan 2024 20:20:19 -1000 Subject: [PATCH 1045/1544] Small speed up to listing config entries in the websocket api (#108892) --- .../components/config/config_entries.py | 29 +++++++++---------- homeassistant/config_entries.py | 13 +++++++++ tests/test_config_entries.py | 7 +++++ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index c289459a2af..b19c0101232 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -516,7 +516,7 @@ async def async_matching_config_entries( if not type_filter: return [entry_json(entry) for entry in entries] - integrations = {} + integrations: dict[str, Integration] = {} # Fetch all the integrations so we can check their type domains = {entry.domain for entry in entries} for domain_key, integration_or_exc in ( @@ -531,35 +531,32 @@ async def async_matching_config_entries( # when only helpers are requested, also filter out entries # from unknown integrations. This prevent them from showing # up in the helpers UI. - entries = [ - entry + filter_is_not_helper = type_filter != ["helper"] + filter_set = set(type_filter) + return [ + entry_json(entry) for entry in entries - if (type_filter != ["helper"] and entry.domain not in integrations) - or ( - entry.domain in integrations - and integrations[entry.domain].integration_type in type_filter + # If the filter is not 'helper', we still include the integration + # even if its not returned from async_get_integrations for backwards + # compatibility. + if ( + (integration := integrations.get(entry.domain)) + and integration.integration_type in filter_set ) + or (filter_is_not_helper and entry.domain not in integrations) ] - return [entry_json(entry) for entry in entries] - @callback def entry_json(entry: config_entries.ConfigEntry) -> dict[str, Any]: """Return JSON value of a config entry.""" - handler = config_entries.HANDLERS.get(entry.domain) - # work out if handler has support for options flow - supports_options = handler is not None and handler.async_supports_options_flow( - entry - ) - return { "entry_id": entry.entry_id, "domain": entry.domain, "title": entry.title, "source": entry.source, "state": entry.state.value, - "supports_options": supports_options, + "supports_options": entry.supports_options, "supports_remove_device": entry.supports_remove_device or False, "supports_unload": entry.supports_unload or False, "pref_disable_new_entities": entry.pref_disable_new_entities, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7eee83953a7..d9cfbd08886 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -54,6 +54,7 @@ if TYPE_CHECKING: from .components.zeroconf import ZeroconfServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo + _LOGGER = logging.getLogger(__name__) SOURCE_BLUETOOTH = "bluetooth" @@ -238,6 +239,7 @@ class ConfigEntry: "_integration_for_domain", "_tries", "_setup_again_job", + "_supports_options", ) def __init__( @@ -318,6 +320,9 @@ class ConfigEntry: # Supports remove device self.supports_remove_device: bool | None = None + # Supports options + self._supports_options: bool | None = None + # Listeners to call on update self.update_listeners: list[UpdateListenerType] = [] @@ -351,6 +356,14 @@ class ConfigEntry: f"title={self.title} state={self.state} unique_id={self.unique_id}>" ) + @property + def supports_options(self) -> bool: + """Return if entry supports config options.""" + if self._supports_options is None and (handler := HANDLERS.get(self.domain)): + # work out if handler has support for options flow + self._supports_options = handler.async_supports_options_flow(self) + return self._supports_options or False + async def async_setup( self, hass: HomeAssistant, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index db382ac35f4..e9e1437c06c 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -734,6 +734,7 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: "_integration_for_domain", "_tries", "_setup_again_job", + "_supports_options", } entry = MockConfigEntry(entry_id="mock-entry") @@ -1176,6 +1177,7 @@ async def test_create_entry_options( entries = hass.config_entries.async_entries("comp") assert len(entries) == 1 + assert entries[0].supports_options is False assert entries[0].data == {"example": "data"} assert entries[0].options == {"example": "option"} @@ -1202,6 +1204,10 @@ async def test_entry_options( return OptionsFlowHandler() + def async_supports_options_flow(self, entry: MockConfigEntry) -> bool: + """Test options flow.""" + return True + config_entries.HANDLERS["test"] = TestFlow() flow = await manager.options.async_create_flow( entry.entry_id, context={"source": "test"}, data=None @@ -1216,6 +1222,7 @@ async def test_entry_options( assert entry.data == {"first": True} assert entry.options == {"second": True} + assert entry.supports_options is True async def test_entry_options_abort( From b91e9edd1604307b8bad5895fa10f971e9c61855 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 26 Jan 2024 03:05:36 -0500 Subject: [PATCH 1046/1544] Remove "max_current" from TechnoVE sensors (#108898) --- homeassistant/components/technove/sensor.py | 9 --------- homeassistant/components/technove/strings.json | 3 --- 2 files changed, 12 deletions(-) diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index 99cdc62ceee..38d0eeabe49 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -57,15 +57,6 @@ SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda station: station.info.voltage_out, ), - TechnoVESensorEntityDescription( - key="max_current", - translation_key="max_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda station: station.info.max_current, - ), TechnoVESensorEntityDescription( key="max_station_current", translation_key="max_station_current", diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 39a86ad29f8..6f7cb0d9f6b 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -32,9 +32,6 @@ "voltage_out": { "name": "Output voltage" }, - "max_current": { - "name": "Max current" - }, "max_station_current": { "name": "Max station current" }, From d4ac5e492bac26ee31092483b6d10f59666dde8e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 26 Jan 2024 09:33:56 +0100 Subject: [PATCH 1047/1544] Add entity registry test to Withings (#108900) --- .../withings/fixtures/measurements.json | 20 + .../withings/snapshots/test_diagnostics.ambr | 12 + .../withings/snapshots/test_sensor.ambr | 2228 ++++++++++++++++- tests/components/withings/test_calendar.py | 1 - tests/components/withings/test_sensor.py | 22 +- 5 files changed, 2213 insertions(+), 70 deletions(-) diff --git a/tests/components/withings/fixtures/measurements.json b/tests/components/withings/fixtures/measurements.json index 3ed59a7c3f4..03222521877 100644 --- a/tests/components/withings/fixtures/measurements.json +++ b/tests/components/withings/fixtures/measurements.json @@ -108,6 +108,26 @@ "type": 169, "unit": 0, "value": 100 + }, + { + "type": 198, + "unit": 0, + "value": 102 + }, + { + "type": 197, + "unit": 0, + "value": 102 + }, + { + "type": 196, + "unit": 0, + "value": 102 + }, + { + "type": 170, + "unit": 0, + "value": 102 } ], "modelid": 45, diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index f9b4a1d9bba..3dc7e824230 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -25,6 +25,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, @@ -57,6 +61,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, @@ -89,6 +97,10 @@ 155, 168, 169, + 198, + 197, + 196, + 170, ]), 'received_sleep_data': True, 'received_workout_data': True, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 08d2786fae9..29b3dafb910 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,5 +1,41 @@ # serializer version: 1 -# name: test_all_entities[sensor.henk_active_calories_burnt_today] +# name: test_all_entities[sensor.henk_active_calories_burnt_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_active_calories_burnt_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active calories burnt today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_active_calories_burnt_today', + 'unique_id': 'withings_12345_activity_active_calories_burnt_today', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_active_calories_burnt_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Active calories burnt today', @@ -14,7 +50,43 @@ 'state': '221.132', }) # --- -# name: test_all_entities[sensor.henk_active_time_today] +# name: test_all_entities[sensor.henk_active_time_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_active_time_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active time today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_active_duration_today', + 'unique_id': 'withings_12345_activity_active_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_active_time_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -30,7 +102,40 @@ 'state': '1907', }) # --- -# name: test_all_entities[sensor.henk_average_heart_rate] +# name: test_all_entities[sensor.henk_average_heart_rate-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.henk_average_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_average_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average heart rate', @@ -44,7 +149,40 @@ 'state': '103', }) # --- -# name: test_all_entities[sensor.henk_average_respiratory_rate] +# name: test_all_entities[sensor.henk_average_respiratory_rate-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.henk_average_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_average_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average respiratory rate', @@ -58,7 +196,40 @@ 'state': '14', }) # --- -# name: test_all_entities[sensor.henk_body_temperature] +# name: test_all_entities[sensor.henk_body_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.henk_body_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Body temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'body_temperature', + 'unique_id': 'withings_12345_body_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_body_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -73,7 +244,43 @@ 'state': '40', }) # --- -# name: test_all_entities[sensor.henk_bone_mass] +# name: test_all_entities[sensor.henk_bone_mass-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.henk_bone_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bone mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bone_mass', + 'unique_id': 'withings_12345_bone_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_bone_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -88,7 +295,40 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.henk_breathing_disturbances_intensity] +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity-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.henk_breathing_disturbances_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Breathing disturbances intensity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'breathing_disturbances_intensity', + 'unique_id': 'withings_12345_sleep_breathing_disturbances_intensity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Breathing disturbances intensity', @@ -101,7 +341,41 @@ 'state': '9', }) # --- -# name: test_all_entities[sensor.henk_calories_burnt_last_workout] +# name: test_all_entities[sensor.henk_calories_burnt_last_workout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_calories_burnt_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calories burnt last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_active_calories_burnt', + 'unique_id': 'withings_12345_workout_active_calories_burnt', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Calories burnt last workout', @@ -114,7 +388,40 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.henk_deep_sleep] +# name: test_all_entities[sensor.henk_deep_sleep-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.henk_deep_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deep sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deep_sleep', + 'unique_id': 'withings_12345_sleep_deep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_deep_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -129,7 +436,40 @@ 'state': '5820', }) # --- -# name: test_all_entities[sensor.henk_diastolic_blood_pressure] +# name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Diastolic blood pressure', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'diastolic_blood_pressure', + 'unique_id': 'withings_12345_diastolic_blood_pressure_mmhg', + 'unit_of_measurement': 'mmhg', + }) +# --- +# name: test_all_entities[sensor.henk_diastolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Diastolic blood pressure', @@ -143,7 +483,41 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_distance_travelled_last_workout] +# name: test_all_entities[sensor.henk_distance_travelled_last_workout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_distance_travelled_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance travelled last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_distance', + 'unique_id': 'withings_12345_workout_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -157,7 +531,43 @@ 'state': '232', }) # --- -# name: test_all_entities[sensor.henk_distance_travelled_today] +# name: test_all_entities[sensor.henk_distance_travelled_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_distance_travelled_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance travelled today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_distance_today', + 'unique_id': 'withings_12345_activity_distance_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_distance_travelled_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -173,7 +583,170 @@ 'state': '1020.121', }) # --- -# name: test_all_entities[sensor.henk_elevation_change_last_workout] +# name: test_all_entities[sensor.henk_electrodermal_activity_feet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_electrodermal_activity_feet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity feet', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_feet', + 'unique_id': 'withings_12345_electrodermal_activity_feet', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_feet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity feet', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_feet', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_left_foot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_electrodermal_activity_left_foot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity left foot', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_left_foot', + 'unique_id': 'withings_12345_electrodermal_activity_left_foot', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_left_foot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity left foot', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_left_foot', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_right_foot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_electrodermal_activity_right_foot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electrodermal activity right foot', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electrodermal_activity_right_foot', + 'unique_id': 'withings_12345_electrodermal_activity_right_foot', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_electrodermal_activity_right_foot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Electrodermal activity right foot', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_electrodermal_activity_right_foot', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_elevation_change_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elevation change last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_elevation', + 'unique_id': 'withings_12345_workout_floors_climbed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -187,7 +760,40 @@ 'state': '4', }) # --- -# name: test_all_entities[sensor.henk_elevation_change_today] +# name: test_all_entities[sensor.henk_elevation_change_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_elevation_change_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elevation change today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_elevation_today', + 'unique_id': 'withings_12345_activity_floors_climbed_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_elevation_change_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -203,7 +809,40 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.henk_extracellular_water] +# name: test_all_entities[sensor.henk_extracellular_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_extracellular_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Extracellular water', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'extracellular_water', + 'unique_id': 'withings_12345_extracellular_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_extracellular_water-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -218,7 +857,43 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_fat_free_mass] +# name: test_all_entities[sensor.henk_fat_free_mass-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.henk_fat_free_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass', + 'unique_id': 'withings_12345_fat_free_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -233,7 +908,43 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.henk_fat_mass] +# name: test_all_entities[sensor.henk_fat_mass-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.henk_fat_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass', + 'unique_id': 'withings_12345_fat_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -248,7 +959,43 @@ 'state': '5', }) # --- -# name: test_all_entities[sensor.henk_fat_ratio] +# name: test_all_entities[sensor.henk_fat_ratio-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.henk_fat_ratio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fat ratio', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_ratio', + 'unique_id': 'withings_12345_fat_ratio_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_fat_ratio-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Fat ratio', @@ -262,7 +1009,40 @@ 'state': '0.07', }) # --- -# name: test_all_entities[sensor.henk_heart_pulse] +# name: test_all_entities[sensor.henk_heart_pulse-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.henk_heart_pulse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heart pulse', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heart_pulse', + 'unique_id': 'withings_12345_heart_pulse_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_heart_pulse-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Heart pulse', @@ -276,7 +1056,43 @@ 'state': '60', }) # --- -# name: test_all_entities[sensor.henk_height] +# name: test_all_entities[sensor.henk_height-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.henk_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Height', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'height', + 'unique_id': 'withings_12345_height_m', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -291,7 +1107,40 @@ 'state': '2', }) # --- -# name: test_all_entities[sensor.henk_hydration] +# name: test_all_entities[sensor.henk_hydration-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.henk_hydration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydration', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hydration', + 'unique_id': 'withings_12345_hydration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_hydration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -306,7 +1155,43 @@ 'state': '0.95', }) # --- -# name: test_all_entities[sensor.henk_intense_activity_today] +# name: test_all_entities[sensor.henk_intense_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_intense_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intense activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_intense_duration_today', + 'unique_id': 'withings_12345_activity_intense_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_intense_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -322,7 +1207,40 @@ 'state': '420', }) # --- -# name: test_all_entities[sensor.henk_intracellular_water] +# name: test_all_entities[sensor.henk_intracellular_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_intracellular_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intracellular water', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intracellular_water', + 'unique_id': 'withings_12345_intracellular_water', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_intracellular_water-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -337,7 +1255,41 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_last_workout_duration] +# name: test_all_entities[sensor.henk_last_workout_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_last_workout_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last workout duration', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_duration', + 'unique_id': 'withings_12345_workout_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -351,7 +1303,38 @@ 'state': '255.0', }) # --- -# name: test_all_entities[sensor.henk_last_workout_intensity] +# name: test_all_entities[sensor.henk_last_workout_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_last_workout_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Last workout intensity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_intensity', + 'unique_id': 'withings_12345_workout_intensity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_intensity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Last workout intensity', @@ -363,7 +1346,90 @@ 'state': '30', }) # --- -# name: test_all_entities[sensor.henk_last_workout_type] +# name: test_all_entities[sensor.henk_last_workout_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_last_workout_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last workout type', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_type', + 'unique_id': 'withings_12345_workout_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_type-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -427,7 +1493,40 @@ 'state': 'walk', }) # --- -# name: test_all_entities[sensor.henk_light_sleep] +# name: test_all_entities[sensor.henk_light_sleep-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.henk_light_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_sleep', + 'unique_id': 'withings_12345_sleep_light_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_light_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -442,7 +1541,40 @@ 'state': '10440', }) # --- -# name: test_all_entities[sensor.henk_maximum_heart_rate] +# name: test_all_entities[sensor.henk_maximum_heart_rate-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.henk_maximum_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum heart rate', @@ -456,7 +1588,40 @@ 'state': '120', }) # --- -# name: test_all_entities[sensor.henk_maximum_respiratory_rate] +# name: test_all_entities[sensor.henk_maximum_respiratory_rate-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.henk_maximum_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum respiratory rate', @@ -470,7 +1635,40 @@ 'state': '20', }) # --- -# name: test_all_entities[sensor.henk_minimum_heart_rate] +# name: test_all_entities[sensor.henk_minimum_heart_rate-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.henk_minimum_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum heart rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum_heart_rate', + 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_heart_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum heart rate', @@ -484,7 +1682,40 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_minimum_respiratory_rate] +# name: test_all_entities[sensor.henk_minimum_respiratory_rate-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.henk_minimum_respiratory_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Minimum respiratory rate', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum_respiratory_rate', + 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_respiratory_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum respiratory rate', @@ -498,7 +1729,43 @@ 'state': '10', }) # --- -# name: test_all_entities[sensor.henk_moderate_activity_today] +# name: test_all_entities[sensor.henk_moderate_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_moderate_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moderate activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_moderate_duration_today', + 'unique_id': 'withings_12345_activity_moderate_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_moderate_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -514,7 +1781,43 @@ 'state': '1487', }) # --- -# name: test_all_entities[sensor.henk_muscle_mass] +# name: test_all_entities[sensor.henk_muscle_mass-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.henk_muscle_mass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass', + 'unique_id': 'withings_12345_muscle_mass_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -529,7 +1832,41 @@ 'state': '50', }) # --- -# name: test_all_entities[sensor.henk_pause_during_last_workout] +# name: test_all_entities[sensor.henk_pause_during_last_workout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_pause_during_last_workout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pause during last workout', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'workout_pause_duration', + 'unique_id': 'withings_12345_workout_pause_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_pause_during_last_workout-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -543,7 +1880,40 @@ 'state': '0', }) # --- -# name: test_all_entities[sensor.henk_pulse_wave_velocity] +# name: test_all_entities[sensor.henk_pulse_wave_velocity-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.henk_pulse_wave_velocity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pulse wave velocity', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pulse_wave_velocity', + 'unique_id': 'withings_12345_pulse_wave_velocity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_pulse_wave_velocity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speed', @@ -558,7 +1928,40 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_rem_sleep] +# name: test_all_entities[sensor.henk_rem_sleep-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.henk_rem_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'REM sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rem_sleep', + 'unique_id': 'withings_12345_sleep_rem_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_rem_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -573,7 +1976,40 @@ 'state': '2400', }) # --- -# name: test_all_entities[sensor.henk_skin_temperature] +# name: test_all_entities[sensor.henk_skin_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.henk_skin_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Skin temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'skin_temperature', + 'unique_id': 'withings_12345_skin_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_skin_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -588,7 +2024,43 @@ 'state': '20', }) # --- -# name: test_all_entities[sensor.henk_sleep_goal] +# name: test_all_entities[sensor.henk_sleep_goal-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.henk_sleep_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_goal', + 'unique_id': 'withings_12345_sleep_goal', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_sleep_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -603,7 +2075,40 @@ 'state': '28800', }) # --- -# name: test_all_entities[sensor.henk_sleep_score] +# name: test_all_entities[sensor.henk_sleep_score-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.henk_sleep_score', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep score', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_score', + 'unique_id': 'withings_12345_sleep_score', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_all_entities[sensor.henk_sleep_score-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Sleep score', @@ -617,7 +2122,40 @@ 'state': '37', }) # --- -# name: test_all_entities[sensor.henk_snoring] +# name: test_all_entities[sensor.henk_snoring-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.henk_snoring', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snoring', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'snoring', + 'unique_id': 'withings_12345_sleep_snoring', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_snoring-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring', @@ -630,7 +2168,40 @@ 'state': '1080', }) # --- -# name: test_all_entities[sensor.henk_snoring_episode_count] +# name: test_all_entities[sensor.henk_snoring_episode_count-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.henk_snoring_episode_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snoring episode count', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'snoring_episode_count', + 'unique_id': 'withings_12345_sleep_snoring_eposode_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_snoring_episode_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring episode count', @@ -643,7 +2214,43 @@ 'state': '18', }) # --- -# name: test_all_entities[sensor.henk_soft_activity_today] +# name: test_all_entities[sensor.henk_soft_activity_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_soft_activity_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Soft activity today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_soft_duration_today', + 'unique_id': 'withings_12345_activity_soft_duration_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_soft_activity_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -659,7 +2266,40 @@ 'state': '1516', }) # --- -# name: test_all_entities[sensor.henk_spo2] +# name: test_all_entities[sensor.henk_spo2-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.henk_spo2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SpO2', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spo2', + 'unique_id': 'withings_12345_spo2_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.henk_spo2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk SpO2', @@ -673,7 +2313,40 @@ 'state': '0.95', }) # --- -# name: test_all_entities[sensor.henk_step_goal] +# name: test_all_entities[sensor.henk_step_goal-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.henk_step_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Step goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'step_goal', + 'unique_id': 'withings_12345_step_goal', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.henk_step_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Step goal', @@ -687,7 +2360,40 @@ 'state': '10000', }) # --- -# name: test_all_entities[sensor.henk_steps_today] +# name: test_all_entities[sensor.henk_steps_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_steps_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steps today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_steps_today', + 'unique_id': 'withings_12345_activity_steps_today', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.henk_steps_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Steps today', @@ -702,7 +2408,40 @@ 'state': '1155', }) # --- -# name: test_all_entities[sensor.henk_systolic_blood_pressure] +# name: test_all_entities[sensor.henk_systolic_blood_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Systolic blood pressure', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'systolic_blood_pressure', + 'unique_id': 'withings_12345_systolic_blood_pressure_mmhg', + 'unit_of_measurement': 'mmhg', + }) +# --- +# name: test_all_entities[sensor.henk_systolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Systolic blood pressure', @@ -716,7 +2455,40 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_temperature] +# name: test_all_entities[sensor.henk_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.henk_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_temperature_c', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -731,7 +2503,40 @@ 'state': '40', }) # --- -# name: test_all_entities[sensor.henk_time_to_sleep] +# name: test_all_entities[sensor.henk_time_to_sleep-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.henk_time_to_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to sleep', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_sleep', + 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_time_to_sleep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -746,7 +2551,40 @@ 'state': '540', }) # --- -# name: test_all_entities[sensor.henk_time_to_wakeup] +# name: test_all_entities[sensor.henk_time_to_wakeup-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.henk_time_to_wakeup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to wakeup', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_wakeup', + 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_time_to_wakeup-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -761,7 +2599,43 @@ 'state': '1140', }) # --- -# name: test_all_entities[sensor.henk_total_calories_burnt_today] +# name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_total_calories_burnt_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total calories burnt today', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_total_calories_burnt_today', + 'unique_id': 'withings_12345_activity_total_calories_burnt_today', + 'unit_of_measurement': 'calories', + }) +# --- +# name: test_all_entities[sensor.henk_total_calories_burnt_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Total calories burnt today', @@ -776,7 +2650,38 @@ 'state': '2444.149', }) # --- -# name: test_all_entities[sensor.henk_vascular_age] +# name: test_all_entities[sensor.henk_vascular_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_vascular_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vascular age', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vascular_age', + 'unique_id': 'withings_12345_vascular_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_vascular_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Vascular age', @@ -788,7 +2693,83 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_vo2_max] +# name: test_all_entities[sensor.henk_visceral_fat_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_visceral_fat_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Visceral fat index', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'visceral_fat_index', + 'unique_id': 'withings_12345_visceral_fat', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.henk_visceral_fat_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Visceral fat index', + }), + 'context': , + 'entity_id': 'sensor.henk_visceral_fat_index', + 'last_changed': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max-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.henk_vo2_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VO2 max', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vo2_max', + 'unique_id': 'withings_12345_vo2_max', + 'unit_of_measurement': 'ml/min/kg', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk VO2 max', @@ -802,7 +2783,40 @@ 'state': '100', }) # --- -# name: test_all_entities[sensor.henk_wakeup_count] +# name: test_all_entities[sensor.henk_wakeup_count-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.henk_wakeup_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wakeup count', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wakeup_count', + 'unique_id': 'withings_12345_sleep_wakeup_count', + 'unit_of_measurement': 'times', + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Wakeup count', @@ -816,7 +2830,40 @@ 'state': '1', }) # --- -# name: test_all_entities[sensor.henk_wakeup_time] +# name: test_all_entities[sensor.henk_wakeup_time-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.henk_wakeup_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wakeup time', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wakeup_time', + 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -831,7 +2878,43 @@ 'state': '3060', }) # --- -# name: test_all_entities[sensor.henk_weight] +# name: test_all_entities[sensor.henk_weight-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.henk_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_weight_kg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_weight-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -846,7 +2929,40 @@ 'state': '70', }) # --- -# name: test_all_entities[sensor.henk_weight_goal] +# name: test_all_entities[sensor.henk_weight_goal-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.henk_weight_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight goal', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weight_goal', + 'unique_id': 'withings_12345_weight_goal', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_weight_goal-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py index 227f65473fc..014beb7a233 100644 --- a/tests/components/withings/test_calendar.py +++ b/tests/components/withings/test_calendar.py @@ -58,7 +58,6 @@ async def test_api_events( async def test_calendar_created_when_workouts_available( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 5d42ace495b..88018d54877 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -30,25 +30,24 @@ async def test_all_entities( snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" with patch("homeassistant.components.withings.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, polling_config_entry) - entity_registry = er.async_get(hass) - entity_entries = er.async_entries_for_config_entry( - entity_registry, polling_config_entry.entry_id - ) + entity_entries = er.async_entries_for_config_entry( + entity_registry, polling_config_entry.entry_id + ) - assert entity_entries - for entity_entry in entity_entries: - assert hass.states.get(entity_entry.entity_id) == snapshot( - name=entity_entry.entity_id - ) + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_update_failed( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, @@ -68,7 +67,6 @@ async def test_update_failed( async def test_update_updates_incrementally( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, @@ -253,7 +251,6 @@ async def test_sleep_sensors_created_when_existed( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test sleep sensors will be added if they existed before.""" await setup_integration(hass, polling_config_entry, False) @@ -301,7 +298,6 @@ async def test_workout_sensors_created_when_existed( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test workout sensors will be added if they existed before.""" await setup_integration(hass, polling_config_entry, False) From 00c2ba69f7e4749308a4a4d1f59b43b9c44dd0bf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 26 Jan 2024 10:48:16 +0100 Subject: [PATCH 1048/1544] Add webhook support to tedee integration (#106846) * start work on webhooks * start work on webhooks * use background task * websocket improvement * add test * add webhook id to mock_config_entry * some changes * add webhook to manifest * fix test * reset poll timer on webhook update * reset poll timer on webhook update * code cleanup * generate webhook id in config flow * fix merge * undo var rename * remove * ruff * ruff * only delete specific webhook * clarify warning * version bump * minor improvements * requested changes * unregister function * move more of unregistration logic * test pushed data * add comment * Update config_flow.py Co-authored-by: Joost Lekkerkerker * ruff --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tedee/__init__.py | 80 +++++++++++++- homeassistant/components/tedee/config_flow.py | 8 +- homeassistant/components/tedee/coordinator.py | 26 ++++- homeassistant/components/tedee/manifest.json | 2 +- tests/components/tedee/conftest.py | 7 +- tests/components/tedee/test_config_flow.py | 43 +++++--- tests/components/tedee/test_init.py | 104 +++++++++++++++++- tests/components/tedee/test_lock.py | 34 +++++- 8 files changed, 274 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index eeb0f8e0d5a..cbc608d03a6 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -1,12 +1,25 @@ """Init the tedee component.""" +from collections.abc import Awaitable, Callable +from http import HTTPStatus import logging +from typing import Any +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response +from pytedee_async.exception import TedeeWebhookException + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import DOMAIN, NAME from .coordinator import TedeeApiCoordinator PLATFORMS = [ @@ -37,6 +50,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + async def unregister_webhook(_: Any) -> None: + await coordinator.async_unregister_webhook() + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook() -> None: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + webhook_name = "Tedee" + if entry.title != NAME: + webhook_name = f"{NAME} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + allowed_methods=[METH_POST], + ) + _LOGGER.debug("Registered Tedee webhook at hass: %s", webhook_url) + + try: + await coordinator.async_register_webhook(webhook_url) + except TedeeWebhookException as ex: + _LOGGER.warning("Failed to register Tedee webhook from bridge: %s", ex) + else: + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + entry.async_create_background_task( + hass, register_webhook(), "tedee_register_webhook" + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -45,9 +90,34 @@ 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +def get_webhook_handler( + coordinator: TedeeApiCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + + body = await request.json() + try: + coordinator.webhook_received(body) + except TedeeWebhookException as ex: + return HomeAssistantView.json( + result=str(ex), status_code=HTTPStatus.BAD_REQUEST + ) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 075a4c998ea..8bd9efd2b17 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -11,8 +11,9 @@ from pytedee_async import ( ) import voluptuous as vol +from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -61,7 +62,10 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") await self.async_set_unique_id(local_bridge.serial) self._abort_if_unique_id_configured() - return self.async_create_entry(title=NAME, data=user_input) + return self.async_create_entry( + title=NAME, + data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, + ) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index c846f2a8d9a..cdd907b2e58 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time +from typing import Any from pytedee_async import ( TedeeClient, @@ -10,6 +11,7 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, TedeeLock, + TedeeWebhookException, ) from pytedee_async.bridge import TedeeBridge @@ -23,7 +25,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=20) +SCAN_INTERVAL = timedelta(seconds=30) GET_LOCKS_INTERVAL_SECONDS = 3600 _LOGGER = logging.getLogger(__name__) @@ -53,6 +55,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.tedee_webhook_id: int | None = None @property def bridge(self) -> TedeeBridge: @@ -103,6 +106,27 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): except (TedeeClientException, TimeoutError) as ex: raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex + def webhook_received(self, message: dict[str, Any]) -> None: + """Handle webhook message.""" + self.tedee_client.parse_webhook_message(message) + self.async_set_updated_data(self.tedee_client.locks_dict) + + async def async_register_webhook(self, webhook_url: str) -> None: + """Register the webhook at the Tedee bridge.""" + self.tedee_webhook_id = await self.tedee_client.register_webhook(webhook_url) + + async def async_unregister_webhook(self) -> None: + """Unregister the webhook at the Tedee bridge.""" + if self.tedee_webhook_id is not None: + try: + await self.tedee_client.delete_webhook(self.tedee_webhook_id) + except TedeeWebhookException as ex: + _LOGGER.warning( + "Failed to unregister Tedee webhook from bridge: %s", ex + ) + else: + _LOGGER.debug("Unregistered Tedee webhook") + def _async_add_remove_locks(self) -> None: """Add new locks, remove non-existing locks.""" if not self._locks_last_update: diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 09a46441e66..dbed87bb890 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -3,7 +3,7 @@ "name": "Tedee", "codeowners": ["@patrickhilker", "@zweckj"], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "requirements": ["pytedee-async==0.2.12"] diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 21fb4047ab3..a633b1642ea 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -10,11 +10,13 @@ from pytedee_async.lock import TedeeLock import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -25,6 +27,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_LOCAL_ACCESS_TOKEN: "api_token", CONF_HOST: "192.168.1.42", + CONF_WEBHOOK_ID: WEBHOOK_ID, }, unique_id="0000-0000", ) @@ -59,6 +62,8 @@ def mock_tedee(request) -> Generator[MagicMock, None, None]: tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") tedee.parse_webhook_message.return_value = None + tedee.register_webhook.return_value = 1 + tedee.delete_webhooks.return_value = None locks_json = json.loads(load_fixture("locks.json", DOMAIN)) diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index bc5b73aa4a9..68a61842fc3 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Tedee config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pytedee_async import ( TedeeClientException, @@ -10,10 +10,12 @@ import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry FLOW_UNIQUE_ID = "112233445566778899" @@ -22,25 +24,30 @@ LOCAL_ACCESS_TOKEN = "api_token" async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: """Test config flow with one bridge.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + with patch( + "homeassistant.components.tedee.config_flow.webhook_generate_id", + return_value=WEBHOOK_ID, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { CONF_HOST: "192.168.1.62", CONF_LOCAL_ACCESS_TOKEN: "token", - }, - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["data"] == { - CONF_HOST: "192.168.1.62", - CONF_LOCAL_ACCESS_TOKEN: "token", - } + CONF_WEBHOOK_ID: WEBHOOK_ID, + } async def test_flow_already_configured( diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index ca64c01a983..05fb2c1d6eb 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -1,15 +1,27 @@ """Test initialization of tedee.""" +from http import HTTPStatus +from typing import Any from unittest.mock import MagicMock +from urllib.parse import urlparse -from pytedee_async.exception import TedeeAuthException, TedeeClientException +from pytedee_async.exception import ( + TedeeAuthException, + TedeeClientException, + TedeeWebhookException, +) import pytest from syrupy import SnapshotAssertion +from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_load_unload_config_entry( @@ -50,6 +62,62 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_cleanup_on_shutdown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + + +async def test_webhook_cleanup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_tedee.delete_webhook.side_effect = TedeeWebhookException("") + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + assert "Failed to unregister Tedee webhook from bridge" in caplog.text + + +async def test_webhook_registration_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + mock_tedee.register_webhook.side_effect = TedeeWebhookException("") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_tedee.register_webhook.assert_called_once() + assert "Failed to register Tedee webhook from bridge" in caplog.text + + async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -67,3 +135,37 @@ async def test_bridge_device( ) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ("body", "expected_code", "side_effect"), + [ + ({"hello": "world"}, HTTPStatus.OK, None), # Success + (None, HTTPStatus.BAD_REQUEST, None), # Missing data + ({}, HTTPStatus.BAD_REQUEST, TedeeWebhookException), # Error + ], +) +async def test_webhook_post( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + body: dict[str, Any], + expected_code: HTTPStatus, + side_effect: Exception, +) -> None: + """Test webhook callback.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + mock_tedee.parse_webhook_message.side_effect = side_effect + resp = await client.post(urlparse(webhook_url).path, json=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + assert resp.status == expected_code diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index fca1ae2b07f..2f8b1e2b36d 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -1,9 +1,10 @@ """Tests for tedee lock.""" from datetime import timedelta from unittest.mock import MagicMock +from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock +from pytedee_async import TedeeLock, TedeeLockState from pytedee_async.exception import ( TedeeClientException, TedeeDataUpdateException, @@ -17,15 +18,21 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_LOCKED, STATE_LOCKING, + STATE_UNLOCKED, STATE_UNLOCKING, ) +from homeassistant.components.webhook import async_generate_url from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("init_integration") @@ -266,3 +273,28 @@ async def test_new_lock( assert state state = hass.states.get("lock.lock_6g7h") assert state + + +async def test_webhook_update( + hass: HomeAssistant, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test updated data set through webhook.""" + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKED + + webhook_data = {"dummystate": 6} + mock_tedee.locks_dict[ + 12345 + ].state = TedeeLockState.LOCKED # is updated in the lib, so mock and assert in L296 + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + await client.post(urlparse(webhook_url).path, json=webhook_data) + mock_tedee.parse_webhook_message.assert_called_once_with(webhook_data) + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKED From 7bec5ef6bce997f1ee82a4fc5da0ca02566bdb7f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 26 Jan 2024 14:42:47 +0100 Subject: [PATCH 1049/1544] Use unknown color_mode for MQTT json lights if color mode is not set (#108909) --- .../components/mqtt/light/schema_json.py | 2 + tests/components/mqtt/test_light_json.py | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3479f1611d8..b1e5c1c18d4 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -220,6 +220,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_supported_color_modes = self._config[CONF_SUPPORTED_COLOR_MODES] if self.supported_color_modes and len(self.supported_color_modes) == 1: self._attr_color_mode = next(iter(self.supported_color_modes)) + else: + self._attr_color_mode = ColorMode.UNKNOWN def _update_color(self, values: dict[str, Any]) -> None: if not self._config[CONF_COLOR_MODE]: diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c5c24c3ae79..d1fa2b72a31 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -303,6 +303,80 @@ async def test_single_color_mode( assert state.attributes.get(light.ATTR_COLOR_MODE) == color_modes[0] +@pytest.mark.parametrize("hass_config", [COLOR_MODES_CONFIG]) +async def test_turn_on_with_unknown_color_mode_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup and turn with unknown color_mode in optimistic mode.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # Turn on the light without brightness or color_temp attributes + await common.async_turn_on(hass, "light.test") + state = hass.states.get("light.test") + assert state.attributes.get("color_mode") == light.ColorMode.UNKNOWN + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp") is None + assert state.state == STATE_ON + + # Turn on the light with brightness or color_temp attributes + await common.async_turn_on(hass, "light.test", brightness=50, color_temp=192) + state = hass.states.get("light.test") + assert state.attributes.get("color_mode") == light.ColorMode.COLOR_TEMP + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp") == 192 + assert state.state == STATE_ON + + +@pytest.mark.parametrize( + "hass_config", + [ + ( + help_custom_config( + light.DOMAIN, + COLOR_MODES_CONFIG, + ({"state_topic": "test_light"},), + ) + ) + ], +) +async def test_controlling_state_with_unknown_color_mode( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test setup and turn with unknown color_mode in optimistic mode.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # Send `on` state but omit other attributes + async_fire_mqtt_message( + hass, + "test_light", + '{"state": "ON"}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get(light.ATTR_COLOR_TEMP) is None + assert state.attributes.get(light.ATTR_BRIGHTNESS) is None + assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.UNKNOWN + + # Send complete light state + async_fire_mqtt_message( + hass, + "test_light", + '{"state": "ON", "brightness": 50, "color_mode": "color_temp", "color_temp": 192}', + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + + assert state.attributes.get(light.ATTR_COLOR_TEMP) == 192 + assert state.attributes.get(light.ATTR_BRIGHTNESS) == 50 + assert state.attributes.get(light.ATTR_COLOR_MODE) == light.ColorMode.COLOR_TEMP + + @pytest.mark.parametrize( "hass_config", [ From b074334c073448f9cb0b90193e4dffbef22ae3b1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Jan 2024 18:22:49 +0100 Subject: [PATCH 1050/1544] Fix light color mode in advantage_air (#108875) --- homeassistant/components/advantage_air/light.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 0ee91c6fcbc..47c8c7c1768 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -83,7 +83,8 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): class AdvantageAirLightDimmable(AdvantageAirLight): """Representation of Advantage Air Dimmable Light.""" - _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: """Initialize an Advantage Air Dimmable Light.""" @@ -107,13 +108,15 @@ class AdvantageAirLightDimmable(AdvantageAirLight): class AdvantageAirThingLight(AdvantageAirThingEntity, LightEntity): """Representation of Advantage Air Light controlled by myThings.""" + _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} class AdvantageAirThingLightDimmable(AdvantageAirThingEntity, LightEntity): """Representation of Advantage Air Dimmable Light controlled by myThings.""" - _attr_supported_color_modes = {ColorMode.ONOFF, ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @property def brightness(self) -> int: From d007327cf57c87566b7009bff07bc8d76c252e9d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 26 Jan 2024 19:04:48 +0100 Subject: [PATCH 1051/1544] Deprecate legacy Proximity entity (#108730) * deprecate proximity entity * add test * extend tests * adjust strings * make issue fixable * use default repairflow --- .../components/proximity/__init__.py | 23 +++++++ .../components/proximity/strings.json | 13 ++++ tests/components/proximity/test_init.py | 69 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index c4ab915b577..fabbcaec51a 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,6 +5,8 @@ import logging import voluptuous as vol +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.const import ( CONF_DEVICES, CONF_NAME, @@ -15,6 +17,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -80,6 +83,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: {CONF_NAME: friendly_name, **proximity_config}, config, ) + + # deprecate proximity entity - can be removed in 2024.8 + used_in = automations_with_entity(hass, f"{DOMAIN}.{friendly_name}") + used_in += scripts_with_entity(hass, f"{DOMAIN}.{friendly_name}") + if used_in: + async_create_issue( + hass, + DOMAIN, + f"deprecated_proximity_entity_{friendly_name}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_proximity_entity", + translation_placeholders={ + "entity": f"{DOMAIN}.{friendly_name}", + "used_in": "\n- ".join([f"`{x}`" for x in used_in]), + }, + ) + return True diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 56802e08051..de2c3443998 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -13,5 +13,18 @@ }, "nearest": { "name": "Nearest device" } } + }, + "issues": { + "deprecated_proximity_entity": { + "title": "The proximity entity is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::proximity::issues::deprecated_proximity_entity::title%]", + "description": "The proximity entity `{entity}` is deprecated and will be removed in `2024.8`. However it is used within the following configurations:\n- {used_in}\n\nPlease adjust any automations or scripts that use this deprecated Proximity entity.\nFor each tracked person or device one sensor for the distance and the direction of travel to/from the monitored zone is created. Additionally for each Proximity configuration one sensor which shows the nearest device or person to the monitored zone is created. With this you can use the Min/Max integration to determine the nearest and furthest distance." + } + } + } + } } } diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 5a3fee629ac..b3a83624952 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -2,9 +2,13 @@ import pytest +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.proximity import DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -877,6 +881,71 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.state == "away_from" +async def test_create_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue for deprecated proximity entities used in automations and scripts.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": "proximity.home"}, + "action": { + "service": "automation.turn_on", + "target": {"entity_id": "automation.test"}, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": "proximity.home", + "state": "home", + }, + ], + } + } + }, + ) + config = { + "proximity": { + "home": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, + "work": {"tolerance": "1", "zone": "work"}, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + automation_entities = automations_with_entity(hass, "proximity.home") + assert len(automation_entities) == 1 + assert automation_entities[0] == "automation.test" + + script_entites = scripts_with_entity(hass, "proximity.home") + + assert len(script_entites) == 1 + assert script_entites[0] == "script.test" + assert issue_registry.async_get_issue(DOMAIN, "deprecated_proximity_entity_home") + + assert not issue_registry.async_get_issue( + DOMAIN, "deprecated_proximity_entity_work" + ) + + def config_zones(hass): """Set up zones for test.""" hass.config.components.add("zone") From b1b53ac893f0fbb43f6319e04470ba9f8649589f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 26 Jan 2024 20:33:21 +0100 Subject: [PATCH 1052/1544] Add Ecovacs image entities (#108924) * Add Ecovacs image entities * Fix --- .coveragerc | 1 + homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/image.py | 84 +++++++++++++++++++ homeassistant/components/ecovacs/strings.json | 5 ++ tests/components/ecovacs/test_init.py | 2 +- 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ecovacs/image.py diff --git a/.coveragerc b/.coveragerc index 0d02af162fb..13cab09e294 100644 --- a/.coveragerc +++ b/.coveragerc @@ -283,6 +283,7 @@ omit = homeassistant/components/econet/water_heater.py homeassistant/components/ecovacs/controller.py homeassistant/components/ecovacs/entity.py + homeassistant/components/ecovacs/image.py homeassistant/components/ecovacs/util.py homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 5a17fd6d66f..1f28240c06a 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.SELECT, Platform.SENSOR, Platform.VACUUM, diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py new file mode 100644 index 00000000000..18c162138fb --- /dev/null +++ b/homeassistant/components/ecovacs/image.py @@ -0,0 +1,84 @@ +"""Ecovacs image entities.""" + +from deebot_client.capabilities import CapabilityMap +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 .entity import EcovacsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device in controller.devices: + if caps := device.capabilities.map: + entities.append(EcovacsMap(device, caps, hass)) + + if entities: + async_add_entities(entities) + + +class EcovacsMap( + EcovacsEntity[CapabilityMap], + ImageEntity, +): + """Ecovacs map.""" + + _attr_content_type = "image/svg+xml" + + def __init__( + self, + device: Device, + capability: CapabilityMap, + hass: HomeAssistant, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, hass=hass) + self._attr_extra_state_attributes = {} + + entity_description = EntityDescription( + key="map", + translation_key="map", + ) + + def image(self) -> bytes | None: + """Return bytes of image or None.""" + if svg := self._device.map.get_svg_map(): + return svg.encode() + + return None + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_info(event: CachedMapInfoEvent) -> None: + self._attr_extra_state_attributes["map_name"] = event.name + + async def on_changed(event: MapChangedEvent) -> None: + self._attr_image_last_updated = event.when + self.async_write_ha_state() + + self._subscribe(self._capability.chached_info.event, on_info) + self._subscribe(self._capability.changed.event, on_changed) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + await super().async_update() + self._device.map.refresh() diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 7a9065d7706..016c43ceb09 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -24,6 +24,11 @@ "name": "Mop attached" } }, + "image": { + "map": { + "name": "Map" + } + }, "sensor": { "error": { "name": "Error", diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c64d3055624..04e71567dda 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,7 +116,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 16), + ("yna5x1", 17), ], ) async def test_all_entities_loaded( From 5177d022e8c01986b95f116a0c0900298d6d53f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 17:55:26 -1000 Subject: [PATCH 1053/1544] Switch imap to use async_update_reload_and_abort helper (#108935) --- homeassistant/components/imap/config_flow.py | 4 +--- tests/components/imap/test_config_flow.py | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 70594d5fd7c..dea7a0e2e71 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -169,11 +169,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} if not (errors := await validate_input(self.hass, user_input)): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data=user_input ) - await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( description_placeholders={ diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index d36cffbce06..8c91797ae92 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -239,6 +239,7 @@ async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" From 0120d000819a3f24f06d74e6fd8800067fe8bb32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 17:55:58 -1000 Subject: [PATCH 1054/1544] Switch unifiprotect to use async_update_reload_and_abort helper (#108934) --- homeassistant/components/unifiprotect/config_flow.py | 4 +--- tests/components/unifiprotect/test_config_flow.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 1ca030ce48e..ec756118eb5 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -295,9 +295,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # validate login data _, errors = await self._async_get_nvr_data(form_data) if not errors: - self.hass.config_entries.async_update_entry(self.entry, data=form_data) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(self.entry, data=form_data) self.context["title_placeholders"] = { "name": self.entry.title, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 854109bee6d..6af636ef448 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -226,9 +226,10 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: result2["flow_id"], { "username": "test-username", - "password": "test-password", + "password": "new-password", }, ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" From f96f4d31f76bd3a942cba0b658480592e58a4308 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 18:02:42 -1000 Subject: [PATCH 1055/1544] Convert referenced registry functions to use cached_property (#108895) * Convert referenced registry functions to use cached_property These already implemented caching, but now that we can use cached_property because the lock problem is solved, we can make the code simplier and faster * missed one * make them the same --- .../components/automation/__init__.py | 36 +++++++-------- homeassistant/components/script/__init__.py | 26 ++++++----- homeassistant/helpers/script.py | 44 ++++++++----------- 3 files changed, 51 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b5faeefdbe4..dbf76a1fe59 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from functools import partial import logging -from typing import Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol @@ -111,6 +111,12 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_automation +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -334,7 +340,7 @@ class BaseAutomationEntity(ToggleEntity, ABC): return {CONF_ID: self.unique_id} return None - @property + @cached_property @abstractmethod def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" @@ -344,12 +350,12 @@ class BaseAutomationEntity(ToggleEntity, ABC): def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - @property + @cached_property @abstractmethod def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - @property + @cached_property @abstractmethod def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -389,7 +395,7 @@ class UnavailableAutomationEntity(BaseAutomationEntity): """Return the name of the entity.""" return self._name - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return set() @@ -399,12 +405,12 @@ class UnavailableAutomationEntity(BaseAutomationEntity): """Return referenced blueprint or None.""" return None - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return set() - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return set() @@ -446,8 +452,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.action_script.change_listener = self.async_write_ha_state self._initial_state = initial_state self._is_enabled = False - self._referenced_entities: set[str] | None = None - self._referenced_devices: set[str] | None = None self._logger = LOGGER self._variables = variables self._trigger_variables = trigger_variables @@ -478,7 +482,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): """Return True if entity is on.""" return self._async_detach_triggers is not None or self._is_enabled - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return self.action_script.referenced_areas @@ -490,12 +494,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - if self._referenced_devices is not None: - return self._referenced_devices - referenced = self.action_script.referenced_devices if self._cond_func is not None: @@ -505,15 +506,11 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): for conf in self._trigger_config: referenced |= set(_trigger_extract_devices(conf)) - self._referenced_devices = referenced return referenced - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" - if self._referenced_entities is not None: - return self._referenced_entities - referenced = self.action_script.referenced_entities if self._cond_func is not None: @@ -524,7 +521,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): for entity_id in _trigger_extract_entities(conf): referenced.add(entity_id) - self._referenced_entities = referenced return referenced async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 716f0197c8b..f1a86687255 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -72,6 +72,12 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_script +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCRIPT_SERVICE_SCHEMA = vol.Schema(dict) SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_VARIABLES): {str: cv.match_all}} @@ -381,7 +387,7 @@ class BaseScriptEntity(ToggleEntity, ABC): raw_config: ConfigType | None - @property + @cached_property @abstractmethod def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" @@ -391,12 +397,12 @@ class BaseScriptEntity(ToggleEntity, ABC): def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - @property + @cached_property @abstractmethod def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - @property + @cached_property @abstractmethod def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" @@ -426,7 +432,7 @@ class UnavailableScriptEntity(BaseScriptEntity): """Return the name of the entity.""" return self._name - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return set() @@ -436,12 +442,12 @@ class UnavailableScriptEntity(BaseScriptEntity): """Return referenced blueprint or None.""" return None - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return set() - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return set() @@ -509,7 +515,7 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): """Return true if script is on.""" return self.script.is_running - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" return self.script.referenced_areas @@ -521,12 +527,12 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): return None return self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH] - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" return self.script.referenced_devices - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" return self.script.referenced_entities diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 040059276d3..2a31e02e3de 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,7 +12,7 @@ from functools import partial import itertools import logging from types import MappingProxyType -from typing import Any, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast import voluptuous as vol @@ -101,6 +101,12 @@ from .trace import ( from .trigger import async_initialize_triggers, async_validate_trigger_config from .typing import UNDEFINED, ConfigType, UndefinedType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _T = TypeVar("_T") @@ -1289,9 +1295,6 @@ class Script: self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} - self._referenced_entities: set[str] | None = None - self._referenced_devices: set[str] | None = None - self._referenced_areas: set[str] | None = None self.variables = variables self._variables_dynamic = template.is_complex(variables) if self._variables_dynamic: @@ -1362,15 +1365,12 @@ class Script: """Return true if the current mode support max.""" return self.script_mode in (SCRIPT_MODE_PARALLEL, SCRIPT_MODE_QUEUED) - @property + @cached_property def referenced_areas(self) -> set[str]: """Return a set of referenced areas.""" - if self._referenced_areas is not None: - return self._referenced_areas - - self._referenced_areas = set() - Script._find_referenced_areas(self._referenced_areas, self.sequence) - return self._referenced_areas + referenced_areas: set[str] = set() + Script._find_referenced_areas(referenced_areas, self.sequence) + return referenced_areas @staticmethod def _find_referenced_areas( @@ -1402,15 +1402,12 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_areas(referenced, script[CONF_SEQUENCE]) - @property + @cached_property def referenced_devices(self) -> set[str]: """Return a set of referenced devices.""" - if self._referenced_devices is not None: - return self._referenced_devices - - self._referenced_devices = set() - Script._find_referenced_devices(self._referenced_devices, self.sequence) - return self._referenced_devices + referenced_devices: set[str] = set() + Script._find_referenced_devices(referenced_devices, self.sequence) + return referenced_devices @staticmethod def _find_referenced_devices( @@ -1452,15 +1449,12 @@ class Script: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) - @property + @cached_property def referenced_entities(self) -> set[str]: """Return a set of referenced entities.""" - if self._referenced_entities is not None: - return self._referenced_entities - - self._referenced_entities = set() - Script._find_referenced_entities(self._referenced_entities, self.sequence) - return self._referenced_entities + referenced_entities: set[str] = set() + Script._find_referenced_entities(referenced_entities, self.sequence) + return referenced_entities @staticmethod def _find_referenced_entities( From 61c6c70a7d3e51ee95cee9f5cfdb92bdcb87f703 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 26 Jan 2024 22:04:45 -0600 Subject: [PATCH 1056/1544] Improved Assist debug (#108889) * Differentiate builtin/custom sentences and triggers in debug * Refactor so async_process runs triggers * Report relative path of custom sentences file * Add sentence trigger test --- .../components/conversation/__init__.py | 115 +++++++++++------- .../components/conversation/default_agent.py | 90 ++++++++++---- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 47 +++++++ tests/components/conversation/test_init.py | 86 +++++++++++++ 8 files changed, 275 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index bf18d740821..7ca7fec115f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -31,7 +31,13 @@ from homeassistant.util import language as language_util from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .const import HOME_ASSISTANT_AGENT -from .default_agent import DefaultAgent, async_setup as async_setup_default_agent +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + DefaultAgent, + SentenceTriggerResult, + async_setup as async_setup_default_agent, +) __all__ = [ "DOMAIN", @@ -324,49 +330,64 @@ async def websocket_hass_agent_debug( # Return results for each sentence in the same order as the input. result_dicts: list[dict[str, Any] | None] = [] for result in results: - if result is None: - # Indicate that a recognition failure occurred - result_dicts.append(None) - continue - - successful_match = not result.unmatched_entities - result_dict = { - # Name of the matching intent (or the closest) - "intent": { - "name": result.intent.name, - }, - # Slot values that would be received by the intent - "slots": { # direct access to values - entity_key: entity.value - for entity_key, entity in result.entities.items() - }, - # Extra slot details, such as the originally matched text - "details": { - entity_key: { - "name": entity.name, - "value": entity.value, - "text": entity.text, - } - for entity_key, entity in result.entities.items() - }, - # Entities/areas/etc. that would be targeted - "targets": {}, - # True if match was successful - "match": successful_match, - # Text of the sentence template that matched (or was closest) - "sentence_template": "", - # When match is incomplete, this will contain the best slot guesses - "unmatched_slots": _get_unmatched_slots(result), - } - - if successful_match: - result_dict["targets"] = { - state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, result) + result_dict: dict[str, Any] | None = None + if isinstance(result, SentenceTriggerResult): + result_dict = { + # Matched a user-defined sentence trigger. + # We can't provide the response here without executing the + # trigger. + "match": True, + "source": "trigger", + "sentence_template": result.sentence_template or "", + } + elif isinstance(result, RecognizeResult): + successful_match = not result.unmatched_entities + result_dict = { + # Name of the matching intent (or the closest) + "intent": { + "name": result.intent.name, + }, + # Slot values that would be received by the intent + "slots": { # direct access to values + entity_key: entity.value + for entity_key, entity in result.entities.items() + }, + # Extra slot details, such as the originally matched text + "details": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in result.entities.items() + }, + # Entities/areas/etc. that would be targeted + "targets": {}, + # True if match was successful + "match": successful_match, + # Text of the sentence template that matched (or was closest) + "sentence_template": "", + # When match is incomplete, this will contain the best slot guesses + "unmatched_slots": _get_unmatched_slots(result), } - if result.intent_sentence is not None: - result_dict["sentence_template"] = result.intent_sentence.text + if successful_match: + result_dict["targets"] = { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets(hass, result) + } + + if result.intent_sentence is not None: + result_dict["sentence_template"] = result.intent_sentence.text + + # Inspect metadata to determine if this matched a custom sentence + if result.intent_metadata and result.intent_metadata.get( + METADATA_CUSTOM_SENTENCE + ): + result_dict["source"] = "custom" + result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE) + else: + result_dict["source"] = "builtin" result_dicts.append(result_dict) @@ -402,6 +423,16 @@ def _get_debug_targets( # HassGetState only state_names = set(cv.ensure_list(entities["state"].value)) + if ( + (name is None) + and (area_name is None) + and (not domains) + and (not device_classes) + and (not state_names) + ): + # Avoid "matching" all entities when there is no filter + return + states = intent.async_match_states( hass, name=name, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3207cde405f..bebf8cf4b6a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,6 +62,8 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]] +METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" +METADATA_CUSTOM_FILE = "hass_custom_file" def json_load(fp: IO[str]) -> JsonObjectType: @@ -88,6 +90,15 @@ class TriggerData: callback: TRIGGER_CALLBACK_TYPE +@dataclass(slots=True) +class SentenceTriggerResult: + """Result when matching a sentence trigger in an automation.""" + + sentence: str + sentence_template: str | None + matched_triggers: dict[int, RecognizeResult] + + def _get_language_variations(language: str) -> Iterable[str]: """Generate language codes with and without region.""" yield language @@ -177,8 +188,11 @@ class DefaultAgent(AbstractConversationAgent): async def async_recognize( self, user_input: ConversationInput - ) -> RecognizeResult | None: + ) -> RecognizeResult | SentenceTriggerResult | None: """Recognize intent from user input.""" + if trigger_result := await self._match_triggers(user_input.text): + return trigger_result + language = user_input.language or self.hass.config.language lang_intents = self._lang_intents.get(language) @@ -208,13 +222,36 @@ class DefaultAgent(AbstractConversationAgent): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" - if trigger_result := await self._match_triggers(user_input.text): - return trigger_result - language = user_input.language or self.hass.config.language conversation_id = None # Not supported result = await self.async_recognize(user_input) + + # Check if a trigger matched + if isinstance(result, SentenceTriggerResult): + # Gather callback responses in parallel + trigger_responses = await asyncio.gather( + *( + self._trigger_sentences[trigger_id].callback( + result.sentence, trigger_result + ) + for trigger_id, trigger_result in result.matched_triggers.items() + ) + ) + + # Use last non-empty result as response + response_text: str | None = None + for trigger_response in trigger_responses: + response_text = response_text or trigger_response + + # Convert to conversation result + response = intent.IntentResponse(language=language) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text or "") + + return ConversationResult(response=response) + + # Intent match or failure lang_intents = self._lang_intents.get(language) if result is None: @@ -561,6 +598,22 @@ class DefaultAgent(AbstractConversationAgent): ), dict, ): + # Add metadata so we can identify custom sentences in the debugger + custom_intents_dict = custom_sentences_yaml.get( + "intents", {} + ) + for intent_dict in custom_intents_dict.values(): + intent_data_list = intent_dict.get("data", []) + for intent_data in intent_data_list: + sentence_metadata = intent_data.get("metadata", {}) + sentence_metadata[METADATA_CUSTOM_SENTENCE] = True + sentence_metadata[METADATA_CUSTOM_FILE] = str( + custom_sentences_path.relative_to( + custom_sentences_dir.parent + ) + ) + intent_data["metadata"] = sentence_metadata + merge_dict(intents_dict, custom_sentences_yaml) else: _LOGGER.warning( @@ -807,11 +860,11 @@ class DefaultAgent(AbstractConversationAgent): # Force rebuild on next use self._trigger_intents = None - async def _match_triggers(self, sentence: str) -> ConversationResult | None: + async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None: """Try to match sentence against registered trigger sentences. - Calls the registered callbacks if there's a match and returns a positive - conversation result. + Calls the registered callbacks if there's a match and returns a sentence + trigger result. """ if not self._trigger_sentences: # No triggers registered @@ -824,7 +877,11 @@ class DefaultAgent(AbstractConversationAgent): assert self._trigger_intents is not None matched_triggers: dict[int, RecognizeResult] = {} + matched_template: str | None = None for result in recognize_all(sentence, self._trigger_intents): + if result.intent_sentence is not None: + matched_template = result.intent_sentence.text + trigger_id = int(result.intent.name) if trigger_id in matched_triggers: # Already matched a sentence from this trigger @@ -843,24 +900,7 @@ class DefaultAgent(AbstractConversationAgent): list(matched_triggers), ) - # Gather callback responses in parallel - trigger_responses = await asyncio.gather( - *( - self._trigger_sentences[trigger_id].callback(sentence, result) - for trigger_id, result in matched_triggers.items() - ) - ) - - # Use last non-empty result as speech response - speech: str | None = None - for trigger_response in trigger_responses: - speech = speech or trigger_response - - response = intent.IntentResponse(language=self.hass.config.language) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(speech or "") - - return ConversationResult(response=response) + return SentenceTriggerResult(sentence, matched_template, matched_triggers) def _make_error_result( diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d4a9af346d..96fd7aaf67f 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.3", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eb8befd1781..f6b596b26ab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.4.0 hass-nabucasa==0.75.1 -hassil==1.5.3 +hassil==1.6.0 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240112.0 home-assistant-intents==2024.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2367926f605..9e36de4ed4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1019,7 +1019,7 @@ hass-nabucasa==0.75.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.3 +hassil==1.6.0 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5153ae5cd92..6f792858aac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ habluetooth==2.4.0 hass-nabucasa==0.75.1 # homeassistant.components.conversation -hassil==1.5.3 +hassil==1.6.0 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 1d03bf89ad6..e5a732eab8d 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1408,6 +1408,7 @@ 'slots': dict({ 'name': 'my cool light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -1432,6 +1433,7 @@ 'slots': dict({ 'name': 'my cool light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -1462,6 +1464,7 @@ 'area': 'kitchen', 'domain': 'light', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -1498,6 +1501,7 @@ 'domain': 'light', 'state': 'on', }), + 'source': 'builtin', 'targets': dict({ 'light.kitchen': dict({ 'matched': False, @@ -1522,6 +1526,7 @@ 'slots': dict({ 'domain': 'scene', }), + 'source': 'builtin', 'targets': dict({ }), 'unmatched_slots': dict({ @@ -1540,6 +1545,35 @@ }), }) # --- +# name: test_ws_hass_agent_debug_custom_sentence + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'beer_style': dict({ + 'name': 'beer_style', + 'text': 'lager', + 'value': 'lager', + }), + }), + 'file': 'en/beer.yaml', + 'intent': dict({ + 'name': 'OrderBeer', + }), + 'match': True, + 'sentence_template': "I'd like to order a {beer_style} [please]", + 'slots': dict({ + 'beer_style': 'lager', + }), + 'source': 'custom', + 'targets': dict({ + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- # name: test_ws_hass_agent_debug_null_result dict({ 'results': list([ @@ -1572,6 +1606,7 @@ 'brightness': 100, 'name': 'test light', }), + 'source': 'builtin', 'targets': dict({ 'light.demo_1234': dict({ 'matched': True, @@ -1602,6 +1637,7 @@ 'slots': dict({ 'name': 'test light', }), + 'source': 'builtin', 'targets': dict({ }), 'unmatched_slots': dict({ @@ -1611,3 +1647,14 @@ ]), }) # --- +# name: test_ws_hass_agent_debug_sentence_trigger + dict({ + 'results': list([ + dict({ + 'match': True, + 'sentence_template': 'hello[ world]', + 'source': 'trigger', + }), + ]), + }) +# --- diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 94ce0932964..b654f50f8fe 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1286,3 +1286,89 @@ async def test_ws_hass_agent_debug_out_of_range( # Name matched, but brightness didn't assert results[0]["slots"] == {"name": "test light"} assert results[0]["unmatched_slots"] == {"brightness": 1001} + + +async def test_ws_hass_agent_debug_custom_sentence( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with a custom sentence.""" + # Expecting testing_config/custom_sentences/en/beer.yaml + intent.async_register(hass, OrderBeerIntentHandler()) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "I'd like to order a lager, please.", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "custom" + assert debug_results[0].get("file") == "en/beer.yaml" + + +async def test_ws_hass_agent_debug_sentence_trigger( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a sentence trigger.""" + calls = async_mock_service(hass, "test", "automation") + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["hello", "hello[ world]"], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + client = await hass_ws_client(hass) + + # Use trigger sentence + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": ["hello world"], + } + ) + await hass.async_block_till_done() + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "trigger" + assert debug_results[0].get("sentence_template") == "hello[ world]" + + # Trigger should not have been executed + assert len(calls) == 0 From 5dac5d5c7e9c5a1bf3ec4edc5198a7a0d92d27ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 26 Jan 2024 20:07:24 -1000 Subject: [PATCH 1057/1544] Refactor logbook helpers to reduce splits and lookups (#108933) Co-authored-by: Paulus Schoutsen --- homeassistant/components/logbook/helpers.py | 60 ++++++++++--------- homeassistant/components/logbook/processor.py | 4 +- tests/components/logbook/common.py | 1 + 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 6bfd88c976a..839a742224f 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -39,7 +39,8 @@ def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[st return [ entity_id for entity_id in entity_ids - if not _is_entity_id_filtered(hass, ent_reg, entity_id) + if split_entity_id(entity_id)[0] not in ALWAYS_CONTINUOUS_DOMAINS + and not is_sensor_continuous(hass, ent_reg, entity_id) ] @@ -216,18 +217,37 @@ def async_subscribe_events( ) -def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool: - """Determine if a sensor is continuous by checking its state class. +def is_sensor_continuous( + hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str +) -> bool: + """Determine if a sensor is continuous. - Sensors with a unit_of_measurement are also considered continuous, but are filtered - already by the SQL query generated by _get_events + Sensors with a unit_of_measurement or state_class are considered continuous. + + The unit_of_measurement check will already happen if this is + called for historical data because the SQL query generated by _get_events + will filter out any sensors with a unit_of_measurement. + + If the state still exists in the state machine, this function still + checks for ATTR_UNIT_OF_MEASUREMENT since the live mode is not filtered + by the SQL query. """ - if not (entry := ent_reg.async_get(entity_id)): - # Entity not registered, so can't have a state class - return False - return ( - entry.capabilities is not None - and entry.capabilities.get(ATTR_STATE_CLASS) is not None + # If it is in the state machine we can quick check if it + # has a unit_of_measurement or state_class, and filter if + # it does + if (state := hass.states.get(entity_id)) and (attributes := state.attributes): + return ATTR_UNIT_OF_MEASUREMENT in attributes or ATTR_STATE_CLASS in attributes + # If its not in the state machine, we need to check + # the entity registry to see if its a sensor + # filter with a state class. We do not check + # for unit_of_measurement since the SQL query + # will filter out any sensors with a unit_of_measurement + # and we should never get here in live mode because + # the state machine will always have the state. + return bool( + (entry := ent_reg.async_get(entity_id)) + and entry.capabilities + and entry.capabilities.get(ATTR_STATE_CLASS) ) @@ -239,24 +259,8 @@ def _is_state_filtered(new_state: State, old_state: State) -> bool: """ return bool( new_state.state == old_state.state - or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS or new_state.last_changed != new_state.last_updated + or new_state.domain in ALWAYS_CONTINUOUS_DOMAINS or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes or ATTR_STATE_CLASS in new_state.attributes ) - - -def _is_entity_id_filtered( - hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str -) -> bool: - """Check if the logbook should filter an entity. - - Used to setup listeners and which entities to select - from the database when a list of entities is requested. - """ - return bool( - split_entity_id(entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS - or (state := hass.states.get(entity_id)) - and (ATTR_UNIT_OF_MEASUREMENT in state.attributes) - or is_sensor_continuous(ent_reg, entity_id) - ) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index a36c887b599..02a6dae3ce6 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -175,6 +175,7 @@ class EventProcessor: """Humanify rows.""" return list( _humanify( + self.hass, rows, self.ent_reg, self.logbook_run, @@ -184,6 +185,7 @@ class EventProcessor: def _humanify( + hass: HomeAssistant, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result, ent_reg: er.EntityRegistry, logbook_run: LogbookRun, @@ -219,7 +221,7 @@ def _humanify( if ( is_continuous := continuous_sensors.get(entity_id) ) is None and split_entity_id(entity_id)[0] == SENSOR_DOMAIN: - is_continuous = is_sensor_continuous(ent_reg, entity_id) + is_continuous = is_sensor_continuous(hass, ent_reg, entity_id) continuous_sensors[entity_id] = is_continuous if is_continuous: continue diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index 9fe6c2b60a8..824bbbde21d 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -77,6 +77,7 @@ def mock_humanify(hass_, rows): context_augmenter = processor.ContextAugmenter(logbook_run) return list( processor._humanify( + hass_, rows, ent_reg, logbook_run, From 16a90f8f1946562ab645204d2087eccd7874a985 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sat, 27 Jan 2024 02:34:29 -0500 Subject: [PATCH 1058/1544] Fix stalls in config flow of APCUPSD (#108931) Fix deadlock in config flow of APCUPSD --- homeassistant/components/apcupsd/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 57002d7a2b2..99c78fd5d33 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -52,9 +52,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): # Test the connection to the host and get the current status for serial number. coordinator = APCUPSdCoordinator(self.hass, host, port) - await coordinator.async_request_refresh() - await self.hass.async_block_till_done() + if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): errors = {"base": "cannot_connect"} return self.async_show_form( From f35e0d8a55401a46ede69aecf505de7927cc9eeb Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Sat, 27 Jan 2024 08:43:08 +0100 Subject: [PATCH 1059/1544] Add more Thread vendor to brand mappings (#108899) Thread: Add additional vendor to brand mappings Add additional vendor to brand mappings for known Thread border router vendors. --- homeassistant/components/thread/discovery.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 0f2997986cb..ad1df757af4 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -16,11 +16,14 @@ from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { + "Amazon": "amazon", "Apple Inc.": "apple", "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", + "Nanoleaf": "nanoleaf", + "OpenThread": "openthread", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 From dec9eb9ae496d98c8fc38a5f680e25451d84790c Mon Sep 17 00:00:00 2001 From: matt7aylor Date: Sat, 27 Jan 2024 07:46:24 +0000 Subject: [PATCH 1060/1544] Matter sensors for air quality measurements (#108173) * Matter sensors for air quality measurements Add sensors for CO2, PM1, PM2.5, PM10 and TVOC * Add initial tests for matter air quality sensor * Remove VOC data as requires unit extraction from cluster --- homeassistant/components/matter/sensor.py | 54 ++++ .../fixtures/nodes/air-quality-sensor.json | 288 ++++++++++++++++++ tests/components/matter/test_sensor.py | 67 ++++ 3 files changed, 409 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/air-quality-sensor.json diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index e7b18f308f7..90a9cb3fcc8 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, @@ -207,4 +209,56 @@ DISCOVERY_SCHEMAS = [ optional_attributes=(clusters.OnOff.Attributes.OnOff,), should_poll=True, ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="CarbonDioxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.CarbonDioxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM1Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm1ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM25Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm25ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PM10Sensor", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.Pm10ConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), ] diff --git a/tests/components/matter/fixtures/nodes/air-quality-sensor.json b/tests/components/matter/fixtures/nodes/air-quality-sensor.json new file mode 100644 index 00000000000..4a533f0a166 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/air-quality-sensor.json @@ -0,0 +1,288 @@ +{ + "node_id": 1, + "date_commissioned": "2024-01-13T20:12:42.853855", + "last_interview": "2024-01-13T20:12:42.853862", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Nordic Semiconductor ASA", + "0/40/2": 65521, + "0/40/3": "lightfi-aq1-air-quality-sensor", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "prerelease", + "0/40/9": 0, + "0/40/10": "prerelease", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/21": null, + "0/40/22": null, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 19, 21, 22, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "q/UPJlJtKwk=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "q/UPJlJtKwk=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "SrFuqi7GOA0=", + "5": [], + "6": [ + "/oAAAAAAAABIsW6qLsY4DQ==", + "/a2BRTrZUShjZ6Plq5dszA==", + "/R/knXM6xXGK6bPqGglOHw==" + ], + "7": 4 + } + ], + "0/51/1": 12, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [], + "0/51/65531": [0, 1, 8, 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, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEVW22H4oSAH8ygJxeAehOa+Fy5OvrpZP1+OsUuhMOHK8xRrUY021wlmdjeKyX3Fnax3+QU5eXXNyJl8B4KDS8wTcKNQEoARgkAgE2AwQCBAEYMAQUwvHAaN24tRt6l5HJQbyntNkVQZIwBRRje8c2OVfVDK5m9OcVHaS51jcEChgwC0A5oKtEonnnHfT+Ut+H359m/kiVNMmVkroDCeBWKItO6T28kladkvO0iHB8J1L7QFLEsDxv9YuCBOPa0T7fUHb6GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEa/1FpraZtnACK49eqDofsCGh7KwwBIKj28CVug2c1v7Nk+jy6Alq83vfKRc1MR6+9Lp31clVfzhOpCsX0vYiXjcKNQEpARgkAmAwBBRje8c2OVfVDK5m9OcVHaS51jcECjAFFBM9Maievp4UKHYUW3dZjm1BsGxwGDALQPa5FY9kZ/Ii83D0I4eCXwdSUEQWPIYeCyodb/eO1p0gtUzTbRxYmYFBTaE2bMVQHoZ7KFnC1uBvv6T/ELrxTD0Y", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BBfkzpqcInPpJmIWMy2yckRYs4V/CHeqvr7ObEtvRywP9sQSuJ2vIIJer+Af5gA/5sld0ZRaOCdBksUK3b5g4/w=", + "2": 24582, + "3": 4448312386606703954, + "4": 11636151610245023439, + "5": "", + "254": 2 + }, + { + "1": "BJSwLCRLiMCNDkJINo2xgNg4Q4DQOnPH/UjP6AVITT6YFza4r9itL7nPg3TJo7quKWfdZ1aksO7doJZvFo5WyUU=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 2, + "0/62/4": [ + "FTABAQEkAgE3AycU2fdVLSD8xXAYJgSAVCstJgWAWsNSNwYnFNn3VS0g/MVwGCQHASQIATAJQQQX5M6anCJz6SZiFjMtsnJEWLOFfwh3qr6+zmxLb0csD/bEEridryCCXq/gH+YAP+bJXdGUWjgnQZLFCt2+YOP8Nwo1ASkBGCQCYDAEFEMcdLs9Y7GImXEqgx7gT7WdJXEmMAUUQxx0uz1jsYiZcSqDHuBPtZ0lcSYYMAtA8OCKQmQJSw32MwkiKh2yCqXwqPo5ZFqC5KIju6EhVyic45AZqc8XooMha/G87qtjpG4X6zh4aEdwOJGgMVoewxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEElLAsJEuIwI0OQkg2jbGA2DhDgNA6c8f9SM/oBUhNPpgXNriv2K0vuc+DdMmjuq4pZ91nVqSw7t2glm8WjlbJRTcKNQEpARgkAmAwBBQTPTGonr6eFCh2FFt3WY5tQbBscDAFFBM9Maievp4UKHYUW3dZjm1BsGxwGDALQEoomYkckgebSt7QekvI/9ZPf9y5pCFq7Vi+3bWwduTHa560n9hFZ01anVu4UxOsx1cn8erdu/dVdkHIBtfCKrcY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 44, + "1": 1 + } + ], + "1/29/1": [3, 29, 91, 1026, 1029, 1037, 1043, 1066, 1068, 1069, 1070], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/91/0": null, + "1/91/65532": null, + "1/91/65533": 1, + "1/91/65528": [], + "1/91/65529": [], + "1/91/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/1026/0": 2008, + "1/1026/1": null, + "1/1026/2": null, + "1/1026/65532": 0, + "1/1026/65533": 1, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1029/0": 2875, + "1/1029/1": 0, + "1/1029/2": 0, + "1/1029/65532": 0, + "1/1029/65533": 3, + "1/1029/65528": [], + "1/1029/65529": [], + "1/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1037/0": 678.0, + "1/1037/1": 0.0, + "1/1037/2": 5000.0, + "1/1037/65532": 1, + "1/1037/65533": 3, + "1/1037/65528": [], + "1/1037/65529": [], + "1/1037/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1043/0": 0.0, + "1/1043/1": 0.0, + "1/1043/2": 500.0, + "1/1043/65532": 1, + "1/1043/65533": 3, + "1/1043/65528": [], + "1/1043/65529": [], + "1/1043/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1066/0": 3.0, + "1/1066/1": 0.0, + "1/1066/2": 1000.0, + "1/1066/65532": 1, + "1/1066/65533": 3, + "1/1066/65528": [], + "1/1066/65529": [], + "1/1066/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1068/0": 3.0, + "1/1068/1": 0.0, + "1/1068/2": 1000.0, + "1/1068/65532": 1, + "1/1068/65533": 3, + "1/1068/65528": [], + "1/1068/65529": [], + "1/1068/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1069/0": 3.0, + "1/1069/1": 0.0, + "1/1069/2": 1000.0, + "1/1069/65532": 1, + "1/1069/65533": 3, + "1/1069/65528": [], + "1/1069/65529": [], + "1/1069/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/1070/0": 189.0, + "1/1070/1": 0.0, + "1/1070/2": 500.0, + "1/1070/65532": 1, + "1/1070/65533": 3, + "1/1070/65528": [], + "1/1070/65529": [], + "1/1070/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [ + [1, 1037, 0], + [1, 1070, 0], + [1, 1066, 0], + [1, 1068, 0], + [1, 1069, 0], + [1, 1026, 0], + [1, 1029, 0] + ] +} diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 5b343b8c4e5..579dd7d94c5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -76,6 +76,16 @@ async def eve_energy_plug_node_fixture( ) +@pytest.fixture(name="air_quality_sensor_node") +async def air_quality_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an air quality sensor (LightFi AQ1) node.""" + return await setup_integration_with_node_fixture( + hass, "air-quality-sensor", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -288,3 +298,60 @@ async def test_eve_energy_sensors( state = hass.states.get(entity_id) assert state assert state.state == "5.0" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_air_quality_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + air_quality_sensor_node: MatterNode, +) -> None: + """Test air quality sensor.""" + # Carbon Dioxide + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_carbon_dioxide") + assert state + assert state.state == "678.0" + + set_node_attribute(air_quality_sensor_node, 1, 1037, 0, 789) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_carbon_dioxide") + assert state + assert state.state == "789.0" + + # PM1 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm1") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1068, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm1") + assert state + assert state.state == "50.0" + + # PM2.5 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm2_5") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1066, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm2_5") + assert state + assert state.state == "50.0" + + # PM10 + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") + assert state + assert state.state == "3.0" + + set_node_attribute(air_quality_sensor_node, 1, 1069, 0, 50) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") + assert state + assert state.state == "50.0" From 677b06f502793d12ff4d0a9f25b786ab902e8440 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 27 Jan 2024 13:05:31 +0100 Subject: [PATCH 1061/1544] Add comment to explain not using the core API in MQTT client (#108942) --- homeassistant/components/mqtt/client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 14a18354b01..164632cdd10 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -216,6 +216,10 @@ def subscribe( def remove() -> None: """Remove listener convert.""" + # MQTT messages tend to be high volume, + # and since they come in via a thread and need to be processed in the event loop, + # we want to avoid hass.add_job since most of the time is spent calling + # inspect to figure out how to run the callback. hass.loop.call_soon_threadsafe(async_remove) return remove @@ -796,6 +800,10 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: """Message received callback.""" + # MQTT messages tend to be high volume, + # and since they come in via a thread and need to be processed in the event loop, + # we want to avoid hass.add_job since most of the time is spent calling + # inspect to figure out how to run the callback. self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg) @lru_cache(None) # pylint: disable=method-cache-max-size-none From 950660b953342e1d222b58b5304f81392bc99518 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 27 Jan 2024 07:17:05 -0500 Subject: [PATCH 1062/1544] Reorganize ZHA device availability code (#108856) * Correct ZHA device availability at startup * don't set available property from gateway * cleanup --- homeassistant/components/zha/core/device.py | 29 +++++++++++--------- homeassistant/components/zha/core/gateway.py | 9 +++--- tests/components/zha/conftest.py | 9 ++++-- tests/components/zha/test_gateway.py | 2 +- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 468e89fbbf0..a678dbea89a 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -11,7 +11,7 @@ import time from typing import TYPE_CHECKING, Any, Self from zigpy import types -import zigpy.device +from zigpy.device import Device as ZigpyDevice import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks @@ -124,22 +124,23 @@ class ZHADevice(LogMixin): zha_gateway: ZHAGateway, ) -> None: """Initialize the gateway.""" - self.hass = hass - self._zigpy_device = zigpy_device - self._zha_gateway = zha_gateway - self._available = False - self._available_signal = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" - self._checkins_missed_count = 0 + self.hass: HomeAssistant = hass + self._zigpy_device: ZigpyDevice = zigpy_device + self._zha_gateway: ZHAGateway = zha_gateway + self._available_signal: str = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" + self._checkins_missed_count: int = 0 self.unsubs: list[Callable[[], None]] = [] - self.quirk_applied = isinstance(self._zigpy_device, zigpy.quirks.CustomDevice) - self.quirk_class = ( + self.quirk_applied: bool = isinstance( + self._zigpy_device, zigpy.quirks.CustomDevice + ) + self.quirk_class: str = ( f"{self._zigpy_device.__class__.__module__}." f"{self._zigpy_device.__class__.__name__}" ) - self.quirk_id = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) + self.quirk_id: str | None = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) if self.is_mains_powered: - self.consider_unavailable_time = async_get_zha_config_value( + self.consider_unavailable_time: int = async_get_zha_config_value( self._zha_gateway.config_entry, ZHA_OPTIONS, CONF_CONSIDER_UNAVAILABLE_MAINS, @@ -152,7 +153,10 @@ class ZHADevice(LogMixin): CONF_CONSIDER_UNAVAILABLE_BATTERY, CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ) - + self._available: bool = self.is_coordinator or ( + self.last_seen is not None + and time.time() - self.last_seen < self.consider_unavailable_time + ) self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) self._power_config_ch: ClusterHandler | None = None self._identify_ch: ClusterHandler | None = None @@ -408,7 +412,6 @@ class ZHADevice(LogMixin): hass: HomeAssistant, zigpy_dev: zigpy.device.Device, gateway: ZHAGateway, - restored: bool = False, ) -> Self: """Create new device.""" zha_dev = cls(hass, zigpy_dev, gateway) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index cca8aa93e99..14fd329f1bc 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -223,7 +223,7 @@ class ZHAGateway: zha_data.gateway = self self.coordinator_zha_device = self._async_get_or_create_device( - self._find_coordinator_device(), restored=True + self._find_coordinator_device() ) self.async_load_devices() @@ -264,11 +264,10 @@ class ZHAGateway: """Restore ZHA devices from zigpy application state.""" for zigpy_device in self.application_controller.devices.values(): - zha_device = self._async_get_or_create_device(zigpy_device, restored=True) + zha_device = self._async_get_or_create_device(zigpy_device) delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) - zha_device.available = delta < zha_device.consider_unavailable_time delta_msg = f"{str(timedelta(seconds=delta))} ago" _LOGGER.debug( ( @@ -622,11 +621,11 @@ class ZHAGateway: @callback def _async_get_or_create_device( - self, zigpy_device: zigpy.device.Device, restored: bool = False + self, zigpy_device: zigpy.device.Device ) -> ZHADevice: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self.hass, zigpy_device, self, restored) + zha_device = ZHADevice.new(self.hass, zigpy_device, self) self._devices[zigpy_device.ieee] = zha_device device_registry = dr.async_get(self.hass) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a30c6f35052..4303c156e4b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,6 +25,7 @@ import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.helpers import restore_state from homeassistant.setup import async_setup_component @@ -381,7 +382,7 @@ def zha_device_joined_restored(request): @pytest.fixture def zha_device_mock( - hass, zigpy_device_mock + hass, config_entry, zigpy_device_mock ) -> Callable[..., zha_core_device.ZHADevice]: """Return a ZHA Device factory.""" @@ -409,7 +410,11 @@ def zha_device_mock( zigpy_device = zigpy_device_mock( endpoints, ieee, manufacturer, model, node_desc, patch_cluster=patch_cluster ) - zha_device = zha_core_device.ZHADevice(hass, zigpy_device, MagicMock()) + zha_device = zha_core_device.ZHADevice( + hass, + zigpy_device, + ZHAGateway(hass, {}, config_entry), + ) return zha_device return _zha_device diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index f19ed9bd4a9..e117caf4325 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -365,7 +365,7 @@ async def test_startup_concurrency_limit( zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered ) - zha_gateway._async_get_or_create_device(zigpy_dev, restored=True) + zha_gateway._async_get_or_create_device(zigpy_dev) # Keep track of request concurrency during initialization current_concurrency = 0 From 858fb1fa376af47e570b0a9f718502de2b0d2636 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 27 Jan 2024 22:43:55 +1000 Subject: [PATCH 1063/1544] Add snapshot testing to Tessie (#108346) * Redo Binary Sensors * Redo Button * Redo Climate * Stage unfixed platforms * Redo Cover * Redo device tracker * Redo lock * Redo Media Player * Redo Number * Redo Select * Redo Sensor * Redo Switch * Redo Update * Fix setup_platform * Add mixing snapshot * Fix config flow * Centralise entity testing * Update snapshot * Rename test_entities * Fix assert_entities --- tests/components/tessie/common.py | 26 +- .../tessie/snapshots/test_binary_sensors.ambr | 922 +++++++++++ .../tessie/snapshots/test_button.ambr | 265 +++ .../tessie/snapshots/test_climate.ambr | 994 +++++++++++ .../tessie/snapshots/test_cover.ambr | 132 +- .../tessie/snapshots/test_device_tracker.ambr | 922 +++++++++++ .../tessie/snapshots/test_lock.ambr | 89 + .../tessie/snapshots/test_media_player.ambr | 57 +- .../tessie/snapshots/test_number.ambr | 163 ++ .../tessie/snapshots/test_select.ambr | 299 ++++ .../tessie/snapshots/test_sensor.ambr | 1454 +++++++++++++++++ .../tessie/snapshots/test_switch.ambr | 254 +++ .../tessie/snapshots/test_update.ambr | 54 + .../components/tessie/test_binary_sensors.py | 33 +- tests/components/tessie/test_button.py | 49 +- tests/components/tessie/test_climate.py | 51 +- tests/components/tessie/test_config_flow.py | 6 +- tests/components/tessie/test_coordinator.py | 13 +- tests/components/tessie/test_cover.py | 107 +- .../components/tessie/test_device_tracker.py | 35 +- tests/components/tessie/test_lock.py | 24 +- tests/components/tessie/test_media_player.py | 44 +- tests/components/tessie/test_number.py | 47 +- tests/components/tessie/test_select.py | 25 +- tests/components/tessie/test_sensor.py | 26 +- tests/components/tessie/test_switch.py | 34 +- tests/components/tessie/test_update.py | 20 +- 27 files changed, 5831 insertions(+), 314 deletions(-) create mode 100644 tests/components/tessie/snapshots/test_binary_sensors.ambr create mode 100644 tests/components/tessie/snapshots/test_button.ambr create mode 100644 tests/components/tessie/snapshots/test_climate.ambr create mode 100644 tests/components/tessie/snapshots/test_device_tracker.ambr create mode 100644 tests/components/tessie/snapshots/test_lock.ambr create mode 100644 tests/components/tessie/snapshots/test_number.ambr create mode 100644 tests/components/tessie/snapshots/test_select.ambr create mode 100644 tests/components/tessie/snapshots/test_sensor.ambr create mode 100644 tests/components/tessie/snapshots/test_switch.ambr create mode 100644 tests/components/tessie/snapshots/test_update.ambr diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index ccff7f62b1b..c57dbda8b53 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -5,10 +5,12 @@ from unittest.mock import patch from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo +from syrupy import SnapshotAssertion from homeassistant.components.tessie.const import DOMAIN, TessieStatus -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_json_object_fixture @@ -44,7 +46,9 @@ ERROR_VIRTUAL_KEY = ClientResponseError( ERROR_CONNECTION = ClientConnectionError() -async def setup_platform(hass: HomeAssistant, side_effect=None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] = [], side_effect=None +) -> MockConfigEntry: """Set up the Tessie platform.""" mock_entry = MockConfigEntry( @@ -57,8 +61,24 @@ async def setup_platform(hass: HomeAssistant, side_effect=None): "homeassistant.components.tessie.get_state_of_all_vehicles", return_value=TEST_STATE_OF_ALL_VEHICLES, side_effect=side_effect, - ): + ), patch("homeassistant.components.tessie.PLATFORMS", platforms): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() return mock_entry + + +def assert_entities( + hass: HomeAssistant, + entry_id: str, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/tessie/snapshots/test_binary_sensors.ambr b/tests/components/tessie/snapshots/test_binary_sensors.ambr new file mode 100644 index 00000000000..73ea5f3989a --- /dev/null +++ b/tests/components/tessie/snapshots/test_binary_sensors.ambr @@ -0,0 +1,922 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_battery_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_battery_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Cabin overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection actively cooling', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charging_state', + 'unique_id': 'VINVINVIN-charge_state_charging_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_dashcam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_dashcam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dashcam', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_dashcam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'VINVINVIN-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'VINVINVIN-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_heat', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_preconditioning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning enabled', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'VINVINVIN-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'VINVINVIN-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_scheduled_charging_pending-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scheduled charging pending', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'VINVINVIN-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_tire_pressure_warning_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_trip_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'VINVINVIN-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_button.ambr b/tests/components/tessie/snapshots/test_button.ambr new file mode 100644 index 00000000000..5c3938eaddb --- /dev/null +++ b/tests/components/tessie/snapshots/test_button.ambr @@ -0,0 +1,265 @@ +# serializer version: 1 +# name: test_buttons[button.test_flash_lights-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_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flashlight', + 'original_name': 'Flash lights', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'VINVINVIN-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + 'icon': 'mdi:flashlight', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_homelink-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_homelink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage', + 'original_name': 'Homelink', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trigger_homelink', + 'unique_id': 'VINVINVIN-trigger_homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + 'icon': 'mdi:garage', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_honk_horn-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_honk_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:bullhorn', + 'original_name': 'Honk horn', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'VINVINVIN-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_keyless_driving-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_keyless_driving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car-key', + 'original_name': 'Keyless driving', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'VINVINVIN-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + 'icon': 'mdi:car-key', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_play_fart-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_play_fart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Play fart', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'VINVINVIN-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + 'icon': 'mdi:volume-high', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.test_wake-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_wake', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Wake', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'VINVINVIN-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + 'icon': 'mdi:sleep-off', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr new file mode 100644 index 00000000000..47f310849ca --- /dev/null +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -0,0 +1,994 @@ +# serializer version: 1 +# name: test_climate[binary_sensor.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_battery_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_battery_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Cabin overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection actively cooling', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charging_state', + 'unique_id': 'VINVINVIN-charge_state_charging_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_dashcam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_dashcam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dashcam', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_dashcam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_front_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'VINVINVIN-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_front_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'VINVINVIN-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_heat', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_preconditioning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning enabled', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_rear_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'VINVINVIN-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_rear_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'VINVINVIN-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_scheduled_charging_pending-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scheduled charging pending', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'VINVINVIN-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_trip_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'VINVINVIN-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[binary_sensor.test_user_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'primary', + 'unique_id': 'VINVINVIN-primary', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.5, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + 'temperature': 22.5, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr index ae5e95be68d..e95da1df3b9 100644 --- a/tests/components/tessie/snapshots/test_cover.ambr +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -1,5 +1,36 @@ # serializer version: 1 -# name: test_covers[cover.test_charge_port_door-open_unlock_charge_port-close_charge_port][cover.test_charge_port_door] +# name: test_covers[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'VINVINVIN-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_charge_port_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -13,7 +44,38 @@ 'state': 'open', }) # --- -# name: test_covers[cover.test_frunk-open_front_trunk-False][cover.test_frunk] +# name: test_covers[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'VINVINVIN-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_frunk-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -27,7 +89,38 @@ 'state': 'closed', }) # --- -# name: test_covers[cover.test_trunk-open_close_rear_trunk-open_close_rear_trunk][cover.test_trunk] +# name: test_covers[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'VINVINVIN-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_trunk-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -41,7 +134,38 @@ 'state': 'closed', }) # --- -# name: test_covers[cover.test_vent_windows-vent_windows-close_windows][cover.test_vent_windows] +# name: test_covers[cover.test_vent_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_vent_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vent windows', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'VINVINVIN-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_vent_windows-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'window', diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..bb96de2f4c6 --- /dev/null +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -0,0 +1,922 @@ +# serializer version: 1 +# name: test_device_tracker[binary_sensor.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_left', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_auto_seat_climate_right', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_battery_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_battery_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Cabin overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection actively cooling', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charging_state', + 'unique_id': 'VINVINVIN-charge_state_charging_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_dashcam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_dashcam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dashcam', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_dashcam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_front_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'VINVINVIN-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_front_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'VINVINVIN-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_heat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_heat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heat', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_heat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Heat', + }), + 'context': , + 'entity_id': 'binary_sensor.test_heat', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_preconditioning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning enabled', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_rear_driver_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'VINVINVIN-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_rear_passenger_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'VINVINVIN-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_scheduled_charging_pending-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scheduled charging pending', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'VINVINVIN-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_trip_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip charging', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'VINVINVIN-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_device_tracker[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[binary_sensor.test_user_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_lock.ambr b/tests/components/tessie/snapshots/test_lock.ambr new file mode 100644 index 00000000000..cef92a1226f --- /dev/null +++ b/tests/components/tessie/snapshots/test_lock.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_locks[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'VINVINVIN-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_locks[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'VINVINVIN-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index e4c7f37c4ce..34856626b66 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -1,5 +1,37 @@ # serializer version: 1 -# name: test_media_player_idle +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-paused] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', @@ -14,7 +46,7 @@ 'state': 'idle', }) # --- -# name: test_media_player_idle.1 +# name: test_media_player[media_player.test_media_player-playing] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', @@ -29,31 +61,16 @@ 'state': 'idle', }) # --- -# name: test_media_player_playing +# name: test_media_player[media_player.test_media_player-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'speaker', - 'friendly_name': 'Test', + 'friendly_name': 'Test Media player', 'supported_features': , 'volume_level': 0.22580323309042688, }), 'context': , - 'entity_id': 'media_player.test', - 'last_changed': , - 'last_updated': , - 'state': 'idle', - }) -# --- -# name: test_sensors - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'speaker', - 'friendly_name': 'Test', - 'supported_features': , - 'volume_level': 0.22580323309042688, - }), - 'context': , - 'entity_id': 'media_player.test', + 'entity_id': 'media_player.test_media_player', 'last_changed': , 'last_updated': , 'state': 'idle', diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr new file mode 100644 index 00000000000..23ecbbfabbe --- /dev/null +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -0,0 +1,163 @@ +# serializer version: 1 +# name: test_numbers[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'VINVINVIN-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_numbers[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'VINVINVIN-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_numbers[number.test_speed_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 120, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_speed_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed limit', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_speed_limit_mode_current_limit_mph', + 'unique_id': 'VINVINVIN-vehicle_state_speed_limit_mode_current_limit_mph', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.test_speed_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Speed limit', + 'max': 120, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_speed_limit', + 'last_changed': , + 'last_updated': , + 'state': '74.564543', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr new file mode 100644 index 00000000000..7a6978e3aef --- /dev/null +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -0,0 +1,299 @@ +# serializer version: 1 +# name: test_select[select.test_seat_heater_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_left', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear center', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_center', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_center', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear center', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_center', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_left', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_left', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_right', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear right', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_right', + 'unique_id': 'VINVINVIN-climate_state_seat_heater_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater right', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_right', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select_option] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater left', + 'options': list([ + , + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_left', + 'last_changed': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..be6e62b3635 --- /dev/null +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -0,0 +1,1454 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_energy_at_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_battery', + 'last_changed': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensors[sensor.test_battery_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.test_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_usable_battery_level', + 'unique_id': 'VINVINVIN-charge_state_usable_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_sensors[sensor.test_battery_range-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.test_battery_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery range', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_range', + 'unique_id': 'VINVINVIN-charge_state_battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Battery range', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_range', + 'last_changed': , + 'last_updated': , + 'state': '424.35182592', + }) +# --- +# name: test_sensors[sensor.test_charge_energy_added-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.test_charge_energy_added', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge energy added', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_energy_added', + 'unique_id': 'VINVINVIN-charge_state_charge_energy_added', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charge_energy_added-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Charge energy added', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charge_energy_added', + 'last_changed': , + 'last_updated': , + 'state': '18.47', + }) +# --- +# name: test_sensors[sensor.test_charge_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charge_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge rate', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_rate', + 'unique_id': 'VINVINVIN-charge_state_charge_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charge_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Charge rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charge_rate', + 'last_changed': , + 'last_updated': , + 'state': '49.2', + }) +# --- +# name: test_sensors[sensor.test_charger_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charger_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger current', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_actual_current', + 'unique_id': 'VINVINVIN-charge_state_charger_actual_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charger current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_current', + 'last_changed': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[sensor.test_charger_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_charger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_power', + 'unique_id': 'VINVINVIN-charge_state_charger_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Charger power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_power', + 'last_changed': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensors[sensor.test_charger_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_charger_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger voltage', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_voltage', + 'unique_id': 'VINVINVIN-charge_state_charger_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_charger_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test Charger voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_charger_voltage', + 'last_changed': , + 'last_updated': , + 'state': '224', + }) +# --- +# name: test_sensors[sensor.test_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:map-marker', + 'original_name': 'Destination', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_destination', + 'unique_id': 'VINVINVIN-drive_state_active_route_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Destination', + 'icon': 'mdi:map-marker', + }), + 'context': , + 'entity_id': 'sensor.test_destination', + 'last_changed': , + 'last_updated': , + 'state': 'Giga Texas', + }) +# --- +# name: test_sensors[sensor.test_distance-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.test_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_miles_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_distance', + 'last_changed': , + 'last_updated': , + 'state': '75.168198', + }) +# --- +# name: test_sensors[sensor.test_distance_to_arrival-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.test_distance_to_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_miles_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_distance_to_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Distance to arrival', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_distance_to_arrival', + 'last_changed': , + 'last_updated': , + 'state': '75.168198', + }) +# --- +# name: test_sensors[sensor.test_driver_temperature_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_driver_temperature_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver temperature setting', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_driver_temp_setting', + 'unique_id': 'VINVINVIN-climate_state_driver_temp_setting', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_driver_temperature_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Driver temperature setting', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_driver_temperature_setting', + 'last_changed': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensors[sensor.test_duration-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.test_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Duration', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_minutes_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_duration', + 'last_changed': , + 'last_updated': , + 'state': '59.2', + }) +# --- +# name: test_sensors[sensor.test_duration_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_duration_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Duration', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_minutes_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_duration_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_duration_2', + 'last_changed': , + 'last_updated': , + 'state': '59.2', + }) +# --- +# name: test_sensors[sensor.test_inside_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.test_inside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inside temperature', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_inside_temp', + 'unique_id': 'VINVINVIN-climate_state_inside_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_inside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Inside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_inside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '30.4', + }) +# --- +# name: test_sensors[sensor.test_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_odometer', + 'unique_id': 'VINVINVIN-vehicle_state_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Test Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_odometer', + 'last_changed': , + 'last_updated': , + 'state': '8778.15941765875', + }) +# --- +# name: test_sensors[sensor.test_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_outside_temp', + 'unique_id': 'VINVINVIN-climate_state_outside_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '30.5', + }) +# --- +# name: test_sensors[sensor.test_passenger_temperature_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_passenger_temperature_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger temperature setting', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_passenger_temp_setting', + 'unique_id': 'VINVINVIN-climate_state_passenger_temp_setting', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_passenger_temperature_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Passenger temperature setting', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_passenger_temperature_setting', + 'last_changed': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensors[sensor.test_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_power', + 'unique_id': 'VINVINVIN-drive_state_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_power', + 'last_changed': , + 'last_updated': , + 'state': '-7', + }) +# --- +# name: test_sensors[sensor.test_shift_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'p', + 'd', + 'r', + 'n', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_shift_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:car-shift-pattern', + 'original_name': 'Shift state', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_shift_state', + 'unique_id': 'VINVINVIN-drive_state_shift_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_shift_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Shift state', + 'icon': 'mdi:car-shift-pattern', + 'options': list([ + 'p', + 'd', + 'r', + 'n', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_shift_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_speed-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.test_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_speed', + 'unique_id': 'VINVINVIN-drive_state_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Test Speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_speed', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_state_of_charge_at_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_state_of_charge_at_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge at arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_energy_at_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_state_of_charge_at_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test State of charge at arrival', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_state_of_charge_at_arrival', + 'last_changed': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensors[sensor.test_time_to_full_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_time_to_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to full charge', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_minutes_to_full_charge', + 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_time_to_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Time to full charge', + }), + 'context': , + 'entity_id': 'sensor.test_time_to_full_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_timestamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_timestamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timestamp', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_minutes_to_full_charge', + 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_timestamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Timestamp', + }), + 'context': , + 'entity_id': 'sensor.test_timestamp', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_tire_pressure_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure front left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_fl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fl', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure front left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_front_left', + 'last_changed': , + 'last_updated': , + 'state': '43.1487288094417', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_tire_pressure_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure front right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_fr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fr', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure front right', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_front_right', + 'last_changed': , + 'last_updated': , + 'state': '43.1487288094417', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_tire_pressure_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure rear left', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_rl', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rl', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure rear left', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_rear_left', + 'last_changed': , + 'last_updated': , + 'state': '42.7861344496985', + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_tire_pressure_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure rear right', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_pressure_rr', + 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rr', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_tire_pressure_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Tire pressure rear right', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_tire_pressure_rear_right', + 'last_changed': , + 'last_updated': , + 'state': '42.7861344496985', + }) +# --- +# name: test_sensors[sensor.test_traffic_delay-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.test_traffic_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Traffic delay', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_traffic_minutes_delay', + 'unique_id': 'VINVINVIN-drive_state_active_route_traffic_minutes_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_traffic_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Traffic delay', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_traffic_delay', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr new file mode 100644 index 00000000000..686542feacd --- /dev/null +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -0,0 +1,254 @@ +# serializer version: 1 +# name: test_switches[switch.test_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Charge', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_enable_request', + 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.test_defrost_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_defrost_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:snowflake', + 'original_name': 'Defrost mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_defrost_mode', + 'unique_id': 'VINVINVIN-climate_state_defrost_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_defrost_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost mode', + 'icon': 'mdi:snowflake', + }), + 'context': , + 'entity_id': 'switch.test_defrost_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_sentry_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_sentry_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:shield-car', + 'original_name': 'Sentry mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sentry_mode', + 'unique_id': 'VINVINVIN-vehicle_state_sentry_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_sentry_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + 'icon': 'mdi:shield-car', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:steering', + 'original_name': 'Steering wheel heater', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heater', + 'unique_id': 'VINVINVIN-climate_state_steering_wheel_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Steering wheel heater', + 'icon': 'mdi:steering', + }), + 'context': , + 'entity_id': 'switch.test_steering_wheel_heater', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_valet_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_valet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:car-key', + 'original_name': 'Valet mode', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_valet_mode', + 'unique_id': 'VINVINVIN-vehicle_state_valet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_valet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Valet mode', + 'icon': 'mdi:car-key', + }), + 'context': , + 'entity_id': 'switch.test_valet_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[turn_off] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[turn_on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr new file mode 100644 index 00000000000..b47cc78ef6e --- /dev/null +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_updates[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'update', + 'unique_id': 'VINVINVIN-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_updates[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/tessie/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.38.6', + 'latest_version': '2023.44.30.4', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py index 7f1eb1805a2..ca53a60d493 100644 --- a/tests/components/tessie/test_binary_sensors.py +++ b/tests/components/tessie/test_binary_sensors.py @@ -1,33 +1,18 @@ """Test the Tessie binary sensor platform.""" +from syrupy import SnapshotAssertion -from homeassistant.components.tessie.binary_sensor import DESCRIPTIONS -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform - -OFFON = [STATE_OFF, STATE_ON] +from .common import assert_entities, setup_platform -async def test_binary_sensors(hass: HomeAssistant) -> None: +async def test_binary_sensors( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the binary sensor entities are correct.""" - assert len(hass.states.async_all("binary_sensor")) == 0 + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - await setup_platform(hass) - - assert len(hass.states.async_all("binary_sensor")) == len(DESCRIPTIONS) - - state = hass.states.get("binary_sensor.test_battery_heater").state - is_on = state == STATE_ON - assert is_on == TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_heater_on"] - - state = hass.states.get("binary_sensor.test_charging").state - is_on = state == STATE_ON - assert is_on == ( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["charging_state"] == "Charging" - ) - - state = hass.states.get("binary_sensor.test_auto_seat_climate_left").state - is_on = state == STATE_ON - assert is_on == TEST_VEHICLE_STATE_ONLINE["climate_state"]["auto_seat_climate_left"] + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index 153171c8b9f..674e7a32747 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -1,39 +1,40 @@ """Test the Tessie button platform.""" from unittest.mock import patch -import pytest +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import assert_entities, setup_platform -@pytest.mark.parametrize( - ("entity_id", "func"), - [ +async def test_buttons( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Tests that the button entities are correct.""" + + entry = await setup_platform(hass, [Platform.BUTTON]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + for entity_id, func in [ ("button.test_wake", "wake"), ("button.test_flash_lights", "flash_lights"), ("button.test_honk_horn", "honk"), ("button.test_homelink", "trigger_homelink"), ("button.test_keyless_driving", "enable_keyless_driving"), ("button.test_play_fart", "boombox"), - ], -) -async def test_buttons(hass: HomeAssistant, entity_id, func) -> None: - """Tests that the button entities are correct.""" - - await setup_platform(hass) - - # Test wake button - with patch( - f"homeassistant.components.tessie.button.{func}", - ) as mock_wake: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_wake.assert_called_once() + ]: + with patch( + f"homeassistant.components.tessie.button.{func}", + ) as mock_press: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_press.assert_called_once() diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index 341e4714470..cbb6b7ad09e 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -2,53 +2,38 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, - SERVICE_TURN_ON, + SERVICE_TURN_OFF, HVACMode, ) from homeassistant.components.tessie.const import TessieClimateKeeper -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ( - ERROR_UNKNOWN, - TEST_RESPONSE, - TEST_VEHICLE_STATE_ONLINE, - setup_platform, -) +from .common import ERROR_UNKNOWN, TEST_RESPONSE, assert_entities, setup_platform -async def test_climate(hass: HomeAssistant) -> None: +async def test_climate( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the climate entity is correct.""" - assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.CLIMATE]) - await setup_platform(hass) - - assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "climate.test_climate" - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert ( - state.attributes.get(ATTR_MIN_TEMP) - == TEST_VEHICLE_STATE_ONLINE["climate_state"]["min_avail_temp"] - ) - assert ( - state.attributes.get(ATTR_MAX_TEMP) - == TEST_VEHICLE_STATE_ONLINE["climate_state"]["max_avail_temp"] - ) # Test setting climate on with patch( @@ -62,6 +47,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL # Test setting climate temp with patch( @@ -75,6 +62,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 # Test setting climate preset with patch( @@ -88,6 +77,8 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == TessieClimateKeeper.ON # Test setting climate off with patch( @@ -101,22 +92,24 @@ async def test_climate(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF async def test_errors(hass: HomeAssistant) -> None: - """Tests virtual key error is handled.""" + """Tests errors are handled.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" # Test setting climate on with unknown error with patch( - "homeassistant.components.tessie.climate.start_climate_preconditioning", + "homeassistant.components.tessie.climate.stop_climate", side_effect=ERROR_UNKNOWN, ) as mock_set, pytest.raises(HomeAssistantError) as error: await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_TURN_ON, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index 7bc3efa24fc..e5bcf11efd1 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -132,9 +132,9 @@ async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> No async def test_reauth_errors( hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error ) -> None: - """Test reauth flows that failscript/.""" + """Test reauth flows that fail.""" - mock_entry = await setup_platform(hass) + mock_entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) mock_get_state_of_all_vehicles.side_effect = side_effect result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 65f91c6f33e..14bb6b7d203 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -1,8 +1,9 @@ """Test the Tessie sensor platform.""" from datetime import timedelta +from homeassistant.components.tessie import PLATFORMS from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -24,7 +25,7 @@ async def test_coordinator_online( ) -> None: """Tests that the coordinator handles online vehicles.""" - await setup_platform(hass) + await setup_platform(hass, PLATFORMS) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -36,7 +37,7 @@ async def test_coordinator_online( async def test_coordinator_asleep(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles asleep vehicles.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) mock_get_status.return_value = TEST_VEHICLE_STATUS_ASLEEP async_fire_time_changed(hass, utcnow() + WAIT) @@ -49,7 +50,7 @@ async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_status) -> """Tests that the coordinator handles client errors.""" mock_get_status.side_effect = ERROR_UNKNOWN - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -61,7 +62,7 @@ async def test_coordinator_auth(hass: HomeAssistant, mock_get_status) -> None: """Tests that the coordinator handles timeout errors.""" mock_get_status.side_effect = ERROR_AUTH - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() @@ -72,7 +73,7 @@ async def test_coordinator_connection(hass: HomeAssistant, mock_get_status) -> N """Tests that the coordinator handles connection errors.""" mock_get_status.side_effect = ERROR_CONNECTION - await setup_platform(hass) + await setup_platform(hass, [Platform.BINARY_SENSOR]) async_fire_time_changed(hass, utcnow() + WAIT) await hass.async_block_till_done() mock_get_status.assert_called_once() diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index 713108b962a..c86cce466e1 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -11,70 +11,72 @@ from homeassistant.components.cover import ( STATE_CLOSED, STATE_OPEN, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_RESPONSE_ERROR, + assert_entities, + setup_platform, +) -@pytest.mark.parametrize( - ("entity_id", "openfunc", "closefunc"), - [ +async def test_covers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the window cover entity is correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + for entity_id, openfunc, closefunc in [ ("cover.test_vent_windows", "vent_windows", "close_windows"), ("cover.test_charge_port_door", "open_unlock_charge_port", "close_charge_port"), ("cover.test_frunk", "open_front_trunk", False), ("cover.test_trunk", "open_close_rear_trunk", "open_close_rear_trunk"), - ], -) -async def test_covers( - hass: HomeAssistant, - entity_id: str, - openfunc: str, - closefunc: str, - snapshot: SnapshotAssertion, -) -> None: - """Tests that the window cover entity is correct.""" + ]: + # Test open windows + if openfunc: + with patch( + f"homeassistant.components.tessie.cover.{openfunc}", + return_value=TEST_RESPONSE, + ) as mock_open: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_open.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN - await setup_platform(hass) - - assert hass.states.get(entity_id) == snapshot(name=entity_id) - - # Test open windows - if openfunc: - with patch( - f"homeassistant.components.tessie.cover.{openfunc}", - return_value=TEST_RESPONSE, - ) as mock_open: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_open.assert_called_once() - assert hass.states.get(entity_id).state == STATE_OPEN - - # Test close windows - if closefunc: - with patch( - f"homeassistant.components.tessie.cover.{closefunc}", - return_value=TEST_RESPONSE, - ) as mock_close: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - mock_close.assert_called_once() - assert hass.states.get(entity_id).state == STATE_CLOSED + # Test close windows + if closefunc: + with patch( + f"homeassistant.components.tessie.cover.{closefunc}", + return_value=TEST_RESPONSE, + ) as mock_close: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_close.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED async def test_errors(hass: HomeAssistant) -> None: """Tests errors are handled.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.COVER]) entity_id = "cover.test_charge_port_door" # Test setting cover open with unknown error @@ -91,13 +93,6 @@ async def test_errors(hass: HomeAssistant) -> None: mock_set.assert_called_once() assert error.from_exception == ERROR_UNKNOWN - -async def test_response_error(hass: HomeAssistant) -> None: - """Tests response errors are handled.""" - - await setup_platform(hass) - entity_id = "cover.test_charge_port_door" - # Test setting cover open with unknown error with patch( "homeassistant.components.tessie.cover.open_unlock_charge_port", diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index d737b02b40e..5b856b31aec 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -1,36 +1,19 @@ """Test the Tessie device tracker platform.""" +from syrupy import SnapshotAssertion -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_STATE_OF_ALL_VEHICLES, setup_platform - -STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] +from .common import assert_entities, setup_platform -async def test_device_tracker(hass: HomeAssistant) -> None: +async def test_device_tracker( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the device tracker entities are correct.""" - assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - await setup_platform(hass) - - assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 2 - - entity_id = "device_tracker.test_location" - state = hass.states.get(entity_id) - assert state.attributes.get(ATTR_LATITUDE) == STATES["drive_state"]["latitude"] - assert state.attributes.get(ATTR_LONGITUDE) == STATES["drive_state"]["longitude"] - - entity_id = "device_tracker.test_route" - state = hass.states.get(entity_id) - assert ( - state.attributes.get(ATTR_LATITUDE) - == STATES["drive_state"]["active_route_latitude"] - ) - assert ( - state.attributes.get(ATTR_LONGITUDE) - == STATES["drive_state"]["active_route_longitude"] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index d1cbdfe1fa9..b1e4f24ac59 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -3,34 +3,32 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_locks(hass: HomeAssistant) -> None: +async def test_locks( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the lock entity is correct.""" - assert len(hass.states.async_all("lock")) == 0 + entry = await setup_platform(hass, [Platform.LOCK]) - await setup_platform(hass) - - assert len(hass.states.async_all("lock")) == 2 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "lock.test_lock" - assert ( - hass.states.get(entity_id).state == STATE_LOCKED - ) == TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["locked"] - # Test lock set value functions with patch("homeassistant.components.tessie.lock.lock") as mock_run: await hass.services.async_call( @@ -39,8 +37,8 @@ async def test_locks(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_LOCKED mock_run.assert_called_once() + assert hass.states.get(entity_id).state == STATE_LOCKED with patch("homeassistant.components.tessie.lock.unlock") as mock_run: await hass.services.async_call( @@ -49,7 +47,7 @@ async def test_locks(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_UNLOCKED + mock_run.assert_called_once() # Test charge cable lock set value functions diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py index f658fe28acd..c9e4c3b84bc 100644 --- a/tests/components/tessie/test_media_player.py +++ b/tests/components/tessie/test_media_player.py @@ -6,41 +6,39 @@ from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import ( - TEST_STATE_OF_ALL_VEHICLES, - TEST_VEHICLE_STATE_ONLINE, - setup_platform, -) +from .common import setup_platform from tests.common import async_fire_time_changed WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) -MEDIA_INFO_1 = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"]["vehicle_state"][ - "media_info" -] -MEDIA_INFO_2 = TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["media_info"] - -async def test_media_player_idle( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion +async def test_media_player( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Tests that the media player entity is correct when idle.""" - assert len(hass.states.async_all("media_player")) == 0 + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) - await setup_platform(hass) + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - assert len(hass.states.async_all("media_player")) == 1 + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-paused") - state = hass.states.get("media_player.test_media_player") - assert state == snapshot + # The refresh fixture has music playing + freezer.tick(WAIT) + async_fire_time_changed(hass) - # Trigger coordinator refresh since it has a different fixture. - freezer.tick(WAIT) - async_fire_time_changed(hass) - - state = hass.states.get("media_player.test_media_player") - assert state == snapshot + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-playing" + ) diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 116c9a2657d..8a3d1a649c7 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -2,70 +2,61 @@ from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE -from homeassistant.components.tessie.number import DESCRIPTIONS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_numbers(hass: HomeAssistant) -> None: +async def test_numbers( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the number entities are correct.""" - assert len(hass.states.async_all("number")) == 0 + entry = await setup_platform(hass, [Platform.NUMBER]) - await setup_platform(hass) - - assert len(hass.states.async_all("number")) == len(DESCRIPTIONS) - - assert hass.states.get("number.test_charge_current").state == str( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_current_request"] - ) - - assert hass.states.get("number.test_charge_limit").state == str( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_limit_soc"] - ) - - assert hass.states.get("number.test_speed_limit").state == str( - TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["speed_limit_mode"][ - "current_limit_mph" - ] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) # Test number set value functions + entity_id = "number.test_charge_current" with patch( "homeassistant.components.tessie.number.set_charging_amps", ) as mock_set_charging_amps: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ["number.test_charge_current"], "value": 16}, + {ATTR_ENTITY_ID: [entity_id], "value": 16}, blocking=True, ) - assert hass.states.get("number.test_charge_current").state == "16.0" mock_set_charging_amps.assert_called_once() + assert hass.states.get(entity_id).state == "16.0" + entity_id = "number.test_charge_limit" with patch( "homeassistant.components.tessie.number.set_charge_limit", ) as mock_set_charge_limit: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ["number.test_charge_limit"], "value": 80}, + {ATTR_ENTITY_ID: [entity_id], "value": 80}, blocking=True, ) - assert hass.states.get("number.test_charge_limit").state == "80.0" mock_set_charge_limit.assert_called_once() + assert hass.states.get(entity_id).state == "80.0" + entity_id = "number.test_speed_limit" with patch( "homeassistant.components.tessie.number.set_speed_limit", ) as mock_set_speed_limit: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: ["number.test_speed_limit"], "value": 60}, + {ATTR_ENTITY_ID: [entity_id], "value": 60}, blocking=True, ) - assert hass.states.get("number.test_speed_limit").state == "60.0" mock_set_speed_limit.assert_called_once() + assert hass.states.get(entity_id).state == "60.0" diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index 09afa9306a7..d22f8cccad7 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -2,30 +2,31 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.components.tessie.const import TessieSeatHeaterOptions -from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er -from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform +from .common import ERROR_UNKNOWN, TEST_RESPONSE, assert_entities, setup_platform -async def test_select(hass: HomeAssistant) -> None: +async def test_select( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the select entities are correct.""" - assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 + entry = await setup_platform(hass, [Platform.SELECT]) - await setup_platform(hass) - - assert len(hass.states.async_all(SELECT_DOMAIN)) == 5 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "select.test_seat_heater_left" - assert hass.states.get(entity_id).state == STATE_OFF # Test changing select with patch( @@ -39,15 +40,15 @@ async def test_select(hass: HomeAssistant) -> None: blocking=True, ) mock_set.assert_called_once() - assert mock_set.call_args[1]["seat"] == "front_left" - assert mock_set.call_args[1]["level"] == 1 - assert hass.states.get(entity_id).state == TessieSeatHeaterOptions.LOW + assert mock_set.call_args[1]["seat"] == "front_left" + assert mock_set.call_args[1]["level"] == 1 + assert hass.states.get(entity_id) == snapshot(name=SERVICE_SELECT_OPTION) async def test_errors(hass: HomeAssistant) -> None: """Tests unknown error is handled.""" - await setup_platform(hass) + await setup_platform(hass, [Platform.SELECT]) entity_id = "select.test_seat_heater_left" # Test setting cover open with unknown error diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index 0c719f66136..fef251f0108 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -1,24 +1,18 @@ """Test the Tessie sensor platform.""" -from homeassistant.components.tessie.sensor import DESCRIPTIONS -from homeassistant.const import STATE_UNKNOWN +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the sensor entities are correct.""" - assert len(hass.states.async_all("sensor")) == 0 + entry = await setup_platform(hass, [Platform.SENSOR]) - await setup_platform(hass) - - assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) - - assert hass.states.get("sensor.test_battery_level").state == str( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] - ) - assert hass.states.get("sensor.test_charge_energy_added").state == str( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] - ) - assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN + assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 5bc24d12e5c..60f3fab490c 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -1,34 +1,30 @@ """Test the Tessie switch platform.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.components.tessie.switch import DESCRIPTIONS -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform +from .common import assert_entities, setup_platform -async def test_switches(hass: HomeAssistant) -> None: +async def test_switches( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that the switche entities are correct.""" - assert len(hass.states.async_all("switch")) == 0 + entry = await setup_platform(hass, [Platform.SWITCH]) - await setup_platform(hass) - - assert len(hass.states.async_all("switch")) == len(DESCRIPTIONS) - - assert (hass.states.get("switch.test_charge").state == STATE_ON) == ( - TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_enable_request"] - ) - assert (hass.states.get("switch.test_sentry_mode").state == STATE_ON) == ( - TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["sentry_mode"] - ) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + entity_id = "switch.test_charge" with patch( "homeassistant.components.tessie.switch.start_charging", ) as mock_start_charging: @@ -36,10 +32,12 @@ async def test_switches(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ["switch.test_charge"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_start_charging.assert_called_once() + assert hass.states.get(entity_id) == snapshot(name=SERVICE_TURN_ON) + with patch( "homeassistant.components.tessie.switch.stop_charging", ) as mock_stop_charging: @@ -47,7 +45,9 @@ async def test_switches(hass: HomeAssistant) -> None: await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ["switch.test_charge"]}, + {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) mock_stop_charging.assert_called_once() + + assert hass.states.get(entity_id) == snapshot(name=SERVICE_TURN_OFF) diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py index 182acdf17ff..54e56c46b50 100644 --- a/tests/components/tessie/test_update.py +++ b/tests/components/tessie/test_update.py @@ -1,30 +1,30 @@ """Test the Tessie update platform.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.components.update import ( ATTR_IN_PROGRESS, DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import assert_entities, setup_platform -async def test_updates(hass: HomeAssistant) -> None: +async def test_updates( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: """Tests that update entity is correct.""" - assert len(hass.states.async_all("update")) == 0 + entry = await setup_platform(hass, [Platform.UPDATE]) - await setup_platform(hass) - - assert len(hass.states.async_all("update")) == 1 + assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "update.test_update" - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes.get(ATTR_IN_PROGRESS) is False with patch( "homeassistant.components.tessie.update.schedule_software_update" From 3cc5ffaa4b1701ff9cc345565f468f48d10d5fab Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 27 Jan 2024 16:39:33 +0100 Subject: [PATCH 1064/1544] Replace modbus number_validator by HA standard (#108939) --- homeassistant/components/modbus/__init__.py | 15 ++++---- .../components/modbus/base_platform.py | 17 ++++++++-- homeassistant/components/modbus/sensor.py | 5 ++- homeassistant/components/modbus/validators.py | 17 ---------- tests/components/modbus/test_init.py | 23 ------------- tests/components/modbus/test_sensor.py | 34 +++++++++---------- 6 files changed, 43 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index f81a1200905..0f674d4d0df 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -136,7 +136,6 @@ from .validators import ( check_config, duplicate_fan_mode_validator, nan_validator, - number_validator, register_int_list_validator, struct_validator, ) @@ -187,8 +186,8 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( ] ), vol.Optional(CONF_STRUCTURE): cv.string, - vol.Optional(CONF_SCALE, default=1): number_validator, - vol.Optional(CONF_OFFSET, default=0): number_validator, + vol.Optional(CONF_SCALE, default=1): cv.positive_float, + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( CONF_SWAP, @@ -242,8 +241,8 @@ CLIMATE_SCHEMA = vol.All( { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): number_validator, - vol.Optional(CONF_MIN_TEMP, default=5): number_validator, + vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float, + vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, @@ -343,10 +342,10 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_MIN_VALUE): number_validator, - vol.Optional(CONF_MAX_VALUE): number_validator, + vol.Optional(CONF_MIN_VALUE): cv.positive_float, + vol.Optional(CONF_MAX_VALUE): cv.positive_float, vol.Optional(CONF_NAN_VALUE): nan_validator, - vol.Optional(CONF_ZERO_SUPPRESS): number_validator, + vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float, } ), ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index af9b83f8b85..877d33afbcc 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -182,12 +182,25 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._scale = config[CONF_SCALE] - self._precision = config.get(CONF_PRECISION, 2 if self._scale < 1 else 0) + self._precision = config.get(CONF_PRECISION, 2) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 ) self._slave_size = self._count = config[CONF_COUNT] + self._value_is_int: bool = self._data_type in ( + DataType.INT16, + DataType.INT32, + DataType.INT64, + DataType.UINT16, + DataType.UINT32, + DataType.UINT64, + ) + if self._value_is_int: + if self._min_value: + self._min_value = round(self._min_value) + if self._max_value: + self._max_value = round(self._max_value) def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: """Do swap as needed.""" @@ -227,7 +240,7 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): return str(self._max_value) if self._zero_suppress is not None and abs(val) <= self._zero_suppress: return "0" - if self._precision == 0: + if self._precision == 0 or self._value_is_int: return str(int(round(val, 0))) return f"{float(val):.{self._precision}f}" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index c015d117b13..4f2a1d6dc76 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -125,7 +125,10 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): if self._coordinator: if result: result_array = list( - map(float if self._precision else int, result.split(",")) + map( + float if not self._value_is_int else int, + result.split(","), + ) ) self._attr_native_value = result_array[0] self._coordinator.async_set_updated_data(result_array) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index e8ce35e834f..76d8e270ffe 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -172,23 +172,6 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: } -def number_validator(value: Any) -> int | float: - """Coerce a value to number without losing precision.""" - if isinstance(value, int): - return value - if isinstance(value, float): - return value - - try: - return int(value) - except (TypeError, ValueError): - pass - try: - return float(value) - except (TypeError, ValueError) as err: - raise vol.Invalid(f"invalid number {value}") from err - - def nan_validator(value: Any) -> int: """Convert nan string to number (can be hex string or int).""" if isinstance(value, int): diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 24ae8d0ebfc..3c932a24afb 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -83,7 +83,6 @@ from homeassistant.components.modbus.validators import ( duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, - number_validator, register_int_list_validator, struct_validator, ) @@ -157,28 +156,6 @@ async def test_register_int_list_validator() -> None: register_int_list_validator(["aq"]) -async def test_number_validator() -> None: - """Test number validator.""" - - for value, value_type in ( - (15, int), - (15.1, float), - ("15", int), - ("15.1", float), - (-15, int), - (-15.1, float), - ("-15", int), - ("-15.1", float), - ): - assert isinstance(number_validator(value), value_type) - - try: - number_validator("x15.1") - except vol.Invalid: - return - pytest.fail("Number_validator not throwing exception") - - async def test_nan_validator() -> None: """Test number validator.""" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index c9e943b06a7..7c58290b143 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -357,7 +357,7 @@ async def test_config_wrong_struct_sensor( }, [7], False, - "34.0000", + "34", ), ( { @@ -379,7 +379,7 @@ async def test_config_wrong_struct_sensor( }, [9], False, - "18.5", + "18", ), ( { @@ -390,7 +390,7 @@ async def test_config_wrong_struct_sensor( }, [1], False, - "2.40", + "2", ), ( { @@ -401,7 +401,7 @@ async def test_config_wrong_struct_sensor( }, [2], False, - "-8.3", + "-8", ), ( { @@ -445,7 +445,7 @@ async def test_config_wrong_struct_sensor( }, [0x89AB, 0xCDEF, 0x0123, 0x4567], False, - "9920249030613615975", + "9920249030613616640", ), ( { @@ -456,7 +456,7 @@ async def test_config_wrong_struct_sensor( }, [0x0123, 0x4567, 0x89AB, 0xCDEF], False, - "163971058432973793", + "163971058432973792", ), ( { @@ -676,7 +676,7 @@ async def test_config_wrong_struct_sensor( }, [0x00AB, 0xCDEF], False, - "112593.75", + "112594", ), ( { @@ -686,7 +686,7 @@ async def test_config_wrong_struct_sensor( }, [0x00AB, 0xCDEF], False, - "112593.75", + "112594", ), ], ) @@ -727,7 +727,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392", "0"], + ["34899771392.0", "0.0"], ), ( { @@ -742,7 +742,7 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: int.from_bytes(struct.pack(">f", float("nan"))[2:4]), ], False, - ["34899771392", "0"], + ["34899771392.0", "0.0"], ), ( { @@ -937,7 +937,7 @@ async def test_virtual_sensor( }, [0x0102, 0x0304, 0x0506, 0x0708], False, - [str(0x0708050603040102)], + [str(0x0708050603040100)], ), ( { @@ -970,7 +970,7 @@ async def test_virtual_sensor( }, [0x0102, 0x0304, 0x0506, 0x0708, 0x0901, 0x0902, 0x0903, 0x0904], False, - [str(0x0708050603040102), str(0x0904090309020901)], + [str(0x0708050603040100), str(0x0904090309020900)], ), ( { @@ -1035,10 +1035,10 @@ async def test_virtual_sensor( ], False, [ - str(0x0604060306020601), - str(0x0704070307020701), - str(0x0804080308020801), - str(0x0904090309020901), + str(0x0604060306020600), + str(0x0704070307020700), + str(0x0804080308020800), + str(0x0904090309020900), ], ), ], @@ -1202,7 +1202,7 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: 0x0000, 0x000A, ], - "0,10", + "0,10.00", ), ( { From 7069fb9508daf81643e670b102f7db4090b3a1c5 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 27 Jan 2024 10:40:23 -0500 Subject: [PATCH 1065/1544] Add model check to ZHA Sonoff manufacturer specific cluster handler (#108947) --- .../zha/core/cluster_handlers/manufacturerspecific.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 732a580759e..9375ecf60b1 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -430,4 +430,8 @@ class XiaomiVibrationAQ1ClusterHandler(MultistateInputClusterHandler): class SonoffPresenceSenorClusterHandler(ClusterHandler): """SonoffPresenceSensor cluster handler.""" - ZCL_INIT_ATTRS = {"last_illumination_state": True} + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize SonoffPresenceSensor cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "SNZB-06P": + self.ZCL_INIT_ATTRS = {"last_illumination_state": True} From 019e80b204c09374518123adeadce7a7e859bdbb Mon Sep 17 00:00:00 2001 From: mkmer Date: Sat, 27 Jan 2024 13:45:13 -0500 Subject: [PATCH 1066/1544] Use version property in Blink (#108911) --- homeassistant/components/blink/alarm_control_panel.py | 2 +- homeassistant/components/blink/camera.py | 2 +- tests/components/blink/conftest.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index d0f8529b6db..80a6ceb50e0 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -65,7 +65,7 @@ class BlinkSyncModuleHA( name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, serial_number=sync.serial, - sw_version=sync.attributes.get("version"), + sw_version=sync.version, ) self._update_attr() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index c90a44ad990..838020c98c6 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -79,7 +79,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, serial_number=camera.serial, - sw_version=camera.attributes.get("version"), + sw_version=camera.version, name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, diff --git a/tests/components/blink/conftest.py b/tests/components/blink/conftest.py index d7deaf39bd9..d15d35e1c08 100644 --- a/tests/components/blink/conftest.py +++ b/tests/components/blink/conftest.py @@ -45,6 +45,7 @@ def camera() -> MagicMock: mock_blink_camera.motion_detected = False mock_blink_camera.wifi_strength = 2.1 mock_blink_camera.camera_type = "lotus" + mock_blink_camera.version = "123" mock_blink_camera.attributes = CAMERA_ATTRIBUTES return mock_blink_camera From 49667a26b2c8085e955393e6c3197e36c89b2c9d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sat, 27 Jan 2024 21:10:03 +0100 Subject: [PATCH 1067/1544] Bump pyenphase to 1.19.0 (#108951) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 4b3a4eadb3d..61c8a07cfbb 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.17.0"], + "requirements": ["pyenphase==1.19.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 9e36de4ed4c..c5351a60b2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1769,7 +1769,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.17.0 +pyenphase==1.19.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f792858aac..c86bfd9515d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1359,7 +1359,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.17.0 +pyenphase==1.19.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 601988ecf2eb37c6b8c59be7b440581846ab603e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Jan 2024 10:30:00 -1000 Subject: [PATCH 1068/1544] Bump cryptography to 42.0.1 and pyOpenSSL to 24.0.0 (#108956) * Bump cryptography to 42.0.1 changes: https://github.com/pyca/cryptography/compare/41.0.7...42.0.1 Note that more of the non-rust backend code has been removed I had to handle that in https://github.com/bdraco/chacha20poly1305-reuseable/releases/tag/v0.12.1 So there may be other downstream consumers that have a problem * need pyOpenSSL as well * too early before coffee --- homeassistant/package_constraints.txt | 10 +++++----- pyproject.toml | 4 ++-- requirements.txt | 4 ++-- script/gen_requirements_all.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f6b596b26ab..8a5ae2dde36 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.7 +cryptography==42.0.1 dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 @@ -44,7 +44,7 @@ pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 PyNaCl==1.5.0 -pyOpenSSL==23.2.0 +pyOpenSSL==24.0.0 pyserial==3.5 python-slugify==8.0.1 PyTurboJPEG==1.7.1 @@ -145,9 +145,9 @@ iso4217!=1.10.20220401 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.1.0 or later required to avoid import errors when -# cryptography 40.0.1 is installed with botocore -pyOpenSSL>=23.1.0 +# pyOpenSSL 24.0.0 or later required to avoid import errors when +# cryptography 42.0.0 is installed with botocore +pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels diff --git a/pyproject.toml b/pyproject.toml index 99027f29b8d..6ed57860ee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,9 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.7", + "cryptography==42.0.1", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ - "pyOpenSSL==23.2.0", + "pyOpenSSL==24.0.0", "orjson==3.9.12", "packaging>=23.1", "pip>=21.3.1", diff --git a/requirements.txt b/requirements.txt index cd5a84a506d..75fd75f6177 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,8 +20,8 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 PyJWT==2.8.0 -cryptography==41.0.7 -pyOpenSSL==23.2.0 +cryptography==42.0.1 +pyOpenSSL==24.0.0 orjson==3.9.12 packaging>=23.1 pip>=21.3.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ee0eee21e59..64d897b7ee7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -138,9 +138,9 @@ iso4217!=1.10.20220401 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.1.0 or later required to avoid import errors when -# cryptography 40.0.1 is installed with botocore -pyOpenSSL>=23.1.0 +# pyOpenSSL 24.0.0 or later required to avoid import errors when +# cryptography 42.0.0 is installed with botocore +pyOpenSSL>=24.0.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels From a793a5445f4a9f33f2e1c334c0d569ec772335fe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 27 Jan 2024 22:24:35 +0100 Subject: [PATCH 1069/1544] Add options flow to Analytics Insights (#108716) * Add options flow to Analytics Insights * Fix options flow function --- .../components/analytics_insights/__init__.py | 6 ++ .../analytics_insights/config_flow.py | 60 ++++++++++++++++++- .../analytics_insights/strings.json | 15 ++++- .../fixtures/integrations.json | 7 +++ .../analytics_insights/test_config_flow.py | 46 ++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 2078d9715f4..1c5118ca004 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -46,6 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -56,3 +57,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index afa6b2bac38..eb6d0f87079 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -10,7 +10,13 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import IntegrationType import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -31,6 +37,12 @@ INTEGRATION_TYPES_WITHOUT_ANALYTICS = ( class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Homeassistant Analytics.""" + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return HomeassistantAnalyticsOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -72,3 +84,49 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): } ), ) + + +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Homeassistant Analytics options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input: + return self.async_create_entry(title="", data=user_input) + + client = HomeassistantAnalyticsClient( + session=async_get_clientsession(self.hass) + ) + try: + integrations = await client.get_integrations() + except HomeassistantAnalyticsConnectionError: + LOGGER.exception("Error connecting to Home Assistant analytics") + return self.async_abort(reason="cannot_connect") + + options = [ + SelectOptionDict( + value=domain, + label=integration.title, + ) + for domain, integration in integrations.items() + if integration.integration_type not in INTEGRATION_TYPES_WITHOUT_ANALYTICS + ] + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=True, + sort=True, + ) + ), + }, + ), + self.options, + ), + ) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index c6890524a6b..5c249a1cd5a 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -7,12 +7,21 @@ } } }, - "error": { + "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]" + } + } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } } diff --git a/tests/components/analytics_insights/fixtures/integrations.json b/tests/components/analytics_insights/fixtures/integrations.json index eb42216c232..d43f75ab32e 100644 --- a/tests/components/analytics_insights/fixtures/integrations.json +++ b/tests/components/analytics_insights/fixtures/integrations.json @@ -5,5 +5,12 @@ "quality_scale": "", "iot_class": "Cloud Polling", "integration_type": "service" + }, + "hue": { + "title": "Philips Hue", + "description": "Instructions on setting up Philips Hue within Home Assistant.", + "quality_scale": "platinum", + "iot_class": "Local Push", + "integration_type": "hub" } } diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index 4046bd040df..a93290745f2 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +from tests.components.analytics_insights import setup_integration async def test_form( @@ -68,3 +69,48 @@ async def test_form_already_configured( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_options_flow( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + mock_analytics_client.get_integrations.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + } + await hass.async_block_till_done() + mock_analytics_client.get_integrations.assert_called_once() + + +async def test_options_flow_cannot_connect( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle cannot connect error.""" + + mock_analytics_client.get_integrations.side_effect = ( + HomeassistantAnalyticsConnectionError + ) + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" From 40010620dd35f6045ff7138573f606d6e86a2212 Mon Sep 17 00:00:00 2001 From: Matrix Date: Sun, 28 Jan 2024 13:38:42 +0800 Subject: [PATCH 1070/1544] Bump yolink-api to 0.3.6 fix aiomqtt breaking changes (#108555) * bump yolink-api to 0.3.5 * bump yolink-api to 0.3.6 --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index a42687a3551..6fd62ce571c 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.4"] + "requirements": ["yolink-api==0.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index c5351a60b2e..63c813bdaec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2879,7 +2879,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.4 +yolink-api==0.3.6 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c86bfd9515d..1f66cf9bb3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2199,7 +2199,7 @@ yalexs==1.10.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.4 +yolink-api==0.3.6 # homeassistant.components.youless youless-api==1.0.1 From ba5d10be7311516b9fd20e12d3a6f3a657b1f632 Mon Sep 17 00:00:00 2001 From: myztillx <33730898+myztillx@users.noreply.github.com> Date: Sun, 28 Jan 2024 05:21:52 -0500 Subject: [PATCH 1071/1544] Separate ecobee start and end date/times for create_vacation service (#107255) Separate start and end time msg and update service string --- homeassistant/components/ecobee/climate.py | 21 +++++++++++++------- homeassistant/components/ecobee/strings.json | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 1b0e65f7390..e15a8e1d3d8 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -115,9 +115,12 @@ SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" -DTGROUP_INCLUSIVE_MSG = ( - f"{ATTR_START_DATE}, {ATTR_START_TIME}, {ATTR_END_DATE}, " - f"and {ATTR_END_TIME} must be specified together" +DTGROUP_START_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" +) + +DTGROUP_END_INCLUSIVE_MSG = ( + f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" ) CREATE_VACATION_SCHEMA = vol.Schema( @@ -127,13 +130,17 @@ CREATE_VACATION_SCHEMA = vol.Schema( vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), vol.Inclusive( - ATTR_START_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG ): ecobee_date, vol.Inclusive( - ATTR_START_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive( + ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG ): ecobee_time, - vol.Inclusive(ATTR_END_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_date, - vol.Inclusive(ATTR_END_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_time, vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( int, vol.Range(min=0, max=60) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index fc43fc3000e..484d5bf1e1e 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -52,7 +52,7 @@ }, "start_date": { "name": "Start date", - "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time, end_date, and end_time)." + "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time)." }, "start_time": { "name": "Start time", @@ -60,7 +60,7 @@ }, "end_date": { "name": "End date", - "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with start_date, start_time, and end_time)." + "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with end_time)." }, "end_time": { "name": "End time", From c6ffd453d2556c32e614ceede9ff5df305b4246d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 28 Jan 2024 11:26:02 +0100 Subject: [PATCH 1072/1544] Bump pytrafikverket to 0.3.10 (#108984) --- homeassistant/components/trafikverket_camera/manifest.json | 2 +- homeassistant/components/trafikverket_ferry/manifest.json | 2 +- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index d7631ada680..ac8570d8a02 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index e1c86038986..99feccf983f 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 83dd0e726ee..6a09821f729 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 1f27346b3a8..430d240761f 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.2"] + "requirements": ["pytrafikverket==0.3.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63c813bdaec..ddc25e08d6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2308,7 +2308,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.2 +pytrafikverket==0.3.10 # homeassistant.components.v2c pytrydan==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f66cf9bb3c..198b9f44cee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1763,7 +1763,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.2 +pytrafikverket==0.3.10 # homeassistant.components.v2c pytrydan==0.4.0 From a2d707442a2101656a1969956c9b1c008c0c4f81 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 28 Jan 2024 08:13:00 -0500 Subject: [PATCH 1073/1544] Fix error when passing a whole number to location selector (#108952) * Fix error when passing an integer to location selector * fix tests * more fix tests * don't mutate original dict * remove string testcase --- homeassistant/helpers/selector.py | 6 +++--- tests/helpers/test_selector.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9c4266583e8..52e48724639 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -879,9 +879,9 @@ class LocationSelector(Selector[LocationSelectorConfig]): ) DATA_SCHEMA = vol.Schema( { - vol.Required("latitude"): float, - vol.Required("longitude"): float, - vol.Optional("radius"): float, + vol.Required("latitude"): vol.Coerce(float), + vol.Required("longitude"): vol.Coerce(float), + vol.Optional("radius"): vol.Coerce(float), } ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index e925b425f96..633673cac98 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -858,6 +858,11 @@ def test_language_selector_schema(schema, valid_selections, invalid_selections) "longitude": 2.0, "radius": 3.0, }, + { + "latitude": 1, + "longitude": 2, + "radius": 3, + }, ), ( None, @@ -865,7 +870,6 @@ def test_language_selector_schema(schema, valid_selections, invalid_selections) {}, {"latitude": 1.0}, {"longitude": 1.0}, - {"latitude": 1.0, "longitude": "1.0"}, ), ), ), From 9413d15c256e92957a009f322a4700162fd1da90 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Sun, 28 Jan 2024 15:02:39 +0100 Subject: [PATCH 1074/1544] Add enum sensor to Vogel's MotionMount integration (#108643) Add enum sensor entity --- .coveragerc | 1 + .../components/motionmount/__init__.py | 7 ++- .../components/motionmount/sensor.py | 46 +++++++++++++++++++ .../components/motionmount/strings.json | 10 ++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/motionmount/sensor.py diff --git a/.coveragerc b/.coveragerc index 13cab09e294..1d291610bc1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -780,6 +780,7 @@ omit = homeassistant/components/motionmount/entity.py homeassistant/components/motionmount/number.py homeassistant/components/motionmount/select.py + homeassistant/components/motionmount/sensor.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 5c661a77955..6f62a0731b6 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -13,7 +13,12 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SELECT] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py new file mode 100644 index 00000000000..ed3cbd7d38b --- /dev/null +++ b/homeassistant/components/motionmount/sensor.py @@ -0,0 +1,46 @@ +"""Support for MotionMount sensors.""" +import motionmount + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm = hass.data[DOMAIN][entry.entry_id] + + async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) + + +class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): + """The error status sensor of a MotionMount.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["none", "motor", "internal"] + _attr_translation_key = "motionmount_error_status" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize sensor entiry.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-error-status" + + @property + def native_value(self) -> str: + """Return error status.""" + errors = self.mm.error_status or 0 + + if errors & (1 << 31): + # Only when but 31 is set are there any errors active at this moment + if errors & (1 << 10): + return "motor" + + return "internal" + + return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 94859dc90e3..39f7c53db35 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -38,6 +38,16 @@ "name": "Turn" } }, + "sensor": { + "motionmount_error_status": { + "name": "Error Status", + "state": { + "none": "None", + "motor": "Motor", + "internal": "Internal" + } + } + }, "select": { "motionmount_preset": { "name": "Preset", From f2100f80c4fa42db0accd6bb93e28d06d03cd052 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Sun, 28 Jan 2024 16:06:57 +0100 Subject: [PATCH 1075/1544] Add device info to lupusec (#108910) * added device info and unique id * removed wrong attribute * added base entity * rename domain * added entity.py to coveragerc * added base entity for sensors and alarm panel * add generic type translation * rename functions * rename device name to device model * set _attr_name = None * pass in only the entry_id instead of the full config_entry * set unique id to device_id or entry id * use deviceinfo class * moved _attr_name = None to entities * Update homeassistant/components/lupusec/alarm_control_panel.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lupusec/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lupusec/entity.py Co-authored-by: Joost Lekkerkerker * remove DOMAIN from unique id * removed redundant function * Update homeassistant/components/lupusec/alarm_control_panel.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lupusec/entity.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: suaveolent Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/lupusec/__init__.py | 19 --------- .../components/lupusec/alarm_control_panel.py | 22 ++++++++-- .../components/lupusec/binary_sensor.py | 9 ++-- homeassistant/components/lupusec/const.py | 33 +++++++++++++++ homeassistant/components/lupusec/entity.py | 42 +++++++++++++++++++ homeassistant/components/lupusec/switch.py | 9 ++-- 7 files changed, 107 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/lupusec/entity.py diff --git a/.coveragerc b/.coveragerc index 1d291610bc1..511f30c507e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -709,6 +709,7 @@ omit = homeassistant/components/lupusec/__init__.py homeassistant/components/lupusec/alarm_control_panel.py homeassistant/components/lupusec/binary_sensor.py + homeassistant/components/lupusec/entity.py homeassistant/components/lupusec/switch.py homeassistant/components/lutron/__init__.py homeassistant/components/lutron/binary_sensor.py diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index b55c203b0e7..7493059e9e9 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -17,7 +17,6 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -139,21 +138,3 @@ class LupusecSystem: def __init__(self, username, password, ip_address) -> None: """Initialize the system.""" self.lupusec = lupupy.Lupusec(username, password, ip_address) - - -class LupusecDevice(Entity): - """Representation of a Lupusec device.""" - - def __init__(self, data, device) -> None: - """Initialize a sensor for Lupusec device.""" - self._data = data - self._device = device - - def update(self): - """Update automation state.""" - self._device.refresh() - - @property - def name(self): - """Return the name of the sensor.""" - return self._device.name diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 8dd2ecb8b9c..2a904c34410 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -15,9 +15,11 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) @@ -28,9 +30,11 @@ async def async_setup_entry( async_add_devices: AddEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" - data = hass.data[LUPUSEC_DOMAIN][config_entry.entry_id] + data = hass.data[DOMAIN][config_entry.entry_id] - alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())] + alarm_devices = [ + LupusecAlarm(data, data.lupusec.get_alarm(), config_entry.entry_id) + ] async_add_devices(alarm_devices) @@ -38,12 +42,24 @@ async def async_setup_entry( class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): """An alarm_control_panel implementation for Lupusec.""" + _attr_name = None _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + def __init__(self, data, device, entry_id) -> None: + """Initialize the LupusecAlarm class.""" + super().__init__(data, device, entry_id) + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=device.name, + manufacturer="Lupus Electronics", + model=f"Lupusec-XT{data.lupusec.model}", + ) + @property def state(self) -> str | None: """Return the state of the device.""" diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 0819d30e1fc..06a07c070c6 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -14,7 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -34,14 +35,16 @@ async def async_setup_entry( sensors = [] for device in data.lupusec.get_devices(generic_type=device_types): - sensors.append(LupusecBinarySensor(data, device)) + sensors.append(LupusecBinarySensor(data, device, config_entry.entry_id)) async_add_devices(sensors) -class LupusecBinarySensor(LupusecDevice, BinarySensorEntity): +class LupusecBinarySensor(LupusecBaseSensor, BinarySensorEntity): """A binary sensor implementation for Lupusec device.""" + _attr_name = None + @property def is_on(self): """Return True if the binary sensor is on.""" diff --git a/homeassistant/components/lupusec/const.py b/homeassistant/components/lupusec/const.py index 08aee718440..489d878306d 100644 --- a/homeassistant/components/lupusec/const.py +++ b/homeassistant/components/lupusec/const.py @@ -1,6 +1,39 @@ """Constants for the Lupusec component.""" +from lupupy.constants import ( + TYPE_CONTACT_XT, + TYPE_DOOR, + TYPE_INDOOR_SIREN_XT, + TYPE_KEYPAD_V2, + TYPE_OUTDOOR_SIREN_XT, + TYPE_POWER_SWITCH, + TYPE_POWER_SWITCH_1_XT, + TYPE_POWER_SWITCH_2_XT, + TYPE_SMOKE, + TYPE_SMOKE_XT, + TYPE_WATER, + TYPE_WATER_XT, + TYPE_WINDOW, +) + DOMAIN = "lupusec" INTEGRATION_TITLE = "Lupus Electronics LUPUSEC" ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=lupusec"} + + +TYPE_TRANSLATION = { + TYPE_WINDOW: "Fensterkontakt", + TYPE_DOOR: "Türkontakt", + TYPE_SMOKE: "Rauchmelder", + TYPE_WATER: "Wassermelder", + TYPE_POWER_SWITCH: "Steckdose", + TYPE_CONTACT_XT: "Fenster- / Türkontakt V2", + TYPE_WATER_XT: "Wassermelder V2", + TYPE_SMOKE_XT: "Rauchmelder V2", + TYPE_POWER_SWITCH_1_XT: "Funksteckdose", + TYPE_POWER_SWITCH_2_XT: "Funksteckdose V2", + TYPE_KEYPAD_V2: "Keypad V2", + TYPE_INDOOR_SIREN_XT: "Innensirene", + TYPE_OUTDOOR_SIREN_XT: "Außensirene V2", +} diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py new file mode 100644 index 00000000000..208a0edafaa --- /dev/null +++ b/homeassistant/components/lupusec/entity.py @@ -0,0 +1,42 @@ +"""Provides the Lupusec entity for Home Assistant.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, TYPE_TRANSLATION + + +class LupusecDevice(Entity): + """Representation of a Lupusec device.""" + + _attr_has_entity_name = True + + def __init__(self, data, device, entry_id) -> None: + """Initialize a sensor for Lupusec device.""" + self._data = data + self._device = device + self._attr_unique_id = device.device_id + + def update(self): + """Update automation state.""" + self._device.refresh() + + +class LupusecBaseSensor(LupusecDevice): + """Lupusec Sensor base entity.""" + + def __init__(self, data, device, entry_id) -> None: + """Initialize the LupusecBaseSensor.""" + super().__init__(data, device, entry_id) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=device.name, + manufacturer="Lupus Electronics", + serial_number=device.device_id, + model=TYPE_TRANSLATION.get(device.type, device.type), + via_device=(DOMAIN, entry_id), + ) + + def get_type_name(self): + """Return the type of the sensor.""" + return TYPE_TRANSLATION.get(self._device.type, self._device.type) diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 582d72b7cfe..7b87b2cac55 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -11,7 +11,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, LupusecDevice +from . import DOMAIN +from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -29,14 +30,16 @@ async def async_setup_entry( switches = [] for device in data.lupusec.get_devices(generic_type=device_types): - switches.append(LupusecSwitch(data, device)) + switches.append(LupusecSwitch(data, device, config_entry.entry_id)) async_add_devices(switches) -class LupusecSwitch(LupusecDevice, SwitchEntity): +class LupusecSwitch(LupusecBaseSensor, SwitchEntity): """Representation of a Lupusec switch.""" + _attr_name = None + def turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" self._device.switch_on() From f413ff2837518250fb4805de9a1a43c10e384bf1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 28 Jan 2024 18:50:38 +0100 Subject: [PATCH 1076/1544] Add clima support to Comelit integration (#108858) * Add clima support to Comelit integration * address first part of review comments * applied more review comments * remove old multiplier * removed preset modes (not always configured) * small tweak * apply StrEnum class --- .coveragerc | 1 + homeassistant/components/comelit/__init__.py | 1 + homeassistant/components/comelit/climate.py | 206 +++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 homeassistant/components/comelit/climate.py diff --git a/.coveragerc b/.coveragerc index 511f30c507e..c05cab27f79 100644 --- a/.coveragerc +++ b/.coveragerc @@ -182,6 +182,7 @@ omit = homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py homeassistant/components/comelit/alarm_control_panel.py + homeassistant/components/comelit/climate.py homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index c51081196c9..06db68a2444 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -11,6 +11,7 @@ from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem BRIDGE_PLATFORMS = [ + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.SENSOR, diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py new file mode 100644 index 00000000000..6f45968be4f --- /dev/null +++ b/homeassistant/components/comelit/climate.py @@ -0,0 +1,206 @@ +"""Support for climates.""" +from __future__ import annotations + +import asyncio +from enum import StrEnum +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE, SLEEP_BETWEEN_CALLS + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, + UnitOfTemperature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import ComelitSerialBridge + + +class ClimaMode(StrEnum): + """Serial Bridge clima modes.""" + + AUTO = "A" + OFF = "O" + LOWER = "L" + UPPER = "U" + + +class ClimaAction(StrEnum): + """Serial Bridge clima actions.""" + + OFF = "off" + ON = "on" + MANUAL = "man" + SET = "set" + AUTO = "auto" + + +API_STATUS: dict[str, dict[str, Any]] = { + ClimaMode.OFF: { + "action": "off", + "hvac_mode": HVACMode.OFF, + "hvac_action": HVACAction.OFF, + }, + ClimaMode.LOWER: { + "action": "lower", + "hvac_mode": HVACMode.COOL, + "hvac_action": HVACAction.COOLING, + }, + ClimaMode.UPPER: { + "action": "upper", + "hvac_mode": HVACMode.HEAT, + "hvac_action": HVACAction.HEATING, + }, +} + +MODE_TO_ACTION: dict[HVACMode, ClimaAction] = { + HVACMode.OFF: ClimaAction.OFF, + HVACMode.AUTO: ClimaAction.AUTO, + HVACMode.COOL: ClimaAction.MANUAL, + HVACMode.HEAT: ClimaAction.MANUAL, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit climates.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitClimateEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[CLIMATE].values() + ) + + +class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity): + """Climate device.""" + + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] + _attr_max_temp = 30 + _attr_min_temp = 5 + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_TENTHS + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init light entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, device.type) + + @property + def _clima(self) -> list[Any]: + """Return clima device data.""" + # CLIMATE has 2 turple: + # - first for Clima + # - second for Humidifier + return self.coordinator.data[CLIMATE][self._device.index].val[0] + + @property + def _api_mode(self) -> str: + """Return device mode.""" + # Values from API: "O", "L", "U" + return self._clima[2] + + @property + def _api_active(self) -> bool: + "Return device active/idle." + return self._clima[1] + + @property + def _api_automatic(self) -> bool: + """Return device in automatic/manual mode.""" + return self._clima[3] == ClimaMode.AUTO + + @property + def target_temperature(self) -> float: + """Set target temperature.""" + return self._clima[4] / 10 + + @property + def current_temperature(self) -> float: + """Return current temperature.""" + return self._clima[0] / 10 + + @property + def hvac_mode(self) -> HVACMode | None: + """HVAC current mode.""" + + if self._api_mode == ClimaMode.OFF: + return HVACMode.OFF + + if self._api_automatic: + return HVACMode.AUTO + + if self._api_mode in API_STATUS: + return API_STATUS[self._api_mode]["hvac_mode"] + + _LOGGER.warning("Unknown API mode '%s' in hvac_mode", self._api_mode) + return None + + @property + def hvac_action(self) -> HVACAction | None: + """HVAC current action.""" + + if self._api_mode == ClimaMode.OFF: + return HVACAction.OFF + + if not self._api_active: + return HVACAction.IDLE + + if self._api_mode in API_STATUS: + return API_STATUS[self._api_mode]["hvac_action"] + + _LOGGER.warning("Unknown API mode '%s' in hvac_action", self._api_mode) + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ( + target_temp := kwargs.get(ATTR_TEMPERATURE) + ) is None or self.hvac_mode == HVACMode.OFF: + return + + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.MANUAL + ) + await asyncio.sleep(SLEEP_BETWEEN_CALLS) + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.SET, target_temp + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode != HVACMode.OFF: + await self.coordinator.api.set_clima_status( + self._device.index, ClimaAction.ON + ) + await asyncio.sleep(SLEEP_BETWEEN_CALLS) + await self.coordinator.api.set_clima_status( + self._device.index, MODE_TO_ACTION[hvac_mode] + ) From 19988b456cd7df08eb3e569a93709fe5883bbbd4 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:07:14 +0100 Subject: [PATCH 1077/1544] Fix entity naming for heatpump heatings in ViCare (#109013) Update strings.json --- homeassistant/components/vicare/strings.json | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 6c08215a9c1..87b5bb6cc14 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -141,52 +141,52 @@ "name": "Heating gas consumption this year" }, "gas_summary_consumption_heating_currentday": { - "name": "Heating gas consumption current day" + "name": "Heating gas consumption today" }, "gas_summary_consumption_heating_currentmonth": { - "name": "Heating gas consumption current month" + "name": "Heating gas consumption this month" }, "gas_summary_consumption_heating_currentyear": { - "name": "Heating gas consumption current year" + "name": "Heating gas consumption this year" }, "gas_summary_consumption_heating_lastsevendays": { "name": "Heating gas consumption last seven days" }, "hotwater_gas_summary_consumption_heating_currentday": { - "name": "DHW gas consumption current day" + "name": "DHW gas consumption today" }, "hotwater_gas_summary_consumption_heating_currentmonth": { - "name": "DHW gas consumption current month" + "name": "DHW gas consumption this month" }, "hotwater_gas_summary_consumption_heating_currentyear": { - "name": "DHW gas consumption current year" + "name": "DHW gas consumption this year" }, "hotwater_gas_summary_consumption_heating_lastsevendays": { "name": "DHW gas consumption last seven days" }, "energy_summary_consumption_heating_currentday": { - "name": "Energy consumption of gas heating current day" + "name": "Heating energy consumption today" }, "energy_summary_consumption_heating_currentmonth": { - "name": "Energy consumption of gas heating current month" + "name": "Heating energy consumption this month" }, "energy_summary_consumption_heating_currentyear": { - "name": "Energy consumption of gas heating current year" + "name": "Heating energy consumption this year" }, "energy_summary_consumption_heating_lastsevendays": { - "name": "Energy consumption of gas heating last seven days" + "name": "Heating energy consumption last seven days" }, "energy_dhw_summary_consumption_heating_currentday": { - "name": "Energy consumption of hot water gas heating current day" + "name": "DHW energy consumption today" }, "energy_dhw_summary_consumption_heating_currentmonth": { - "name": "Energy consumption of hot water gas heating current month" + "name": "DHW energy consumption this month" }, "energy_dhw_summary_consumption_heating_currentyear": { - "name": "Energy consumption of hot water gas heating current year" + "name": "DHW energy consumption this year" }, "energy_summary_dhw_consumption_heating_lastsevendays": { - "name": "Energy consumption of hot water gas heating last seven days" + "name": "DHW energy consumption last seven days" }, "power_production_current": { "name": "Power production current" From 17b543513cbc7ddbea15afba4c198077573e1f96 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 Jan 2024 19:18:22 +0100 Subject: [PATCH 1078/1544] Add strings to Sensirion BLE (#109001) --- .../components/sensirion_ble/strings.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 homeassistant/components/sensirion_ble/strings.json diff --git a/homeassistant/components/sensirion_ble/strings.json b/homeassistant/components/sensirion_ble/strings.json new file mode 100644 index 00000000000..d1d544c2381 --- /dev/null +++ b/homeassistant/components/sensirion_ble/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} From 6ef0b9bf976eae122b70b7f6d83b2f01d740c5fc Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 29 Jan 2024 04:25:17 +1000 Subject: [PATCH 1079/1544] Bump tesla-fleet-api to 0.2.3 (#108992) --- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index be6f6ae634c..c76ac6fb63a 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.2.0"] + "requirements": ["tesla-fleet-api==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ddc25e08d6f..b344cb2934f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2660,7 +2660,7 @@ temperusb==1.6.1 # tensorflow==2.5.0 # homeassistant.components.teslemetry -tesla-fleet-api==0.2.0 +tesla-fleet-api==0.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 198b9f44cee..4250ec7d85d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ temescal==0.5 temperusb==1.6.1 # homeassistant.components.teslemetry -tesla-fleet-api==0.2.0 +tesla-fleet-api==0.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.0 From 5818b6141a030ff7111b6c1c9a87c779b006b6e9 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:26:05 +0100 Subject: [PATCH 1080/1544] Added type information to lupusec (#109004) Co-authored-by: suaveolent --- homeassistant/components/lupusec/__init__.py | 14 ++------------ .../components/lupusec/alarm_control_panel.py | 14 ++++++++------ homeassistant/components/lupusec/binary_sensor.py | 8 ++++---- homeassistant/components/lupusec/entity.py | 11 ++++++----- homeassistant/components/lupusec/switch.py | 6 +++--- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index 7493059e9e9..bf7c30845a3 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -109,11 +109,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: lupusec_system = await hass.async_add_executor_job( - LupusecSystem, - username, - password, - host, + lupupy.Lupusec, username, password, host ) + except LupusecException: _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False @@ -130,11 +128,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True - - -class LupusecSystem: - """Lupusec System class.""" - - def __init__(self, username, password, ip_address) -> None: - """Initialize the system.""" - self.lupusec = lupupy.Lupusec(username, password, ip_address) diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 2a904c34410..2e4ca5cab63 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +import lupupy + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -32,9 +34,7 @@ async def async_setup_entry( """Set up an alarm control panel for a Lupusec device.""" data = hass.data[DOMAIN][config_entry.entry_id] - alarm_devices = [ - LupusecAlarm(data, data.lupusec.get_alarm(), config_entry.entry_id) - ] + alarm_devices = [LupusecAlarm(data, data.get_alarm(), config_entry.entry_id)] async_add_devices(alarm_devices) @@ -49,15 +49,17 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) - def __init__(self, data, device, entry_id) -> None: + def __init__( + self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str + ) -> None: """Initialize the LupusecAlarm class.""" - super().__init__(data, device, entry_id) + super().__init__(device) self._attr_unique_id = entry_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry_id)}, name=device.name, manufacturer="Lupus Electronics", - model=f"Lupusec-XT{data.lupusec.model}", + model=f"Lupusec-XT{data.model}", ) @property diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index 06a07c070c6..ecff9a6266d 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -34,8 +34,8 @@ async def async_setup_entry( device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR sensors = [] - for device in data.lupusec.get_devices(generic_type=device_types): - sensors.append(LupusecBinarySensor(data, device, config_entry.entry_id)) + for device in data.get_devices(generic_type=device_types): + sensors.append(LupusecBinarySensor(device, config_entry.entry_id)) async_add_devices(sensors) @@ -46,12 +46,12 @@ class LupusecBinarySensor(LupusecBaseSensor, BinarySensorEntity): _attr_name = None @property - def is_on(self): + def is_on(self) -> bool: """Return True if the binary sensor is on.""" return self._device.is_on @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" if self._device.generic_type not in ( item.value for item in BinarySensorDeviceClass diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py index 208a0edafaa..6237e5dd16b 100644 --- a/homeassistant/components/lupusec/entity.py +++ b/homeassistant/components/lupusec/entity.py @@ -1,4 +1,6 @@ """Provides the Lupusec entity for Home Assistant.""" +import lupupy + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -10,9 +12,8 @@ class LupusecDevice(Entity): _attr_has_entity_name = True - def __init__(self, data, device, entry_id) -> None: + def __init__(self, device: lupupy.devices.LupusecDevice) -> None: """Initialize a sensor for Lupusec device.""" - self._data = data self._device = device self._attr_unique_id = device.device_id @@ -24,9 +25,9 @@ class LupusecDevice(Entity): class LupusecBaseSensor(LupusecDevice): """Lupusec Sensor base entity.""" - def __init__(self, data, device, entry_id) -> None: + def __init__(self, device: lupupy.devices.LupusecDevice, entry_id: str) -> None: """Initialize the LupusecBaseSensor.""" - super().__init__(data, device, entry_id) + super().__init__(device) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device_id)}, @@ -37,6 +38,6 @@ class LupusecBaseSensor(LupusecDevice): via_device=(DOMAIN, entry_id), ) - def get_type_name(self): + def get_type_name(self) -> str: """Return the type of the sensor.""" return TYPE_TRANSLATION.get(self._device.type, self._device.type) diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 7b87b2cac55..a2b3796ef5b 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -29,8 +29,8 @@ async def async_setup_entry( device_types = CONST.TYPE_SWITCH switches = [] - for device in data.lupusec.get_devices(generic_type=device_types): - switches.append(LupusecSwitch(data, device, config_entry.entry_id)) + for device in data.get_devices(generic_type=device_types): + switches.append(LupusecSwitch(device, config_entry.entry_id)) async_add_devices(switches) @@ -49,6 +49,6 @@ class LupusecSwitch(LupusecBaseSensor, SwitchEntity): self._device.switch_off() @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.is_on From 8abb4e5f52dc6c9dfb7a5e2f27b0cd40c21dd770 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 28 Jan 2024 19:27:14 +0100 Subject: [PATCH 1081/1544] Improve display of errors with no message in script trace (#108735) --- homeassistant/helpers/trace.py | 2 +- tests/helpers/test_script.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 53e66e1c651..21154914f17 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -93,7 +93,7 @@ class TraceElement: if self._variables: result["changed_variables"] = self._variables if self._error is not None: - result["error"] = str(self._error) + result["error"] = str(self._error) or self._error.__class__.__name__ if self._result is not None: result["result"] = self._result return result diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 57c1b2dd473..b0136fdebc9 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1341,7 +1341,7 @@ async def test_wait_continue_on_timeout( } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True - expected_trace["0"][0]["error"] = "" + expected_trace["0"][0]["error"] = "TimeoutError" expected_script_execution = "aborted" else: expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] From c3222ef733ecbd1325352fcaa52d2c8ab4dd7f06 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 28 Jan 2024 13:28:06 -0500 Subject: [PATCH 1082/1544] Fix statuses for ZHA attribute reporting configuration event (#108532) Co-authored-by: TheJulianJES --- .../zha/core/cluster_handlers/__init__.py | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index c72d84adecd..65137d683de 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -262,7 +262,7 @@ class ClusterHandler(LogMixin): "id": attr, "name": attr_name, "change": config[2], - "success": False, + "status": None, } to_configure = [*self.REPORT_CONFIG] @@ -274,10 +274,7 @@ class ClusterHandler(LogMixin): reports = {rec["attr"]: rec["config"] for rec in chunk} try: res = await self.cluster.configure_reporting_multiple(reports, **kwargs) - self._configure_reporting_status(reports, res[0]) - # if we get a response, then it's a success - for attr_stat in event_data.values(): - attr_stat["success"] = True + self._configure_reporting_status(reports, res[0], event_data) except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: self.debug( "failed to set reporting on '%s' cluster for: %s", @@ -304,7 +301,10 @@ class ClusterHandler(LogMixin): ) def _configure_reporting_status( - self, attrs: dict[str, tuple[int, int, float | int]], res: list | tuple + self, + attrs: dict[str, tuple[int, int, float | int]], + res: list | tuple, + event_data: dict[str, dict[str, Any]], ) -> None: """Parse configure reporting result.""" if isinstance(res, (Exception, ConfigureReportingResponseRecord)): @@ -315,6 +315,8 @@ class ClusterHandler(LogMixin): self.name, res, ) + for attr in attrs: + event_data[attr]["status"] = Status.FAILURE.name return if res[0].status == Status.SUCCESS and len(res) == 1: self.debug( @@ -323,24 +325,38 @@ class ClusterHandler(LogMixin): self.name, res, ) + # 2.5.8.1.3 Status Field + # The status field specifies the status of the Configure Reporting operation attempted on this attribute, as detailed in 2.5.7.3. + # Note that attribute status records are not included for successfully configured attributes, in order to save bandwidth. + # In the case of successful configuration of all attributes, only a single attribute status record SHALL be included in the command, + # with the status field set to SUCCESS and the direction and attribute identifier fields omitted. + for attr in attrs: + event_data[attr]["status"] = Status.SUCCESS.name return + for record in res: + event_data[self.cluster.find_attribute(record.attrid).name][ + "status" + ] = record.status.name failed = [ self.cluster.find_attribute(record.attrid).name for record in res if record.status != Status.SUCCESS ] - self.debug( - "Successfully configured reporting for '%s' on '%s' cluster", - set(attrs) - set(failed), - self.name, - ) self.debug( "Failed to configure reporting for '%s' on '%s' cluster: %s", failed, self.name, res, ) + success = set(attrs) - set(failed) + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + set(attrs) - set(failed), + self.name, + ) + for attr in success: + event_data[attr]["status"] = Status.SUCCESS.name async def async_configure(self) -> None: """Set cluster binding and attribute reporting.""" From 843c84a325983472dd0844a49ca305382521d589 Mon Sep 17 00:00:00 2001 From: max2697 <143563471+max2697@users.noreply.github.com> Date: Sun, 28 Jan 2024 12:54:16 -0600 Subject: [PATCH 1083/1544] Add new virtual integration for opower City of Austin Utilities provider (#108337) --- homeassistant/components/coautilities/__init__.py | 1 + homeassistant/components/coautilities/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/coautilities/__init__.py create mode 100644 homeassistant/components/coautilities/manifest.json diff --git a/homeassistant/components/coautilities/__init__.py b/homeassistant/components/coautilities/__init__.py new file mode 100644 index 00000000000..f21006af29d --- /dev/null +++ b/homeassistant/components/coautilities/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: City of Austin Utilities.""" diff --git a/homeassistant/components/coautilities/manifest.json b/homeassistant/components/coautilities/manifest.json new file mode 100644 index 00000000000..be213d03587 --- /dev/null +++ b/homeassistant/components/coautilities/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "coautilities", + "name": "City of Austin Utilities", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 43bd3aa4c5d..9cd0ad8785b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -918,6 +918,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "coautilities": { + "name": "City of Austin Utilities", + "integration_type": "virtual", + "supported_by": "opower" + }, "coinbase": { "name": "Coinbase", "integration_type": "hub", From 6de8304256e52370bf0589954d99206583866d6e Mon Sep 17 00:00:00 2001 From: Tomer Shemesh Date: Sun, 28 Jan 2024 14:18:28 -0500 Subject: [PATCH 1084/1544] Update pylutron-caseta to 0.19.0 (#108987) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index ff2831950c6..e549e37d59d 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.18.3"], + "requirements": ["pylutron-caseta==0.19.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b344cb2934f..ee7d531cbad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1919,7 +1919,7 @@ pylitejet==0.6.2 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.3 +pylutron-caseta==0.19.0 # homeassistant.components.lutron pylutron==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4250ec7d85d..9e241e3fbf0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1473,7 +1473,7 @@ pylitejet==0.6.2 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.3 +pylutron-caseta==0.19.0 # homeassistant.components.lutron pylutron==0.2.8 From 7667024a2fdb0479dfd4ff2984dd6f74b6952e57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Jan 2024 09:23:07 -1000 Subject: [PATCH 1085/1544] Remove extra confirmation step in tplink authenticated discovery flow (#109016) Remove extra confirmation step in tplink discovery flow After discovery, and manually entering credentials, we would ask the user if they still wanted to set up the device. Instead we now set create the config entry as soon as they enter correct credentials as its clear that they want to proceed. --- .../components/tplink/config_flow.py | 2 +- tests/components/tplink/test_config_flow.py | 39 ++++--------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 96d720e59a0..e1e51f19e3a 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -159,7 +159,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._discovered_device = device await set_credentials(self.hass, username, password) self.hass.async_create_task(self._async_reload_requires_auth_entries()) - return await self.async_step_discovery_confirm() + return self._async_create_entry_from_device(self._discovered_device) placeholders = self._async_make_placeholders_from_discovery() self.context["title_placeholders"] = placeholders diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 18e22db60f4..f5b0ba6c41f 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -141,18 +141,9 @@ async def test_discovery_auth( }, ) - assert result2["type"] == "form" - assert result2["step_id"] == "discovery_confirm" - assert not result2["errors"] - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={} - ) - - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == CREATE_ENTRY_DATA_AUTH + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_ENTRY_TITLE + assert result2["data"] == CREATE_ENTRY_DATA_AUTH @pytest.mark.parametrize( @@ -213,17 +204,8 @@ async def test_discovery_auth_errors( CONF_PASSWORD: "fake_password", }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "discovery_confirm" - - await hass.async_block_till_done() - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {}, - ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == CREATE_ENTRY_DATA_AUTH + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH async def test_discovery_new_credentials( @@ -325,15 +307,8 @@ async def test_discovery_new_credentials_invalid( CONF_PASSWORD: "fake_password", }, ) - assert result3["type"] is FlowResultType.FORM - assert result3["step_id"] == "discovery_confirm" - - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {}, - ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == CREATE_ENTRY_DATA_AUTH + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["data"] == CREATE_ENTRY_DATA_AUTH async def test_discovery_with_existing_device_present(hass: HomeAssistant) -> None: From 7c59b0b43cb33b3a7f89f65254307153085f3c2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jan 2024 20:35:52 +0100 Subject: [PATCH 1086/1544] Bump dorny/paths-filter from 2.12.0 to 3.0.0 (#108894) 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 f2ccc256000..3ae6c14940b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -103,7 +103,7 @@ jobs: echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v2.12.0 + uses: dorny/paths-filter@v3.0.0 id: core with: filters: .core_files.yaml @@ -118,7 +118,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v2.12.0 + uses: dorny/paths-filter@v3.0.0 id: integrations with: filters: .integration_paths.yaml From a01e73a5752232cbbd4171423f583921c849131e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 Jan 2024 20:40:21 +0100 Subject: [PATCH 1087/1544] Add translation placeholder to Hue (#108848) --- homeassistant/components/hue/event.py | 9 +++------ homeassistant/components/hue/strings.json | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index da59515e7be..183d2bfb3ae 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -86,12 +86,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): ): event_types.append(event_type.value) self._attr_event_types = event_types - - @property - def name(self) -> str: - """Return name for the entity.""" - # this can be translated too as soon as we support arguments into translations ? - return f"Button {self.resource.metadata.control_id}" + self._attr_translation_placeholders = { + "button_id": self.resource.metadata.control_id + } @callback def _handle_event(self, event_type: EventType, resource: Button) -> None: diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 114f501d7a3..ab1d0fb58ad 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -76,6 +76,7 @@ "entity": { "event": { "button": { + "name": "Button {button_id}", "state_attributes": { "event_type": { "state": { From b28e8a3cf09fe5b96abf7192829759bb3a8e2ee9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 29 Jan 2024 06:04:44 +1000 Subject: [PATCH 1088/1544] Add tests to Teslemetry (#108884) * Add tests * Add partial sleep test * Remove useless AsyncMock * Review feedback * Patch imports * Fix mock_test --- .coveragerc | 5 - .../components/teslemetry/__init__.py | 4 +- .../components/teslemetry/context.py | 2 +- tests/components/teslemetry/__init__.py | 49 ++++ tests/components/teslemetry/conftest.py | 47 +++ tests/components/teslemetry/const.py | 11 + .../teslemetry/fixtures/products.json | 99 +++++++ .../teslemetry/fixtures/vehicle_data.json | 269 ++++++++++++++++++ .../teslemetry/snapshots/test_climate.ambr | 73 +++++ tests/components/teslemetry/test_climate.py | 131 +++++++++ .../components/teslemetry/test_config_flow.py | 19 +- tests/components/teslemetry/test_init.py | 118 ++++++++ 12 files changed, 808 insertions(+), 19 deletions(-) create mode 100644 tests/components/teslemetry/conftest.py create mode 100644 tests/components/teslemetry/fixtures/products.json create mode 100644 tests/components/teslemetry/fixtures/vehicle_data.json create mode 100644 tests/components/teslemetry/snapshots/test_climate.ambr create mode 100644 tests/components/teslemetry/test_climate.py create mode 100644 tests/components/teslemetry/test_init.py diff --git a/.coveragerc b/.coveragerc index c05cab27f79..e20b26ff182 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1371,11 +1371,6 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/teslemetry/__init__.py - homeassistant/components/teslemetry/climate.py - homeassistant/components/teslemetry/coordinator.py - homeassistant/components/teslemetry/entity.py - homeassistant/components/teslemetry/context.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 0c2a16fa15b..fb74e905181 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -2,7 +2,7 @@ import asyncio from typing import Final -from tesla_fleet_api import Teslemetry +from tesla_fleet_api import Teslemetry, VehicleSpecific from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue vin = product["vin"] - api = teslemetry.vehicle.specific(vin) + api = VehicleSpecific(teslemetry.vehicle, vin) coordinator = TeslemetryVehicleDataCoordinator(hass, api) data.append( TeslemetryVehicleData( diff --git a/homeassistant/components/teslemetry/context.py b/homeassistant/components/teslemetry/context.py index c2c9317f671..942f1ccdd4b 100644 --- a/homeassistant/components/teslemetry/context.py +++ b/homeassistant/components/teslemetry/context.py @@ -13,4 +13,4 @@ def handle_command(): try: yield except TeslaFleetError as e: - raise HomeAssistantError from e + raise HomeAssistantError("Teslemetry command failed") from e diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index 422a2ecaac9..eae58127d1d 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -1 +1,50 @@ """Tests for the Teslemetry integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONFIG + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): + """Set up the Teslemetry platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + ) + mock_entry.add_to_hass(hass) + + if platforms is None: + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry + + +def assert_entities( + hass: HomeAssistant, + entry_id: str, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py new file mode 100644 index 00000000000..0fc279eaa21 --- /dev/null +++ b/tests/components/teslemetry/conftest.py @@ -0,0 +1,47 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .const import PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE + + +@pytest.fixture(autouse=True) +def mock_products(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + ) as mock_products: + yield mock_products + + +@pytest.fixture(autouse=True) +def mock_vehicle_data(): + """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + return_value=VEHICLE_DATA, + ) as mock_vehicle_data: + yield mock_vehicle_data + + +@pytest.fixture(autouse=True) +def mock_wake_up(): + """Mock Tesla Fleet API Vehicle Specific wake_up method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + return_value=WAKE_UP_ONLINE, + ) as mock_wake_up: + yield mock_wake_up + + +@pytest.fixture(autouse=True) +def mock_request(): + """Mock Tesla Fleet API Vehicle Specific class.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry._request", + return_value=RESPONSE_OK, + ) as mock_request: + yield mock_request diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 527ef98efca..0feb056fa72 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -1,5 +1,16 @@ """Constants for the teslemetry tests.""" +from homeassistant.components.teslemetry.const import DOMAIN, TeslemetryState from homeassistant.const import CONF_ACCESS_TOKEN +from tests.common import load_json_object_fixture + CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} + +WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} +WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} + +PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) + +RESPONSE_OK = {"response": {}, "error": None} diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json new file mode 100644 index 00000000000..430c3b39dc8 --- /dev/null +++ b/tests/components/teslemetry/fixtures/products.json @@ -0,0 +1,99 @@ +{ + "response": [ + { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "display_name": "Test", + "option_codes": null, + "cached_data": null, + "granular_access": { "hide_private": false }, + "tokens": ["abc", "def"], + "state": "asleep", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": true, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1705701487912, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "command_signing": "allowed", + "release_notes_supported": true + }, + { + "energy_site_id": 2345, + "resource_type": "wall_connector", + "id": "ID1234", + "asset_site_id": "abcdef", + "warp_site_number": "ID1234", + "go_off_grid_test_banner_enabled": null, + "storm_mode_enabled": null, + "powerwall_onboarding_settings_set": null, + "powerwall_tesla_electric_interested_in": null, + "vpp_tour_enabled": null, + "sync_grid_alert_enabled": false, + "breaker_alert_enabled": false, + "components": { + "battery": false, + "solar": false, + "grid": false, + "load_meter": false, + "wall_connectors": [ + { "device_id": "abcdef", "din": "12345", "is_active": true } + ] + }, + "features": {} + } + ], + "count": 2 +} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json new file mode 100644 index 00000000000..44556c1c8df --- /dev/null +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -0,0 +1,269 @@ +{ + "response": { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["abc", "def"], + "state": "online", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 77, + "battery_range": 266.87, + "charge_amps": 16, + "charge_current_request": 16, + "charge_current_request_max": 16, + "charge_enable_request": true, + "charge_energy_added": 0, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 0, + "charge_miles_added_rated": 0, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 0, + "charger_actual_current": 0, + "charger_phases": null, + "charger_pilot_current": 16, + "charger_power": 0, + "charger_voltage": 2, + "charging_state": "Stopped", + "conn_charge_cable": "IEC", + "est_battery_range": 275.04, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 266.87, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 0, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "Off", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": null, + "scheduled_charging_start_time_app": 600, + "scheduled_departure_time": 1704837600, + "scheduled_departure_time_minutes": 480, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0, + "timestamp": 1705707520649, + "trip_charging": false, + "usable_battery_level": 77, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": false, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": false, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 29.8, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 251, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30, + "passenger_temp_setting": 22, + "remote_heater_control_enabled": false, + "right_temp_direction": 251, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1705707520649, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": -27.855946, + "active_route_longitude": 153.345056, + "active_route_traffic_minutes_delay": 0, + "power": 0, + "shift_state": null, + "speed": null, + "timestamp": 1705707520649 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1705707520649 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": true, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1705707520649, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 71, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.44.30.8 06f534d46010", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,187f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": false, + "media_info": { + "audio_volume": 2.6667, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": true + }, + "notifications_supported": true, + "odometer": 6481.019282, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 69, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1705707520649, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1705700812, + "tpms_last_seen_pressure_time_fr": 1705700793, + "tpms_last_seen_pressure_time_rl": 1705700794, + "tpms_last_seen_pressure_time_rr": 1705700823, + "tpms_pressure_fl": 2.775, + "tpms_pressure_fr": 2.8, + "tpms_pressure_rl": 2.775, + "tpms_pressure_rr": 2.775, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + } + } +} diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f0f6f1b0140 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'VINVINVIN-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py new file mode 100644 index 00000000000..ede38a695e2 --- /dev/null +++ b/tests/components/teslemetry/test_climate.py @@ -0,0 +1,131 @@ +"""Test the Teslemetry climate platform.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform + +from tests.common import async_fire_time_changed + + +async def test_climate( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the climate entity is correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL + + # Set Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 + + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "keep"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "keep" + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + +async def test_errors( + hass: HomeAssistant, +) -> None: + """Tests service error is handled.""" + + await setup_platform(hass, platforms=[Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + side_effect=InvalidCommand, + ) as mock_on, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + assert error.from_exception == InvalidCommand + + +async def test_asleep_or_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Tests asleep is handled.""" + + await setup_platform(hass, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + mock_vehicle_data.assert_called_once() + + # Put the vehicle alseep + mock_vehicle_data.reset_mock() + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + # Run a command that will wake up the vehicle, but not immediately + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True + ) + await hass.async_block_till_done() diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index ca9b89bedb3..b89967bfa35 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Teslemetry config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from aiohttp import ClientConnectionError import pytest @@ -16,13 +16,12 @@ from .const import CONFIG @pytest.fixture(autouse=True) -def teslemetry_config_entry_mock(): +def mock_test(): """Mock Teslemetry api class.""" with patch( - "homeassistant.components.teslemetry.config_flow.Teslemetry", - ) as teslemetry_config_entry_mock: - teslemetry_config_entry_mock.return_value.test = AsyncMock() - yield teslemetry_config_entry_mock + "homeassistant.components.teslemetry.Teslemetry.test", return_value=True + ) as mock_test: + yield mock_test async def test_form( @@ -60,16 +59,14 @@ async def test_form( (TeslaFleetError, {"base": "unknown"}), ], ) -async def test_form_errors( - hass: HomeAssistant, side_effect, error, teslemetry_config_entry_mock -) -> None: +async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) -> None: """Test errors are handled.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - teslemetry_config_entry_mock.return_value.test.side_effect = side_effect + mock_test.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], CONFIG, @@ -79,7 +76,7 @@ async def test_form_errors( assert result2["errors"] == error # Complete the flow - teslemetry_config_entry_mock.return_value.test.side_effect = None + mock_test.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], CONFIG, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py new file mode 100644 index 00000000000..28440094bec --- /dev/null +++ b/tests/components/teslemetry/test_init.py @@ -0,0 +1,118 @@ +"""Test the Tessie init.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from tesla_fleet_api.exceptions import ( + InvalidToken, + PaymentRequired, + TeslaFleetError, + VehicleOffline, +) + +from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform +from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE + +from tests.common import async_fire_time_changed + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an authentication error.""" + + mock_products.side_effect = InvalidToken + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_subscription_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = PaymentRequired + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_other_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +# Coordinator + + +async def test_first_refresh( + hass: HomeAssistant, + mock_wake_up, + mock_vehicle_data, + mock_products, + freezer: FrozenDateTimeFactory, +) -> None: + """Test first coordinator refresh but vehicle is asleep.""" + + # Mock vehicle is asleep + mock_wake_up.return_value = WAKE_UP_ASLEEP + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + mock_wake_up.assert_called_once() + + # Reset mock and set vehicle to online + mock_wake_up.reset_mock() + mock_wake_up.return_value = WAKE_UP_ONLINE + + # Wait for the retry + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify we have loaded + assert entry.state is ConfigEntryState.LOADED + mock_wake_up.assert_called_once() + mock_vehicle_data.assert_called_once() + + +async def test_first_refresh_error(hass: HomeAssistant, mock_wake_up) -> None: + """Test first coordinator refresh with an error.""" + mock_wake_up.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_refresh_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Test coordinator refresh with an error.""" + entry = await setup_platform(hass, [Platform.CLIMATE]) + assert entry.state is ConfigEntryState.LOADED + mock_vehicle_data.assert_called_once() + mock_vehicle_data.reset_mock() + + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + +async def test_refresh_error(hass: HomeAssistant, mock_vehicle_data) -> None: + """Test coordinator refresh with an error.""" + mock_vehicle_data.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY From b54e282801159762a99281bbcffd246e1206a974 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Jan 2024 10:07:12 -1000 Subject: [PATCH 1089/1544] Remove follow symlinks support from CachingStaticResource (#109015) --- homeassistant/components/http/static.py | 9 ++++----- tests/components/http/test_static.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 7fe359d6486..e6e773d4c0c 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -19,10 +19,10 @@ from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER} -PATH_CACHE: LRU[tuple[str, Path, bool], tuple[Path | None, str | None]] = LRU(512) +PATH_CACHE: LRU[tuple[str, Path], tuple[Path | None, str | None]] = LRU(512) -def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: +def _get_file_path(rel_url: str, directory: Path) -> Path | None: """Return the path to file on disk or None.""" filename = Path(rel_url) if filename.anchor: @@ -31,8 +31,7 @@ def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path # where the static dir is totally different raise HTTPForbidden filepath: Path = directory.joinpath(filename).resolve() - if not follow_symlinks: - filepath.relative_to(directory) + filepath.relative_to(directory) # on opening a dir, load its contents if allowed if filepath.is_dir(): return None @@ -47,7 +46,7 @@ class CachingStaticResource(StaticResource): async def _handle(self, request: Request) -> StreamResponse: """Return requested file from disk as a FileResponse.""" rel_url = request.match_info["filename"] - key = (rel_url, self._directory, self._follow_symlinks) + key = (rel_url, self._directory) if (filepath_content_type := PATH_CACHE.get(key)) is None: hass: HomeAssistant = request.app[KEY_HASS] try: diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py index 1d711464966..b11d54defd6 100644 --- a/tests/components/http/test_static.py +++ b/tests/components/http/test_static.py @@ -58,4 +58,4 @@ async def test_static_path_blocks_anchors( # it gets here but we want to make sure if aiohttp ever # changes we still block it. with pytest.raises(HTTPForbidden): - _get_file_path(canonical_url, tmp_path, False) + _get_file_path(canonical_url, tmp_path) From e13a34df0f92dd84c7808a0100cdf9f0360d0c9a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 28 Jan 2024 22:43:22 +0100 Subject: [PATCH 1090/1544] Separate fixture in Sensibo (#109000) --- tests/components/sensibo/conftest.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/components/sensibo/conftest.py b/tests/components/sensibo/conftest.py index d455e1bb1f7..17c295b4c48 100644 --- a/tests/components/sensibo/conftest.py +++ b/tests/components/sensibo/conftest.py @@ -63,9 +63,13 @@ async def get_data_from_library( @pytest.fixture(name="load_json") -def load_json_from_fixture() -> SensiboData: +def load_json_from_fixture(load_data: str) -> SensiboData: """Load fixture with json data and return.""" - - data_fixture = load_fixture("data.json", "sensibo") - json_data: dict[str, Any] = json.loads(data_fixture) + json_data: dict[str, Any] = json.loads(load_data) return json_data + + +@pytest.fixture(name="load_data", scope="session") +def load_data_from_fixture() -> str: + """Load fixture with fixture data and return.""" + return load_fixture("data.json", "sensibo") From 2b33feb34158d1d87a97bc1f77ebf2d9656b07ee Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sun, 28 Jan 2024 22:46:47 +0100 Subject: [PATCH 1091/1544] Add phase entities to Enphase Envoy (#108725) * add phase entities to Enphase Envoy * Implement review feedback for translation strings * Enphase Envoy multiphase review changes Move device name logic to separate function. Refactor native value for phases Use dataclasses.replace for phase entities, add on-phase to base class as well, no need for phase entity descriptions anymore * Enphase Envoy reviewe feedback Move model determination to library. Revert states test for future split to sensor test. * Enphase_Envoy use model description from pyenphase library * Enphase_Envoy refactor Phase Sensors * Enphase_Envoy use walrus in phase sensor --------- Co-authored-by: J. Nick Koston --- .../components/enphase_envoy/sensor.py | 107 +++++++++++++++++- .../components/enphase_envoy/strings.json | 24 ++++ tests/components/enphase_envoy/conftest.py | 54 +++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2ae9dca63ba..c2ecf8e8a13 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import datetime import logging +from typing import TYPE_CHECKING from pyenphase import ( EnvoyEncharge, @@ -15,6 +16,7 @@ from pyenphase import ( EnvoySystemConsumption, EnvoySystemProduction, ) +from pyenphase.const import PHASENAMES, PhaseNames from homeassistant.components.sensor import ( SensorDeviceClass, @@ -85,6 +87,7 @@ class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -104,6 +107,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda production: production.watts_now, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="daily_production", @@ -114,6 +118,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda production: production.watt_hours_today, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="seven_days_production", @@ -123,6 +128,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda production: production.watt_hours_last_7_days, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="lifetime_production", @@ -133,15 +139,32 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda production: production.watt_hours_lifetime, + on_phase=None, ), ) +PRODUCTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(PRODUCTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -161,6 +184,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda consumption: consumption.watts_now, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="daily_consumption", @@ -171,6 +195,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda consumption: consumption.watt_hours_today, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="seven_days_consumption", @@ -180,6 +205,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda consumption: consumption.watt_hours_last_7_days, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="lifetime_consumption", @@ -190,10 +216,26 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda consumption: consumption.watt_hours_lifetime, + on_phase=None, ), ) +CONSUMPTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(CONSUMPTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" @@ -361,6 +403,23 @@ async def async_setup_entry( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) + # For each production phase reported add production entities + if envoy_data.system_production_phases: + entities.extend( + EnvoyProductionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_production_phases.items() + for description in PRODUCTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + # For each consumption phase reported add consumption entities + if envoy_data.system_consumption_phases: + entities.extend( + EnvoyConsumptionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_consumption_phases.items() + for description in CONSUMPTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + if envoy_data.inverters: entities.extend( EnvoyInverterEntity(coordinator, description, inverter) @@ -414,9 +473,11 @@ class EnvoySystemSensorEntity(EnvoySensorBaseEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.envoy_serial_num)}, manufacturer="Enphase", - model=coordinator.envoy.part_number or "Envoy", + model=coordinator.envoy.envoy_model, name=coordinator.name, sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, ) @@ -446,6 +507,48 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_consumption) +class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase production entity.""" + + entity_description: EnvoyProductionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_production_phases + + if ( + system_production := self.data.system_production_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_production) + + +class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_consumption_phases + + if ( + system_consumption := self.data.system_consumption_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_consumption) + + class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index fe32002e6b2..f3e78432f90 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -119,6 +119,30 @@ "lifetime_consumption": { "name": "Lifetime energy consumption" }, + "current_power_production_phase": { + "name": "Current power production {phase_name}" + }, + "daily_production_phase": { + "name": "Energy production today {phase_name}" + }, + "seven_days_production_phase": { + "name": "Energy production last seven days {phase_name}" + }, + "lifetime_production_phase": { + "name": "Lifetime energy production {phase_name}" + }, + "current_power_consumption_phase": { + "name": "Current power consumption {phase_name}" + }, + "daily_consumption_phase": { + "name": "Energy consumption today {phase_name}" + }, + "seven_days_consumption_phase": { + "name": "Energy consumption last seven days {phase_name}" + }, + "lifetime_consumption_phase": { + "name": "Lifetime energy consumption {phase_name}" + }, "reserve_soc": { "name": "Reserve battery level" }, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 185f65aa892..ed0a60dfe94 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ from pyenphase import ( EnvoySystemProduction, EnvoyTokenAuth, ) +from pyenphase.const import PhaseNames, SupportedFeatures +from pyenphase.models.meters import CtType, EnvoyPhaseMode import pytest from homeassistant.components.enphase_envoy import DOMAIN @@ -53,6 +55,18 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth + mock_envoy.supported_features = SupportedFeatures( + SupportedFeatures.INVERTERS + | SupportedFeatures.PRODUCTION + | SupportedFeatures.PRODUCTION + | SupportedFeatures.METERING + | SupportedFeatures.THREEPHASE + ) + mock_envoy.phase_mode = EnvoyPhaseMode.THREE + mock_envoy.phase_count = 3 + mock_envoy.active_phase_count = 3 + mock_envoy.ct_meter_count = 2 + mock_envoy.consumption_meter_type = CtType.NET_CONSUMPTION mock_envoy.data = EnvoyData( system_consumption=EnvoySystemConsumption( watt_hours_last_7_days=1234, @@ -66,6 +80,46 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): watt_hours_today=1234, watts_now=1234, ), + system_consumption_phases={ + PhaseNames.PHASE_1: EnvoySystemConsumption( + watt_hours_last_7_days=1321, + watt_hours_lifetime=1322, + watt_hours_today=1323, + watts_now=1324, + ), + PhaseNames.PHASE_2: EnvoySystemConsumption( + watt_hours_last_7_days=2321, + watt_hours_lifetime=2322, + watt_hours_today=2323, + watts_now=2324, + ), + PhaseNames.PHASE_3: EnvoySystemConsumption( + watt_hours_last_7_days=3321, + watt_hours_lifetime=3322, + watt_hours_today=3323, + watts_now=3324, + ), + }, + system_production_phases={ + PhaseNames.PHASE_1: EnvoySystemProduction( + watt_hours_last_7_days=1231, + watt_hours_lifetime=1232, + watt_hours_today=1233, + watts_now=1234, + ), + PhaseNames.PHASE_2: EnvoySystemProduction( + watt_hours_last_7_days=2231, + watt_hours_lifetime=2232, + watt_hours_today=2233, + watts_now=2234, + ), + PhaseNames.PHASE_3: EnvoySystemProduction( + watt_hours_last_7_days=3231, + watt_hours_lifetime=3232, + watt_hours_today=3233, + watts_now=3234, + ), + }, inverters={ "1": EnvoyInverter( serial_number="1", From b50f7041a339049c26879bbde4c7a742b1ef5e35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Jan 2024 07:10:17 +0100 Subject: [PATCH 1092/1544] Bump pytest-asyncio to 0.23.4 (#109027) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 92a0a6e8ba0..893860834d4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pydantic==1.10.12 pylint==3.0.3 pylint-per-file-ignores==1.2.1 pipdeptree==2.13.2 -pytest-asyncio==0.21.0 +pytest-asyncio==0.23.4 pytest-aiohttp==1.0.5 pytest-cov==4.1.0 pytest-freezer==0.4.8 From 57622acabfd563d559de64a16309502a3ad42673 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Jan 2024 08:42:24 +0100 Subject: [PATCH 1093/1544] Bump python-homewizard-energy to v4.2.2 (#109038) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index e2aaceccf2d..9a1fc9c1a1d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==4.2.1"], + "requirements": ["python-homewizard-energy==4.2.2"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ee7d531cbad..fd3cca21d83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2211,7 +2211,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.2.1 +python-homewizard-energy==4.2.2 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e241e3fbf0..f0f282cfcf1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1690,7 +1690,7 @@ python-fullykiosk==0.0.12 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.2.1 +python-homewizard-energy==4.2.2 # homeassistant.components.izone python-izone==1.2.9 From 95aea1488d9d280be6050c776c4a11aa506845ea Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 29 Jan 2024 10:30:19 +0100 Subject: [PATCH 1094/1544] Add pylint plugin to check if coordinator is placed in its own module (#108174) * Add pylint plugin to check if coordinator is placed in its own module * Remove unintended changes * Remove pylint disable and let CI only fail on W,E,F * Make check conventional * Apply review suggestion Co-authored-by: Martin Hjelmare * Use option instead * Remove pylint arguments from pre-commit * Partially revert "Remove pylint disable and let CI only fail on W,E,F" --------- Co-authored-by: Martin Hjelmare --- .github/workflows/ci.yaml | 4 +- .../components/accuweather/__init__.py | 2 +- .../aurora_abb_powerone/__init__.py | 2 +- homeassistant/components/brother/__init__.py | 2 +- homeassistant/components/elmax/common.py | 2 +- .../components/environment_canada/__init__.py | 2 +- homeassistant/components/esphome/dashboard.py | 2 +- .../components/evil_genius_labs/__init__.py | 2 +- homeassistant/components/flo/device.py | 2 +- homeassistant/components/fritz/common.py | 4 +- homeassistant/components/gios/__init__.py | 2 +- homeassistant/components/gogogate2/common.py | 2 +- homeassistant/components/google/calendar.py | 4 +- homeassistant/components/gree/bridge.py | 2 +- homeassistant/components/hassio/__init__.py | 2 +- .../homeassistant_alerts/__init__.py | 2 +- homeassistant/components/ialarm/__init__.py | 2 +- .../components/idasen_desk/__init__.py | 2 +- .../components/kostal_plenticore/helper.py | 10 +- homeassistant/components/melnor/models.py | 4 +- homeassistant/components/mikrotik/hub.py | 2 +- homeassistant/components/mill/__init__.py | 2 +- .../components/modern_forms/__init__.py | 2 +- .../components/moehlenhoff_alpha2/__init__.py | 2 +- homeassistant/components/nam/__init__.py | 2 +- homeassistant/components/nextdns/__init__.py | 16 +-- homeassistant/components/nuki/__init__.py | 2 +- homeassistant/components/nws/__init__.py | 2 +- homeassistant/components/omnilogic/common.py | 2 +- .../components/opengarage/__init__.py | 2 +- .../weather_update_coordinator.py | 2 +- .../components/p1_monitor/__init__.py | 2 +- .../components/philips_js/__init__.py | 2 +- homeassistant/components/plaato/__init__.py | 2 +- .../components/prusalink/__init__.py | 10 +- .../components/pure_energie/__init__.py | 2 +- .../pvpc_hourly_pricing/__init__.py | 2 +- .../components/rainforest_eagle/data.py | 2 +- homeassistant/components/rainmachine/util.py | 2 +- homeassistant/components/risco/__init__.py | 4 +- .../components/sharkiq/update_coordinator.py | 2 +- .../components/surepetcare/__init__.py | 2 +- .../components/switcher_kis/__init__.py | 2 +- homeassistant/components/tibber/sensor.py | 4 +- homeassistant/components/tolo/__init__.py | 4 +- .../components/tomorrowio/__init__.py | 2 +- .../components/totalconnect/__init__.py | 2 +- .../components/tplink_omada/controller.py | 4 +- .../components/tplink_omada/update.py | 2 +- .../components/ukraine_alarm/__init__.py | 2 +- homeassistant/components/upcloud/__init__.py | 2 +- homeassistant/components/vallox/__init__.py | 2 +- homeassistant/components/venstar/__init__.py | 2 +- homeassistant/components/vizio/__init__.py | 2 +- .../components/volvooncall/__init__.py | 2 +- homeassistant/components/wemo/wemo_device.py | 2 +- homeassistant/components/xbox/__init__.py | 2 +- .../components/yamaha_musiccast/__init__.py | 2 +- .../hass_enforce_coordinator_module.py | 54 +++++++ pyproject.toml | 1 + tests/pylint/conftest.py | 21 +++ .../pylint/test_enforce_coordinator_module.py | 133 ++++++++++++++++++ 62 files changed, 290 insertions(+), 81 deletions(-) create mode 100644 pylint/plugins/hass_enforce_coordinator_module.py create mode 100644 tests/pylint/test_enforce_coordinator_module.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ae6c14940b..0b380400031 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -597,14 +597,14 @@ jobs: run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant + pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant - name: Run pylint (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} + pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} mypy: name: Check mypy diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index e98b19e8e82..dfbf5119981 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -75,7 +75,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching AccuWeather data API.""" def __init__( diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 39abba4ada5..c7400f31727 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -52,7 +52,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): +class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching AuroraAbbPowerone data.""" def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 27ac97a27dc..32fee44de99 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -62,7 +62,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): +class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Brother data from the printer.""" def __init__(self, hass: HomeAssistant, brother: Brother) -> None: diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 440344fb839..7cbc6f63596 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -34,7 +34,7 @@ from .const import DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) -class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): +class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=hass-enforce-coordinator-module """Coordinator helper to handle Elmax API polling.""" def __init__( diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 14fb3e8e54c..925bc42a930 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -99,7 +99,7 @@ def device_info(config_entry: ConfigEntry) -> DeviceInfo: ) -class ECDataUpdateCoordinator(DataUpdateCoordinator): +class ECDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching EC data.""" def __init__(self, hass, ec_data, name, update_interval): diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 3d7bfef6ddb..03264291d8f 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -158,7 +158,7 @@ async def async_set_dashboard_info( await manager.async_set_dashboard_info(addon_slug, host, port) -class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): +class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): # pylint: disable=hass-enforce-coordinator-module """Class to interact with the ESPHome dashboard.""" def __init__( diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 3d65a5516c7..44d46d27a9d 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -50,7 +50,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): +class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module """Update coordinator for Evil Genius data.""" info: dict diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index bcc52f512a1..7aacb1b262a 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -15,7 +15,7 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER -class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): +class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Flo device object.""" def __init__( diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 55bf7279ede..c9acd60b23c 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -179,7 +179,7 @@ class UpdateCoordinatorDataType(TypedDict): class FritzBoxTools( update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] -): +): # pylint: disable=hass-enforce-coordinator-module """FritzBoxTools class.""" def __init__( @@ -757,7 +757,7 @@ class FritzBoxTools( raise HomeAssistantError("Service not supported") from ex -class AvmWrapper(FritzBoxTools): +class AvmWrapper(FritzBoxTools): # pylint: disable=hass-enforce-coordinator-module """Setup AVM wrapper for API calls.""" async def _async_service_call( diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 3cdf48944fd..88c505fc4ae 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -74,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold GIOS data.""" def __init__( diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index ba1426e1201..093c93699ff 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -47,7 +47,7 @@ class StateData(NamedTuple): class DeviceDataUpdateCoordinator( DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] -): +): # pylint: disable=hass-enforce-coordinator-module """Manages polling for state changes from the device.""" def __init__( diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 88f59ff44f7..eb77eb27106 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -265,7 +265,7 @@ def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: ) -class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): +class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for calendar RPC calls that use an efficient sync.""" config_entry: ConfigEntry @@ -320,7 +320,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): return None -class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): +class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for calendar RPC calls. This sends a polling RPC, not using sync, as a workaround diff --git a/homeassistant/components/gree/bridge.py b/homeassistant/components/gree/bridge.py index 6628f7fc32c..ebd5e78a820 100644 --- a/homeassistant/components/gree/bridge.py +++ b/homeassistant/components/gree/bridge.py @@ -23,7 +23,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class DeviceDataUpdateCoordinator(DataUpdateCoordinator): +class DeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Manages polling for state changes from the device.""" def __init__(self, hass: HomeAssistant, device: Device) -> None: diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index a8e6419a43e..87860644754 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -746,7 +746,7 @@ def async_remove_addons_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioDataUpdateCoordinator(DataUpdateCoordinator): +class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to retrieve Hass.io status.""" def __init__( diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 8241c171265..036eb07e067 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -124,7 +124,7 @@ class IntegrationAlert: return f"{self.filename}_{self.integration}" -class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): +class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): # pylint: disable=hass-enforce-coordinator-module """Data fetcher for HA Alerts.""" def __init__(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index b2c1800914e..1b821025953 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -53,7 +53,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): +class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching iAlarm data.""" def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 5e112aa39f7..c3e5f3de429 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): +class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage updates for the Idasen Desk.""" def __init__( diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index adb1bfb6f09..c3228e1d449 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -158,7 +158,7 @@ class DataUpdateCoordinatorMixin: return True -class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module """Base implementation of DataUpdateCoordinator for Plenticore data.""" def __init__( @@ -198,7 +198,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for process data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: @@ -222,7 +222,7 @@ class ProcessDataUpdateCoordinator( class SettingDataUpdateCoordinator( PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], DataUpdateCoordinatorMixin, -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for settings data.""" async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: @@ -237,7 +237,7 @@ class SettingDataUpdateCoordinator( return fetched_data -class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module """Base implementation of DataUpdateCoordinator for Plenticore data.""" def __init__( @@ -284,7 +284,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): class SelectDataUpdateCoordinator( PlenticoreSelectUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin, -): +): # pylint: disable=hass-enforce-coordinator-module """Implementation of PlenticoreUpdateCoordinator for select data.""" async def _async_update_data(self) -> dict[str, dict[str, str]]: diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index 409cb9ae3ba..beb8b42a4a3 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -20,7 +20,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): +class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): # pylint: disable=hass-enforce-coordinator-module """Melnor data update coordinator.""" _device: Device @@ -42,7 +42,7 @@ class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): return self._device -class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): +class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """Base class for melnor entities.""" _device: Device diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index d03e46a1d0b..44d60d5dcb4 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -243,7 +243,7 @@ class MikrotikData: return [] -class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): +class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Mikrotik Hub Object.""" def __init__( diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 0482e573766..136c4a2940f 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -class MillDataUpdateCoordinator(DataUpdateCoordinator): +class MillDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Mill data.""" def __init__( diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index 3401d961bc8..5997b2aa846 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -98,7 +98,7 @@ def modernforms_exception_handler( return handler -class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): +class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Modern Forms data from single endpoint.""" def __init__( diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index fa5db2d0e81..4a4c57b676e 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -52,7 +52,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): +class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): # pylint: disable=hass-enforce-coordinator-module """Keep the base instance in one place and centralize the update.""" def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d5881f52d8d..28f9c282a73 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -90,7 +90,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): +class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Nettigo Air Monitor data.""" def __init__( diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 011b487910f..ca59c7d0e3a 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -47,7 +47,7 @@ from .const import ( CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) -class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): +class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS data API.""" def __init__( @@ -84,7 +84,7 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): raise NotImplementedError("Update method not implemented") -class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): +class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics status data from API.""" async def _async_update_data_internal(self) -> AnalyticsStatus: @@ -92,7 +92,7 @@ class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): return await self.nextdns.get_analytics_status(self.profile_id) -class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): +class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics Dnssec data from API.""" async def _async_update_data_internal(self) -> AnalyticsDnssec: @@ -100,7 +100,7 @@ class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): return await self.nextdns.get_analytics_dnssec(self.profile_id) -class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): +class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics encryption data from API.""" async def _async_update_data_internal(self) -> AnalyticsEncryption: @@ -108,7 +108,7 @@ class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncry return await self.nextdns.get_analytics_encryption(self.profile_id) -class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): +class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics IP versions data from API.""" async def _async_update_data_internal(self) -> AnalyticsIpVersions: @@ -116,7 +116,7 @@ class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVer return await self.nextdns.get_analytics_ip_versions(self.profile_id) -class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): +class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS analytics protocols data from API.""" async def _async_update_data_internal(self) -> AnalyticsProtocols: @@ -124,7 +124,7 @@ class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtoc return await self.nextdns.get_analytics_protocols(self.profile_id) -class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): +class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS connection data from API.""" async def _async_update_data_internal(self) -> Settings: @@ -132,7 +132,7 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): return await self.nextdns.get_settings(self.profile_id) -class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): +class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching NextDNS connection data from API.""" async def _async_update_data_internal(self) -> ConnectionStatus: diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3f17c0b795b..42d95f85937 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -279,7 +279,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class NukiCoordinator(DataUpdateCoordinator[None]): +class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Data Update Coordinator for the Nuki integration.""" def __init__(self, hass, bridge, locks, openers): diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 063ecdabab2..da54f3b119e 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -45,7 +45,7 @@ class NWSData: coordinator_forecast_hourly: NwsDataUpdateCoordinator -class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): +class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """NWS data update coordinator. Implements faster data update intervals for failed updates and exposes a last successful update time. diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 4e64a219f77..3fbd53d20f2 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -20,7 +20,7 @@ from .const import ALL_ITEM_KINDS, DOMAIN _LOGGER = logging.getLogger(__name__) -class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): +class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching update data from single endpoint.""" def __init__( diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index b825cace83a..46d018ec1af 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -50,7 +50,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Opengarage data.""" def __init__( diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 56519c46fd9..05b24d60f79 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -60,7 +60,7 @@ _LOGGER = logging.getLogger(__name__) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) -class WeatherUpdateCoordinator(DataUpdateCoordinator): +class WeatherUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Weather data update coordinator.""" def __init__(self, owm, latitude, longitude, forecast_mode, hass): diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index e6178ffeb41..18c58525097 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -67,7 +67,7 @@ class P1MonitorData(TypedDict): watermeter: WaterMeter | None -class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching P1 Monitor data from single endpoint.""" config_entry: ConfigEntry diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3fce7f1fafd..c8540a187da 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -85,7 +85,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): +class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Coordinator to update data.""" config_entry: ConfigEntry diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index aeb1cea8e15..69c65383138 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -208,7 +208,7 @@ def _device_id(data): return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}" -class PlaatoCoordinator(DataUpdateCoordinator): +class PlaatoCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching data from the API.""" def __init__( diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index b6a00bbaf10..94cf21e13df 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -131,7 +131,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): # pylint: disable=hass-enforce-coordinator-module """Update coordinator for the printer.""" config_entry: ConfigEntry @@ -176,7 +176,7 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): return timedelta(seconds=30) -class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): # pylint: disable=hass-enforce-coordinator-module """Printer update coordinator.""" async def _fetch_data(self) -> PrinterStatus: @@ -184,7 +184,7 @@ class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): return await self.api.get_status() -class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): +class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): # pylint: disable=hass-enforce-coordinator-module """Printer legacy update coordinator.""" async def _fetch_data(self) -> LegacyPrinterStatus: @@ -192,7 +192,7 @@ class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): return await self.api.get_legacy_printer() -class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): +class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): # pylint: disable=hass-enforce-coordinator-module """Job update coordinator.""" async def _fetch_data(self) -> JobInfo: @@ -200,7 +200,7 @@ class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): return await self.api.get_job() -class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): +class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """Defines a base PrusaLink entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 4a64e5abb84..cda73a7da0b 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -47,7 +47,7 @@ class PureEnergieData(NamedTuple): smartbridge: SmartBridge -class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): +class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Pure Energie data from single eindpoint.""" config_entry: ConfigEntry diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 00a3a355477..af9154f5512 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -59,7 +59,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): +class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Electricity prices data from API.""" def __init__( diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index f050e92f783..9da6372086f 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -87,7 +87,7 @@ async def async_get_type(hass, cloud_id, install_code, host): return None, None -class EagleDataCoordinator(DataUpdateCoordinator): +class EagleDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Get the latest data from the Eagle device.""" eagle100_reader: Eagle100Reader | None = None diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 64917b6d721..dfb03b11b5d 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -84,7 +84,7 @@ def key_exists(data: dict[str, Any], search_key: str) -> bool: return False -class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): +class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module """Define an extended DataUpdateCoordinator.""" config_entry: ConfigEntry diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index c58721e4e28..d1e1c4f430c 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -197,7 +197,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): +class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching risco data.""" def __init__( @@ -221,7 +221,7 @@ class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): raise UpdateFailed(error) from error -class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): +class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching risco data.""" def __init__( diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 4cfbb033566..e1330b06c08 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL -class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): +class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): # pylint: disable=hass-enforce-coordinator-module """Define a wrapper class to update Shark IQ data.""" def __init__( diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 0ef47a488df..d4c337c4096 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -102,7 +102,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): +class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): # pylint: disable=hass-enforce-coordinator-module """Handle Surepetcare data.""" def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 051c5d2b72a..79ef201efee 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -125,7 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class SwitcherDataUpdateCoordinator( update_coordinator.DataUpdateCoordinator[SwitcherBase] -): +): # pylint: disable=hass-enforce-coordinator-module """Switcher device data update coordinator.""" def __init__( diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 2694ef50e3a..467cd2bfd77 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -498,7 +498,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) self.async_write_ha_state() -class TibberRtDataCoordinator(DataUpdateCoordinator): +class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Handle Tibber realtime data.""" def __init__( @@ -562,7 +562,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): return self.data.get("data", {}).get("liveMeasurement") -class TibberDataCoordinator(DataUpdateCoordinator[None]): +class TibberDataCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Handle Tibber data and insert statistics.""" config_entry: ConfigEntry diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index f0cf94bb825..2fc41fac3af 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -63,7 +63,7 @@ class ToloSaunaData(NamedTuple): settings: SettingsInfo -class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): +class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylint: disable=hass-enforce-coordinator-module """DataUpdateCoordinator for TOLO Sauna.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -92,7 +92,7 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): return ToloSaunaData(status, settings) -class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): +class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module """CoordinatorEntity for TOLO Sauna.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 25b814c106a..ea179219153 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -163,7 +163,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold Tomorrow.io data.""" def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 967cbfa7e73..e10858c6c12 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -78,7 +78,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: client.locations[location_id].auto_bypass_low_battery = bypass -class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): +class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to fetch data from TotalConnect.""" config_entry: ConfigEntry diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 194f18ae9bf..be9e875037e 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -15,7 +15,7 @@ POLL_SWITCH_PORT = 300 POLL_GATEWAY = 300 -class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about ports on a switch.""" def __init__( @@ -36,7 +36,7 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): return {p.port_id: p for p in ports} -class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): +class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about the site's gateway.""" def __init__( diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 1e653a53aae..a5f54071c4f 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -34,7 +34,7 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-coordinator-module """Coordinator for getting details about ports on a switch.""" def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index eb24e5d9a78..1132bd56b72 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -46,7 +46,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Ukraine Alarm API.""" def __init__( diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index a2554858fef..49ec97f073b 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -57,7 +57,7 @@ STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} class UpCloudDataUpdateCoordinator( DataUpdateCoordinator[dict[str, upcloud_api.Server]] -): +): # pylint: disable=hass-enforce-coordinator-module """UpCloud data update coordinator.""" def __init__( diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index c98e2685118..3808bfb1202 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -155,7 +155,7 @@ class ValloxState: return next_filter_change_date -class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): +class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): # pylint: disable=hass-enforce-coordinator-module """The DataUpdateCoordinator for Vallox.""" diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 1416bcf376a..78cb20b33cc 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -64,7 +64,7 @@ async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: return unload_ok -class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): +class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching Venstar data.""" def __init__( diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index af9e649a8b0..2e468087725 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -97,7 +97,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): +class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module """Define an object to hold Vizio app config data.""" def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 4ec1bf4a4ba..8bade56fa97 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -168,7 +168,7 @@ class VolvoData: raise InvalidAuth from exc -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): +class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Volvo coordinator.""" def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 110943a6503..2c216100244 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -85,7 +85,7 @@ class Options: ) -class DeviceCoordinator(DataUpdateCoordinator[None]): +class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module """Home Assistant wrapper for a pyWeMo device.""" options: Options | None = None diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 7f1f11ba25d..37e11dd2693 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -123,7 +123,7 @@ class XboxData: presence: dict[str, PresenceData] -class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): +class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): # pylint: disable=hass-enforce-coordinator-module """Store Xbox Console Status.""" def __init__( diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 307171487bc..5242aa90819 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -104,7 +104,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): +class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # pylint: disable=hass-enforce-coordinator-module """Class to manage fetching data from the API.""" def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: diff --git a/pylint/plugins/hass_enforce_coordinator_module.py b/pylint/plugins/hass_enforce_coordinator_module.py new file mode 100644 index 00000000000..3546632547b --- /dev/null +++ b/pylint/plugins/hass_enforce_coordinator_module.py @@ -0,0 +1,54 @@ +"""Plugin for checking if coordinator is in its own module.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassEnforceCoordinatorModule(BaseChecker): + """Checker for coordinators own module.""" + + name = "hass_enforce_coordinator_module" + priority = -1 + msgs = { + "C7461": ( + "Derived data update coordinator is recommended to be placed in the 'coordinator' module", + "hass-enforce-coordinator-module", + "Used when derived data update coordinator should be placed in its own module.", + ), + } + options = ( + ( + "ignore-wrong-coordinator-module", + { + "default": False, + "type": "yn", + "metavar": "", + "help": "Set to ``no`` if you wish to check if derived data update coordinator " + "is placed in its own module.", + }, + ), + ) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Check if derived data update coordinator is placed in its own module.""" + if self.linter.config.ignore_wrong_coordinator_module: + return + + root_name = node.root().name + + # we only want to check component update coordinators + if not root_name.startswith("homeassistant.components"): + return + + is_coordinator_module = root_name.endswith(".coordinator") + for ancestor in node.ancestors(): + if ancestor.name == "DataUpdateCoordinator" and not is_coordinator_module: + self.add_message("hass-enforce-coordinator-module", node=node) + return + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceCoordinatorModule(linter)) diff --git a/pyproject.toml b/pyproject.toml index 6ed57860ee7..535cac1292f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_coordinator_module", "hass_enforce_sorted_platforms", "hass_enforce_super_call", "hass_enforce_type_hints", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 3554dc66c92..2aeb5fbd5b7 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -101,3 +101,24 @@ def enforce_sorted_platforms_checker_fixture( ) enforce_sorted_platforms_checker.module = "homeassistant.components.pylint_test" return enforce_sorted_platforms_checker + + +@pytest.fixture(name="hass_enforce_coordinator_module", scope="session") +def hass_enforce_coordinator_module_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_coordinator_module check.""" + return _load_plugin_from_file( + "hass_enforce_coordinator_module", + "pylint/plugins/hass_enforce_coordinator_module.py", + ) + + +@pytest.fixture(name="enforce_coordinator_module_checker") +def enforce_coordinator_module_fixture( + hass_enforce_coordinator_module, linter +) -> BaseChecker: + """Fixture to provide a hass_enforce_coordinator_module checker.""" + enforce_coordinator_module_checker = ( + hass_enforce_coordinator_module.HassEnforceCoordinatorModule(linter) + ) + enforce_coordinator_module_checker.module = "homeassistant.components.pylint_test" + return enforce_coordinator_module_checker diff --git a/tests/pylint/test_enforce_coordinator_module.py b/tests/pylint/test_enforce_coordinator_module.py new file mode 100644 index 00000000000..746da8c1d7e --- /dev/null +++ b/tests/pylint/test_enforce_coordinator_module.py @@ -0,0 +1,133 @@ +"""Tests for pylint hass_enforce_coordinator_module plugin.""" +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import UNDEFINED +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + """, + id="simple", + ), + pytest.param( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + + class TestCoordinator2(TestCoordinator): + pass + """, + id="nested", + ), + ], +) +def test_enforce_coordinator_module_good( + linter: UnittestLinter, enforce_coordinator_module_checker: BaseChecker, code: str +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test.coordinator") + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_enforce_coordinator_module_bad_simple( + linter: UnittestLinter, + enforce_coordinator_module_checker: BaseChecker, +) -> None: + """Bad test case with coordinator extending directly.""" + root_node = astroid.parse( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + """, + "homeassistant.components.pylint_test", + ) + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=5, + node=root_node.body[1], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=21, + ), + ): + walker.walk(root_node) + + +def test_enforce_coordinator_module_bad_nested( + linter: UnittestLinter, + enforce_coordinator_module_checker: BaseChecker, +) -> None: + """Bad test case with nested coordinators.""" + root_node = astroid.parse( + """ + class DataUpdateCoordinator: + pass + + class TestCoordinator(DataUpdateCoordinator): + pass + + class NopeCoordinator(TestCoordinator): + pass + """, + "homeassistant.components.pylint_test", + ) + walker = ASTWalker(linter) + walker.add_checker(enforce_coordinator_module_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=5, + node=root_node.body[1], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=21, + ), + MessageTest( + msg_id="hass-enforce-coordinator-module", + line=8, + node=root_node.body[2], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=8, + end_col_offset=21, + ), + ): + walker.walk(root_node) From fd87fd9559fd074dcaa56c4073bfbe5e432fd05d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 Jan 2024 10:32:15 +0100 Subject: [PATCH 1095/1544] Update attributes in Entity.__init__ in matter (#108877) --- homeassistant/components/matter/entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index e308699acad..61535d990db 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -82,6 +82,9 @@ class MatterEntity(Entity): self._attr_should_poll = entity_info.should_poll self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None + # make sure to update the attributes once + self._update_from_device() + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -115,9 +118,6 @@ class MatterEntity(Entity): ) ) - # make sure to update the attributes once - self._update_from_device() - async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" if self._extra_poll_timer_unsub: From d45227adbb6ad9b3dff72059ae9a7a74d6b93e1f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 29 Jan 2024 20:00:18 +1000 Subject: [PATCH 1096/1544] Move asyncio lock in Teslemetry (#109044) Use single wakelock per vehicle --- homeassistant/components/teslemetry/entity.py | 2 +- homeassistant/components/teslemetry/models.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index c8fbc5910d8..d8dcf9934cc 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -15,7 +15,6 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator """Parent class for Teslemetry Entities.""" _attr_has_entity_name = True - _wakelock = asyncio.Lock() def __init__( self, @@ -26,6 +25,7 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator super().__init__(vehicle.coordinator) self.key = key self.api = vehicle.api + self._wakelock = vehicle.wakelock car_type = self.coordinator.data["vehicle_config_car_type"] diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 9b90c0b0750..e5b27fa9279 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -1,6 +1,7 @@ """The Teslemetry integration models.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from tesla_fleet_api import VehicleSpecific @@ -15,3 +16,4 @@ class TeslemetryVehicleData: api: VehicleSpecific coordinator: TeslemetryVehicleDataCoordinator vin: str + wakelock = asyncio.Lock() From 91e7e5e01a4a6b4d24c9ef7150870fc5ae021de2 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Mon, 29 Jan 2024 05:56:57 -0500 Subject: [PATCH 1097/1544] Add binary sensors to TechnoVE integration (#108938) * Add binary sensors to TechnoVE integration * Add unit tests for TechnoVE binary sensors * Implement PR feedback for TechnoVE * Limit to appropriate sensors in TechnoVE tests * Removed leftover code * Implement feedback in TechnoVE PR #108938 --- homeassistant/components/technove/__init__.py | 5 +- .../components/technove/binary_sensor.py | 103 +++++++ .../components/technove/strings.json | 14 + tests/components/technove/__init__.py | 16 ++ .../snapshots/test_binary_sensor.ambr | 261 ++++++++++++++++++ .../technove/snapshots/test_sensor.ambr | 199 ------------- .../components/technove/test_binary_sensor.py | 75 +++++ tests/components/technove/test_sensor.py | 12 +- 8 files changed, 478 insertions(+), 207 deletions(-) create mode 100644 homeassistant/components/technove/binary_sensor.py create mode 100644 tests/components/technove/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/technove/test_binary_sensor.py diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index 12ca604af45..a235f98433b 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -8,7 +8,10 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py new file mode 100644 index 00000000000..09bf08baad6 --- /dev/null +++ b/homeassistant/components/technove/binary_sensor.py @@ -0,0 +1,103 @@ +"""Support for TechnoVE binary sensor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from technove import Station as TechnoVEStation + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity + + +@dataclass(frozen=True, kw_only=True) +class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): + """Describes TechnoVE binary sensor entity.""" + + value_fn: Callable[[TechnoVEStation], bool | None] + + +BINARY_SENSORS = [ + TechnoVEBinarySensorDescription( + key="conflict_in_sharing_config", + translation_key="conflict_in_sharing_config", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.conflict_in_sharing_config, + ), + TechnoVEBinarySensorDescription( + key="in_sharing_mode", + translation_key="in_sharing_mode", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.in_sharing_mode, + ), + TechnoVEBinarySensorDescription( + key="is_battery_protected", + translation_key="is_battery_protected", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda station: station.info.is_battery_protected, + ), + TechnoVEBinarySensorDescription( + key="is_session_active", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda station: station.info.is_session_active, + ), + TechnoVEBinarySensorDescription( + key="is_static_ip", + translation_key="is_static_ip", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda station: station.info.is_static_ip, + ), + TechnoVEBinarySensorDescription( + key="update_available", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.UPDATE, + value_fn=lambda station: not station.info.is_up_to_date, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TechnoVEBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + ) + + +class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity): + """Defines a TechnoVE binary sensor entity.""" + + entity_description: TechnoVEBinarySensorDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVEBinarySensorDescription, + ) -> None: + """Initialize a TechnoVE binary sensor entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 6f7cb0d9f6b..8a850ee610c 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -25,6 +25,20 @@ } }, "entity": { + "binary_sensor": { + "conflict_in_sharing_config": { + "name": "Conflict with power sharing mode" + }, + "in_sharing_mode": { + "name": "Power sharing mode" + }, + "is_battery_protected": { + "name": "Battery protected" + }, + "is_static_ip": { + "name": "Static IP" + } + }, "sensor": { "voltage_in": { "name": "Input voltage" diff --git a/tests/components/technove/__init__.py b/tests/components/technove/__init__.py index e98470b8e2a..2d9f639244f 100644 --- a/tests/components/technove/__init__.py +++ b/tests/components/technove/__init__.py @@ -1 +1,17 @@ """Tests for the TechnoVE integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the TechnoVE integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.technove.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1b54bdda2ce --- /dev/null +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -0,0 +1,261 @@ +# serializer version: 1 +# name: test_sensors[binary_sensor.technove_station_battery_protected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_battery_protected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery protected', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_battery_protected', + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_battery_protected', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_battery_protected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Battery protected', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_battery_protected', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_session_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'TechnoVE Station Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_conflict_with_power_sharing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conflict with power sharing mode', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'conflict_in_sharing_config', + 'unique_id': 'AA:AA:AA:AA:AA:BB_conflict_in_sharing_config', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_conflict_with_power_sharing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Conflict with power sharing mode', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_conflict_with_power_sharing_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_power_sharing_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_power_sharing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power sharing mode', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'in_sharing_mode', + 'unique_id': 'AA:AA:AA:AA:AA:BB_in_sharing_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_power_sharing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Power sharing mode', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_power_sharing_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_static_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_static_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Static IP', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_static_ip', + 'unique_id': 'AA:AA:AA:AA:AA:BB_is_static_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_static_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TechnoVE Station Static IP', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_static_ip', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensors[binary_sensor.technove_station_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.technove_station_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:BB_update_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.technove_station_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'TechnoVE Station Update', + }), + 'context': , + 'entity_id': 'binary_sensor.technove_station_update', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index e0549c1dad1..d38b08631cc 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -47,21 +47,6 @@ 'state': '23.75', }) # --- -# name: test_sensors[sensor.technove_station_current] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'TechnoVE Station Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_current', - 'last_changed': , - 'last_updated': , - 'state': '23.75', - }) -# --- # name: test_sensors[sensor.technove_station_input_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -110,21 +95,6 @@ 'state': '238', }) # --- -# name: test_sensors[sensor.technove_station_input_voltage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'TechnoVE Station Input voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_input_voltage', - 'last_changed': , - 'last_updated': , - 'state': '238', - }) -# --- # name: test_sensors[sensor.technove_station_last_session_energy_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -173,84 +143,6 @@ 'state': '12.34', }) # --- -# name: test_sensors[sensor.technove_station_last_session_energy_usage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'TechnoVE Station Last session energy usage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_last_session_energy_usage', - 'last_changed': , - 'last_updated': , - 'state': '12.34', - }) -# --- -# name: test_sensors[sensor.technove_station_max_current-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.technove_station_max_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Max current', - 'platform': 'technove', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'max_current', - 'unique_id': 'AA:AA:AA:AA:AA:BB_max_current', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.technove_station_max_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'TechnoVE Station Max current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_max_current', - 'last_changed': , - 'last_updated': , - 'state': '24', - }) -# --- -# name: test_sensors[sensor.technove_station_max_current] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'TechnoVE Station Max current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_max_current', - 'last_changed': , - 'last_updated': , - 'state': '24', - }) -# --- # name: test_sensors[sensor.technove_station_max_station_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -299,21 +191,6 @@ 'state': '32', }) # --- -# name: test_sensors[sensor.technove_station_max_station_current] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'TechnoVE Station Max station current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_max_station_current', - 'last_changed': , - 'last_updated': , - 'state': '32', - }) -# --- # name: test_sensors[sensor.technove_station_output_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -362,21 +239,6 @@ 'state': '238', }) # --- -# name: test_sensors[sensor.technove_station_output_voltage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'TechnoVE Station Output voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_output_voltage', - 'last_changed': , - 'last_updated': , - 'state': '238', - }) -# --- # name: test_sensors[sensor.technove_station_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -425,21 +287,6 @@ 'state': '-82', }) # --- -# name: test_sensors[sensor.technove_station_signal_strength] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'signal_strength', - 'friendly_name': 'TechnoVE Station Signal strength', - 'state_class': , - 'unit_of_measurement': 'dBm', - }), - 'context': , - 'entity_id': 'sensor.technove_station_signal_strength', - 'last_changed': , - 'last_updated': , - 'state': '-82', - }) -# --- # name: test_sensors[sensor.technove_station_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -495,24 +342,6 @@ 'state': 'plugged_charging', }) # --- -# name: test_sensors[sensor.technove_station_status] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'TechnoVE Station Status', - 'options': list([ - 'unplugged', - 'plugged_waiting', - 'plugged_charging', - ]), - }), - 'context': , - 'entity_id': 'sensor.technove_station_status', - 'last_changed': , - 'last_updated': , - 'state': 'plugged_charging', - }) -# --- # name: test_sensors[sensor.technove_station_total_energy_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -561,21 +390,6 @@ 'state': '1234', }) # --- -# name: test_sensors[sensor.technove_station_total_energy_usage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'TechnoVE Station Total energy usage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.technove_station_total_energy_usage', - 'last_changed': , - 'last_updated': , - 'state': '1234', - }) -# --- # name: test_sensors[sensor.technove_station_wi_fi_network_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -620,16 +434,3 @@ 'state': 'Connecting...', }) # --- -# name: test_sensors[sensor.technove_station_wi_fi_network_name] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'TechnoVE Station Wi-Fi network name', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'sensor.technove_station_wi_fi_network_name', - 'last_changed': , - 'last_updated': , - 'state': 'Connecting...', - }) -# --- diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py new file mode 100644 index 00000000000..5e168ce0760 --- /dev/null +++ b/tests/components/technove/test_binary_sensor.py @@ -0,0 +1,75 @@ +"""Tests for the TechnoVE binary sensor platform.""" +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from technove import TechnoVEError + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE binary sensors.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + + +@pytest.mark.parametrize( + "entity_id", + ("binary_sensor.technove_station_static_ip",), +) +@pytest.mark.usefixtures("init_integration") +async def test_disabled_by_default_binary_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str +) -> None: + """Test the disabled by default TechnoVE binary sensors.""" + assert hass.states.get(entity_id) is None + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("init_integration") +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_technove: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update failure.""" + entity_id = "binary_sensor.technove_station_charging" + + assert hass.states.get(entity_id).state == STATE_ON + + mock_technove.update.side_effect = TechnoVEError("Test error") + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/technove/test_sensor.py b/tests/components/technove/test_sensor.py index d7010b9451c..5215f62c517 100644 --- a/tests/components/technove/test_sensor.py +++ b/tests/components/technove/test_sensor.py @@ -7,10 +7,12 @@ import pytest from syrupy import SnapshotAssertion from technove import Status, TechnoVEError -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import setup_with_selected_platforms + from tests.common import MockConfigEntry, async_fire_time_changed @@ -22,11 +24,7 @@ async def test_sensors( entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of the TechnoVE sensors.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - entity_registry = er.async_get(hass) + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) entity_entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) @@ -89,9 +87,9 @@ async def test_sensor_update_failure( assert hass.states.get(entity_id).state == Status.PLUGGED_CHARGING.value + mock_technove.update.side_effect = TechnoVEError("Test error") freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) - mock_technove.update.side_effect = TechnoVEError("Test error") await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From 0517e6049b282aba333065c7a7bbd9881f49f574 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:02:53 +0100 Subject: [PATCH 1098/1544] Bump github/codeql-action from 3.23.1 to 3.23.2 (#109039) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.23.1 to 3.23.2. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.23.1...v3.23.2) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8c33ec5a5a7..bdec74a3aff 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.23.1 + uses: github/codeql-action/init@v3.23.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.23.1 + uses: github/codeql-action/analyze@v3.23.2 with: category: "/language:python" From 789055fd68551dd1866f356100a7edb5da0cb1f6 Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:48:55 +0100 Subject: [PATCH 1099/1544] Fix Permobil eula error (#107290) * bump mypermobil to 0.1.8 * add eula check in config flow * Update strings.json * add test for email code with signed eula * fix docstring character limit * add placeholder description for MyPermobil --- .../components/permobil/config_flow.py | 15 ++- .../components/permobil/manifest.json | 2 +- .../components/permobil/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/permobil/test_config_flow.py | 93 ++++++++++++++++++- 6 files changed, 106 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 644ea29d8a3..2e3e228d512 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -5,7 +5,12 @@ from collections.abc import Mapping import logging from typing import Any -from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException +from mypermobil import ( + MyPermobil, + MyPermobilAPIException, + MyPermobilClientException, + MyPermobilEulaException, +) import voluptuous as vol from homeassistant import config_entries @@ -141,10 +146,16 @@ class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # or the backend returned an error when trying to validate the code _LOGGER.exception("Error verifying code") errors["base"] = "invalid_code" + except MyPermobilEulaException: + # The user has not accepted the EULA + errors["base"] = "unsigned_eula" if errors or not user_input: return self.async_show_form( - step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors + step_id="email_code", + data_schema=GET_TOKEN_SCHEMA, + errors=errors, + description_placeholders={"app_name": "MyPermobil"}, ) return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index fd937fc6f8a..2d136b28713 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/permobil", "iot_class": "cloud_polling", - "requirements": ["mypermobil==0.1.6"] + "requirements": ["mypermobil==0.1.8"] } diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index b500bbdb9ea..5070c13d9e5 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -27,7 +27,8 @@ "region_fetch_error": "Error fetching regions", "code_request_error": "Error requesting application code", "invalid_email": "Invalid email", - "invalid_code": "The code you gave is incorrect" + "invalid_code": "The code you gave is incorrect", + "unsigned_eula": "Please sign the EULA in the {app_name} app" } }, "entity": { diff --git a/requirements_all.txt b/requirements_all.txt index fd3cca21d83..b50feb91260 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1322,7 +1322,7 @@ mutagen==1.47.0 mutesync==0.0.1 # homeassistant.components.permobil -mypermobil==0.1.6 +mypermobil==0.1.8 # homeassistant.components.myuplink myuplink==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0f282cfcf1..57e69672dd1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1055,7 +1055,7 @@ mutagen==1.47.0 mutesync==0.0.1 # homeassistant.components.permobil -mypermobil==0.1.6 +mypermobil==0.1.8 # homeassistant.components.myuplink myuplink==0.0.9 diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py index ad61ead7bfc..0f303cc0482 100644 --- a/tests/components/permobil/test_config_flow.py +++ b/tests/components/permobil/test_config_flow.py @@ -1,7 +1,11 @@ """Test the MyPermobil config flow.""" from unittest.mock import Mock, patch -from mypermobil import MyPermobilAPIException, MyPermobilClientException +from mypermobil import ( + MyPermobilAPIException, + MyPermobilClientException, + MyPermobilEulaException, +) import pytest from homeassistant import config_entries @@ -67,7 +71,11 @@ async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> async def test_config_flow_incorrect_code( hass: HomeAssistant, my_permobil: Mock ) -> None: - """Test the config flow from start to until email code verification and have the API return error.""" + """Test email code verification with API error. + + Test the config flow from start to until email code verification + and have the API return API error. + """ my_permobil.request_application_token.side_effect = MyPermobilAPIException # init flow with patch( @@ -105,10 +113,75 @@ async def test_config_flow_incorrect_code( assert result["errors"]["base"] == "invalid_code" +async def test_config_flow_unsigned_eula( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test email code verification with unsigned eula error. + + Test the config flow from start to until email code verification + and have the API return that the eula is unsigned. + """ + my_permobil.request_application_token.side_effect = MyPermobilEulaException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request region code + # here the request_application_token raises a MyPermobilEulaException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "unsigned_eula" + + # Retry to submit the code again, but this time the user has signed the EULA + with patch.object( + my_permobil, + "request_application_token", + return_value=MOCK_TOKEN, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + + # Now the method should not raise an exception, and you can proceed with your assertions + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_DATA + + async def test_config_flow_incorrect_region( hass: HomeAssistant, my_permobil: Mock ) -> None: - """Test the config flow from start to until the request for email code and have the API return error.""" + """Test when the user does not exist in the selected region. + + Test the config flow from start to until the request for email + code and have the API return error because there is not user for + that email. + """ my_permobil.request_application_code.side_effect = MyPermobilAPIException # init flow with patch( @@ -140,7 +213,11 @@ async def test_config_flow_incorrect_region( async def test_config_flow_region_request_error( hass: HomeAssistant, my_permobil: Mock ) -> None: - """Test the config flow from start to until the request for regions and have the API return error.""" + """Test region request error. + + Test the config flow from start to until the request for regions + and have the API return an error. + """ my_permobil.request_region_names.side_effect = MyPermobilAPIException # init flow # here the request_region_names raises a MyPermobilAPIException @@ -162,7 +239,13 @@ async def test_config_flow_region_request_error( async def test_config_flow_invalid_email( hass: HomeAssistant, my_permobil: Mock ) -> None: - """Test the config flow from start to until the request for regions and have the API return error.""" + """Test an incorrectly formatted email. + + Test that the email must be formatted correctly. The schema for the + input should already check for this, but since the API does a + separate check that might not overlap 100% with the schema, + this test is still needed. + """ my_permobil.set_email.side_effect = MyPermobilClientException() # init flow # here the set_email raises a MyPermobilClientException From dbc568cd53f219c3fd8e43c9616a1d1446cace80 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:24:00 +0100 Subject: [PATCH 1100/1544] Simplify HomeWizard sensor names (#108854) * Simplify HomeWizard sensor names * Simplify translations even more by using default device_class names --- homeassistant/components/homewizard/sensor.py | 7 - .../components/homewizard/strings.json | 35 +- .../homewizard/snapshots/test_sensor.ambr | 5387 +++++++++++++++++ tests/components/homewizard/test_sensor.py | 324 +- 4 files changed, 5559 insertions(+), 194 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 01ad2d5ea57..768d99d1529 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -219,7 +219,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_power_w", - translation_key="active_power_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -328,7 +327,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( ), HomeWizardSensorEntityDescription( key="active_frequency_hz", - translation_key="active_frequency_hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -437,35 +435,30 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( EXTERNAL_SENSORS = { ExternalDevice.DeviceType.GAS_METER: HomeWizardExternalSensorEntityDescription( key="gas_meter", - translation_key="total_gas_m3", suggested_device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, device_name="Gas meter", ), ExternalDevice.DeviceType.HEAT_METER: HomeWizardExternalSensorEntityDescription( key="heat_meter", - translation_key="total_energy_gj", suggested_device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, device_name="Heat meter", ), ExternalDevice.DeviceType.WARM_WATER_METER: HomeWizardExternalSensorEntityDescription( key="warm_water_meter", - translation_key="total_liter_m3", suggested_device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, device_name="Warm water meter", ), ExternalDevice.DeviceType.WATER_METER: HomeWizardExternalSensorEntityDescription( key="water_meter", - translation_key="total_liter_m3", suggested_device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, device_name="Water meter", ), ExternalDevice.DeviceType.INLET_HEAT_METER: HomeWizardExternalSensorEntityDescription( key="inlet_heat_meter", - translation_key="total_energy_gj", suggested_device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, device_name="Inlet heat meter", diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 58bdd8c6cb9..d81db805d71 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -48,37 +48,31 @@ "name": "Wi-Fi SSID" }, "active_tariff": { - "name": "Active tariff" + "name": "Tariff" }, "wifi_strength": { "name": "Wi-Fi strength" }, "total_energy_import_kwh": { - "name": "Total energy import" + "name": "Energy import" }, "total_energy_import_tariff_kwh": { - "name": "Total energy import tariff {tariff}" + "name": "Energy import tariff {tariff}" }, "total_energy_export_kwh": { - "name": "Total energy export" + "name": "Energy export" }, "total_energy_export_tariff_kwh": { - "name": "Total energy export tariff {tariff}" - }, - "active_power_w": { - "name": "Active power" + "name": "Energy export tariff {tariff}" }, "active_power_phase_w": { - "name": "Active power phase {phase}" + "name": "Power phase {phase}" }, "active_voltage_phase_v": { - "name": "Active voltage phase {phase}" + "name": "Voltage phase {phase}" }, "active_current_phase_a": { - "name": "Active current phase {phase}" - }, - "active_frequency_hz": { - "name": "Active frequency" + "name": "Current phase {phase}" }, "voltage_sag_phase_count": { "name": "Voltage sags detected phase {phase}" @@ -93,25 +87,16 @@ "name": "Long power failures detected" }, "active_power_average_w": { - "name": "Active average demand" + "name": "Average demand" }, "monthly_power_peak_w": { "name": "Peak demand current month" }, - "total_gas_m3": { - "name": "Total gas" - }, - "meter_identifier": { - "name": "Meter identifier" - }, "active_liter_lpm": { - "name": "Active water usage" + "name": "Water usage" }, "total_liter_m3": { "name": "Total water usage" - }, - "total_energy_gj": { - "name": "Total heat energy" } }, "switch": { diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index cc5800acd7f..0aceea52026 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1353,6 +1353,323 @@ 'state': '12.345', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-4', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1428,6 +1745,886 @@ 'state': '50', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_gas_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1655,6 +2852,89 @@ 'state': '1111.0', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1730,6 +3010,255 @@ 'state': '4', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '456', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '123.456', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1880,6 +3409,95 @@ 'state': 'ISKRA 2M550T-101', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'aabbccddeeff_active_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Device Tariff', + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'sensor.device_tariff', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -2840,6 +4458,246 @@ 'state': '1234.567', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.111', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '230.222', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '230.333', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3290,6 +5148,85 @@ 'state': '6', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '12.345', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3516,6 +5453,82 @@ 'state': 'G001', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'G001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': 'G001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_G001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_updated': , + 'state': '111.111', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_total_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3664,6 +5677,82 @@ 'state': 'H001', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'H001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': 'H001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_meter_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_H001', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_energy', + 'last_changed': , + 'last_updated': , + 'state': '444.444', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_total_heat_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -3812,6 +5901,81 @@ 'state': 'IH001', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'IH001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': 'IH001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inlet_heat_meter_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_IH001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_none:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter None', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_none', + 'last_changed': , + 'last_updated': , + 'state': '555.555', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter_total_heat_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4035,6 +6199,82 @@ 'state': '333.333', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'WW001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': 'WW001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warm_water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_WW001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_water', + 'last_changed': , + 'last_updated': , + 'state': '333.333', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -4183,6 +6423,82 @@ 'state': '222.222', }) # --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'W001', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': 'W001', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_W001', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_water', + 'last_changed': , + 'last_updated': , + 'state': '222.222', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5231,6 +7547,1203 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_average_demand', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5383,6 +8896,89 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -5458,6 +9054,255 @@ 'state': '0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6418,6 +10263,246 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -6868,6 +10953,85 @@ 'state': '0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7034,6 +11198,332 @@ 'state': '1457.277', }) # --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '63.651', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '1457.277', + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-SKT', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-SKT-entity_ids2][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '1457.277', + }) +# --- # name: test_sensors[HWE-SKT-entity_ids2][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7507,6 +11997,85 @@ 'state': '17.014', }) # --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-WTR', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '2.03', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-WTR-entity_ids3][sensor.device_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[HWE-WTR-entity_ids3][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -7827,6 +12396,332 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -8473,6 +13368,498 @@ 'state': '0.0', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 214e1db706b..848acc170e7 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -32,29 +32,29 @@ pytestmark = [ "sensor.device_smart_meter_model", "sensor.device_smart_meter_identifier", "sensor.device_wi_fi_ssid", - "sensor.device_active_tariff", + "sensor.device_tariff", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_energy_import", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_power", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", @@ -63,41 +63,41 @@ pytestmark = [ "sensor.device_voltage_swells_detected_phase_3", "sensor.device_power_failures_detected", "sensor.device_long_power_failures_detected", - "sensor.device_active_average_demand", + "sensor.device_average_demand", "sensor.device_peak_demand_current_month", - "sensor.device_active_water_usage", + "sensor.device_water_usage", "sensor.device_total_water_usage", - "sensor.gas_meter_total_gas", - "sensor.water_meter_total_water_usage", - "sensor.warm_water_meter_total_water_usage", - "sensor.heat_meter_total_heat_energy", - "sensor.inlet_heat_meter_total_heat_energy", + "sensor.gas_meter_gas", + "sensor.water_meter_water", + "sensor.warm_water_meter_water", + "sensor.heat_meter_energy", + "sensor.inlet_heat_meter_none", ], ), ( "HWE-P1-zero-values", [ - "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_energy_import", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_power", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", @@ -106,8 +106,8 @@ pytestmark = [ "sensor.device_voltage_swells_detected_phase_3", "sensor.device_power_failures_detected", "sensor.device_long_power_failures_detected", - "sensor.device_active_average_demand", - "sensor.device_active_water_usage", + "sensor.device_average_demand", + "sensor.device_water_usage", "sensor.device_total_water_usage", ], ), @@ -116,10 +116,10 @@ pytestmark = [ [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_export", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", + "sensor.device_energy_import", + "sensor.device_energy_export", + "sensor.device_power", + "sensor.device_power_phase_1", ], ), ( @@ -127,7 +127,7 @@ pytestmark = [ [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_active_water_usage", + "sensor.device_water_usage", "sensor.device_total_water_usage", ], ), @@ -136,10 +136,10 @@ pytestmark = [ [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_export", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", + "sensor.device_energy_import", + "sensor.device_energy_export", + "sensor.device_power", + "sensor.device_power_phase_1", ], ), ( @@ -147,12 +147,12 @@ pytestmark = [ [ "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_total_energy_import", - "sensor.device_total_energy_export", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", + "sensor.device_energy_import", + "sensor.device_energy_export", + "sensor.device_power", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", ], ), ], @@ -184,23 +184,23 @@ async def test_sensors( "HWE-P1", [ "sensor.device_wi_fi_strength", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", ], ), ( "HWE-P1-unused-exports", [ - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", ], ), ( @@ -248,7 +248,7 @@ async def test_sensors_unreachable( exception: Exception, ) -> None: """Test sensor handles API unreachable.""" - assert (state := hass.states.get("sensor.device_total_energy_import_tariff_1")) + assert (state := hass.states.get("sensor.device_energy_import_tariff_1")) assert state.state == "10830.511" mock_homewizardenergy.data.side_effect = exception @@ -264,7 +264,7 @@ async def test_external_sensors_unreachable( mock_homewizardenergy: MagicMock, ) -> None: """Test external device sensor handles API unreachable.""" - assert (state := hass.states.get("sensor.gas_meter_total_gas")) + assert (state := hass.states.get("sensor.gas_meter_gas")) assert state.state == "111.111" mock_homewizardenergy.data.return_value = Data.from_dict({}) @@ -281,32 +281,32 @@ async def test_external_sensors_unreachable( ( "HWE-SKT", [ - "sensor.device_active_average_demand", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_tariff", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_water_usage", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_tariff", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_water_usage", "sensor.device_dsmr_version", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", @@ -322,28 +322,28 @@ async def test_external_sensors_unreachable( "sensor.device_dsmr_version", "sensor.device_smart_meter_model", "sensor.device_smart_meter_identifier", - "sensor.device_active_tariff", - "sensor.device_total_energy_import", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", - "sensor.device_total_energy_export", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_active_power", - "sensor.device_active_power_phase_1", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", + "sensor.device_tariff", + "sensor.device_energy_import", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_power", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", @@ -352,39 +352,39 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_power_failures_detected", "sensor.device_long_power_failures_detected", - "sensor.device_active_average_demand", + "sensor.device_average_demand", "sensor.device_peak_demand_current_month", ], ), ( "SDM230", [ - "sensor.device_active_average_demand", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", - "sensor.device_active_power_phase_2", - "sensor.device_active_power_phase_3", - "sensor.device_active_tariff", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_water_usage", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_tariff", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_water_usage", "sensor.device_dsmr_version", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", @@ -397,30 +397,30 @@ async def test_external_sensors_unreachable( ( "SDM630", [ - "sensor.device_active_average_demand", - "sensor.device_active_current_phase_1", - "sensor.device_active_current_phase_2", - "sensor.device_active_current_phase_3", - "sensor.device_active_frequency", - "sensor.device_active_tariff", - "sensor.device_active_voltage_phase_1", - "sensor.device_active_voltage_phase_2", - "sensor.device_active_voltage_phase_3", - "sensor.device_active_water_usage", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_frequency", + "sensor.device_tariff", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_water_usage", "sensor.device_dsmr_version", "sensor.device_long_power_failures_detected", "sensor.device_peak_demand_current_month", "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", - "sensor.device_total_energy_export_tariff_1", - "sensor.device_total_energy_export_tariff_2", - "sensor.device_total_energy_export_tariff_3", - "sensor.device_total_energy_export_tariff_4", - "sensor.device_total_energy_import_tariff_1", - "sensor.device_total_energy_import_tariff_2", - "sensor.device_total_energy_import_tariff_3", - "sensor.device_total_energy_import_tariff_4", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", "sensor.device_total_water_usage", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", From 030727b0788deed60a1e2b1f0409a82e0137f129 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:24:58 +0100 Subject: [PATCH 1101/1544] Remove deprecated event_loop fixtures in tests (#109048) --- tests/components/config/test_init.py | 2 +- tests/components/fido/test_sensor.py | 2 +- tests/components/geofency/test_init.py | 4 ++-- tests/components/gpslogger/test_init.py | 4 ++-- tests/components/http/test_security_filter.py | 7 +++---- tests/components/locative/test_init.py | 2 +- tests/components/recorder/test_util.py | 2 +- tests/components/traccar/test_init.py | 4 ++-- tests/conftest.py | 8 +++----- tests/test_bootstrap.py | 9 --------- tests/test_core.py | 4 +--- tests/util/test_location.py | 2 +- 12 files changed, 18 insertions(+), 32 deletions(-) diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 6a95fb8ebda..4dd786edfd1 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -3,7 +3,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -async def test_config_setup(hass: HomeAssistant, event_loop) -> None: +async def test_config_setup(hass: HomeAssistant) -> None: """Test it sets up hassbian.""" await async_setup_component(hass, "config", {}) assert "config" in hass.config.components diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index cccea731a2c..81ae54174ca 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -40,7 +40,7 @@ class FidoClientMockError(FidoClientMock): raise PyFidoError("Fake Error") -async def test_fido_sensor(event_loop, hass: HomeAssistant) -> None: +async def test_fido_sensor(hass: HomeAssistant) -> None: """Test the Fido number sensor.""" with patch("homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock): config = { diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index d5ababaee41..2ab2d9cc8bb 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -116,7 +116,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def geofency_client(event_loop, hass, hass_client_no_auth): +async def geofency_client(hass, hass_client_no_auth): """Geofency mock client (unauthenticated).""" assert await async_setup_component( @@ -129,7 +129,7 @@ async def geofency_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index a9fc5312bba..3873695033e 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -25,7 +25,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def gpslogger_client(event_loop, hass, hass_client_no_auth): +async def gpslogger_client(hass, hass_client_no_auth): """Mock client for GPSLogger (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -37,7 +37,7 @@ async def gpslogger_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 9e4353d7e61..0cd85b48b06 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -1,4 +1,5 @@ """Test security filter middleware.""" +import asyncio from http import HTTPStatus from aiohttp import web @@ -75,7 +76,6 @@ async def test_bad_requests( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - event_loop, ) -> None: """Test request paths that should be filtered.""" app = web.Application() @@ -93,7 +93,7 @@ async def test_bad_requests( man_params = "" http = urllib3.PoolManager() - resp = await event_loop.run_in_executor( + resp = await asyncio.get_running_loop().run_in_executor( None, http.request, "GET", @@ -126,7 +126,6 @@ async def test_bad_requests_with_unsafe_bytes( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - event_loop, ) -> None: """Test request with unsafe bytes in their URLs.""" app = web.Application() @@ -144,7 +143,7 @@ async def test_bad_requests_with_unsafe_bytes( man_params = "" http = urllib3.PoolManager() - resp = await event_loop.run_in_executor( + resp = await asyncio.get_running_loop().run_in_executor( None, http.request, "GET", diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 8861a166bed..7a1e071958d 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -20,7 +20,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture -async def locative_client(event_loop, hass, hass_client): +async def locative_client(hass, hass_client): """Locative mock client.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 66daced2ca8..583416834fb 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -99,7 +99,7 @@ def test_validate_or_move_away_sqlite_database( async def test_last_run_was_recently_clean( - event_loop, async_setup_recorder_instance: RecorderInstanceGenerator, tmp_path: Path + async_setup_recorder_instance: RecorderInstanceGenerator, tmp_path: Path ) -> None: """Test we can check if the last recorder run was recently clean.""" config = { diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 1ac7adfb549..b85701f9c72 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -25,7 +25,7 @@ def mock_dev_track(mock_device_tracker_conf): @pytest.fixture(name="client") -async def traccar_client(event_loop, hass, hass_client_no_auth): +async def traccar_client(hass, hass_client_no_auth): """Mock client for Traccar (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -37,7 +37,7 @@ async def traccar_client(event_loop, hass, hass_client_no_auth): @pytest.fixture(autouse=True) -async def setup_zones(event_loop, hass): +async def setup_zones(hass): """Set up Zone config in HA.""" assert await async_setup_component( hass, diff --git a/tests/conftest.py b/tests/conftest.py index 856213fa60a..2b61cee4e33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -517,14 +517,13 @@ def hass_fixture_setup() -> list[bool]: @pytest.fixture async def hass( hass_fixture_setup: list[bool], - event_loop: asyncio.AbstractEventLoop, load_registries: bool, hass_storage: dict[str, Any], request: pytest.FixtureRequest, ) -> AsyncGenerator[HomeAssistant, None]: """Create a test instance of Home Assistant.""" - loop = event_loop + loop = asyncio.get_running_loop() hass_fixture_setup.append(True) orig_tz = dt_util.DEFAULT_TIME_ZONE @@ -577,12 +576,11 @@ async def hass( @pytest.fixture -async def stop_hass( - event_loop: asyncio.AbstractEventLoop, -) -> AsyncGenerator[None, None]: +async def stop_hass() -> AsyncGenerator[None, None]: """Make sure all hass are stopped.""" orig_hass = ha.HomeAssistant + event_loop = asyncio.get_running_loop() created = [] def mock_hass(*args): diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index b640d59df44..a899b3b3d6c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -480,7 +480,6 @@ async def test_setup_hass( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -530,7 +529,6 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -569,7 +567,6 @@ async def test_setup_hass_invalid_yaml( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch( @@ -597,7 +594,6 @@ async def test_setup_hass_config_dir_nonexistent( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" mock_ensure_config_exists.return_value = False @@ -624,7 +620,6 @@ async def test_setup_hass_recovery_mode( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup") as browser_setup, patch( @@ -659,7 +654,6 @@ async def test_setup_hass_safe_mode( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup"), patch( @@ -692,7 +686,6 @@ async def test_setup_hass_recovery_mode_and_safe_mode( mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup"), patch( @@ -725,7 +718,6 @@ async def test_setup_hass_invalid_core_config( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.bootstrap.async_notify_setup_error") as mock_notify: @@ -765,7 +757,6 @@ async def test_setup_recovery_mode_if_no_frontend( mock_mount_local_lib_path: AsyncMock, mock_ensure_config_exists: AsyncMock, mock_process_ha_config_upgrade: Mock, - event_loop: asyncio.AbstractEventLoop, ) -> None: """Test we setup recovery mode if frontend didn't load.""" verbose = Mock() diff --git a/tests/test_core.py b/tests/test_core.py index 3fef994b8e8..4136249f993 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1747,9 +1747,7 @@ async def test_bad_timezone_raises_value_error(hass: HomeAssistant) -> None: await hass.config.async_update(time_zone="not_a_timezone") -async def test_start_taking_too_long( - event_loop, caplog: pytest.LogCaptureFixture -) -> None: +async def test_start_taking_too_long(caplog: pytest.LogCaptureFixture) -> None: """Test when async_start takes too long.""" hass = ha.HomeAssistant("/test/ha-config") caplog.set_level(logging.WARNING) diff --git a/tests/util/test_location.py b/tests/util/test_location.py index e998c10e565..d52362d5ee6 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -33,7 +33,7 @@ async def session(hass): @pytest.fixture -async def raising_session(event_loop): +async def raising_session(): """Return an aioclient session that only fails.""" return Mock(get=Mock(side_effect=aiohttp.ClientError)) From 5183eed0bc8c2f8a3b124fcdef1b38e635164f7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 03:25:27 -1000 Subject: [PATCH 1102/1544] Avoid re-encoding the hassio command URL each request (#109031) * Avoid reconstructing the hassio command URL each request The host had to be re-encoded every time which creates an ip_address object By doing a join we avoid this. It was actually happening twice since we passed constructed the URL for testing and than passed it as a string so aiohttp did it as well * make url the same --- homeassistant/components/hassio/handler.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 653238709cd..a0061647caa 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -330,6 +330,7 @@ class HassIO: self.loop = loop self.websession = websession self._ip = ip + self._base_url = URL(f"http://{ip}") @_api_bool def is_connected(self) -> Coroutine: @@ -559,14 +560,20 @@ class HassIO: This method is a coroutine. """ url = f"http://{self._ip}{command}" - if url != str(URL(url)): + joined_url = self._base_url.join(URL(command)) + # This check is to make sure the normalized URL string + # is the same as the URL string that was passed in. If + # they are different, then the passed in command URL + # contained characters that were removed by the normalization + # such as ../../../../etc/passwd + if url != str(joined_url): _LOGGER.error("Invalid request %s", command) raise HassioAPIError() try: request = await self.websession.request( method, - f"http://{self._ip}{command}", + joined_url, json=payload, headers={ aiohttp.hdrs.AUTHORIZATION: ( From e9e289286e41450e670ff4bf8290a55b3b22bcbe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 03:32:53 -1000 Subject: [PATCH 1103/1544] Set hassio api json encoding to avoid looking it up every request (#109032) --- homeassistant/components/hassio/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index a0061647caa..c3532d553f4 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -591,7 +591,7 @@ class HassIO: if return_text: return await request.text(encoding="utf-8") - return await request.json() + return await request.json(encoding="utf-8") except asyncio.TimeoutError: _LOGGER.error("Timeout on %s request", command) From d631cad07fed2247f87f2f91e56f71d4b581191a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:42:16 +0100 Subject: [PATCH 1104/1544] Add new sensors exposed by HomeWizard kWh meter (#108850) * Add new sensors exposed by kWh meter * Add entity translation placeholders * Fix Mypy issue * Adjusts tests * Remove suggested display precision for disabled-by-default sensors * Update test-snapshots * Update snapshots --- homeassistant/components/homewizard/sensor.py | 151 + .../components/homewizard/strings.json | 9 + .../homewizard/fixtures/SDM230/data.json | 10 +- .../homewizard/fixtures/SDM630/data.json | 29 +- .../snapshots/test_diagnostics.ambr | 50 +- .../homewizard/snapshots/test_sensor.ambr | 4004 +++++++++++++++++ tests/components/homewizard/test_sensor.py | 312 +- 7 files changed, 4426 insertions(+), 139 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 768d99d1529..e544ee601c0 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -18,8 +18,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_VIA_DEVICE, PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, Platform, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -57,6 +59,11 @@ class HomeWizardExternalSensorEntityDescription(SensorEntityDescription): device_name: str +def to_percentage(value: float | None) -> float | None: + """Convert 0..1 value to percentage when value is not None.""" + return value * 100 if value is not None else None + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", @@ -259,6 +266,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_power_l3_w is not None, value_fn=lambda data: data.active_power_l3_w, ), + HomeWizardSensorEntityDescription( + key="active_voltage_v", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_voltage_v is not None, + value_fn=lambda data: data.active_voltage_v, + ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", translation_key="active_voltage_phase_v", @@ -292,6 +308,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_voltage_l3_v is not None, value_fn=lambda data: data.active_voltage_l3_v, ), + HomeWizardSensorEntityDescription( + key="active_current_a", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_current_a is not None, + value_fn=lambda data: data.active_current_a, + ), HomeWizardSensorEntityDescription( key="active_current_l1_a", translation_key="active_current_phase_a", @@ -334,6 +359,132 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.active_frequency_hz is not None, value_fn=lambda data: data.active_frequency_hz, ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_va", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_va is not None, + value_fn=lambda data: data.active_apparent_power_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l1_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l1_va is not None, + value_fn=lambda data: data.active_apparent_power_l1_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l2_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l2_va is not None, + value_fn=lambda data: data.active_apparent_power_l2_va, + ), + HomeWizardSensorEntityDescription( + key="active_apparent_power_l3_va", + translation_key="active_apparent_power_phase_va", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_apparent_power_l3_va is not None, + value_fn=lambda data: data.active_apparent_power_l3_va, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_var", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_var is not None, + value_fn=lambda data: data.active_reactive_power_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l1_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l1_var is not None, + value_fn=lambda data: data.active_reactive_power_l1_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l2_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l2_var is not None, + value_fn=lambda data: data.active_reactive_power_l2_var, + ), + HomeWizardSensorEntityDescription( + key="active_reactive_power_l3_var", + translation_key="active_reactive_power_phase_var", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_reactive_power_l3_var is not None, + value_fn=lambda data: data.active_reactive_power_l3_var, + ), + HomeWizardSensorEntityDescription( + key="active_power_factor", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor is not None, + value_fn=lambda data: to_percentage(data.active_power_factor), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l1", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "1"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l1), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l2", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "2"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l2), + ), + HomeWizardSensorEntityDescription( + key="active_power_factor_l3", + translation_key="active_power_factor_phase", + translation_placeholders={"phase": "3"}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + has_fn=lambda data: data.active_power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.active_power_factor_l3), + ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_phase_count", diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index d81db805d71..ca903330a44 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -74,6 +74,15 @@ "active_current_phase_a": { "name": "Current phase {phase}" }, + "active_apparent_power_phase_va": { + "name": "Apparent power phase {phase}" + }, + "active_reactive_power_phase_var": { + "name": "Reactive power phase {phase}" + }, + "active_power_factor_phase": { + "name": "Power factor phase {phase}" + }, "voltage_sag_phase_count": { "name": "Voltage sags detected phase {phase}" }, diff --git a/tests/components/homewizard/fixtures/SDM230/data.json b/tests/components/homewizard/fixtures/SDM230/data.json index 64fb2533359..7f970de2cde 100644 --- a/tests/components/homewizard/fixtures/SDM230/data.json +++ b/tests/components/homewizard/fixtures/SDM230/data.json @@ -4,5 +4,13 @@ "total_power_import_t1_kwh": 2.705, "total_power_export_t1_kwh": 255.551, "active_power_w": -1058.296, - "active_power_l1_w": -1058.296 + "active_power_l1_w": -1058.296, + "active_voltage_v": 228.472, + "active_current_a": 0.273, + "active_apparent_current_a": 0.447, + "active_reactive_current_a": 0.354, + "active_apparent_power_va": 74.052, + "active_reactive_power_var": -58.612, + "active_power_factor": 0.611, + "active_frequency_hz": 50 } diff --git a/tests/components/homewizard/fixtures/SDM630/data.json b/tests/components/homewizard/fixtures/SDM630/data.json index ee143220c67..fc0d1e929f9 100644 --- a/tests/components/homewizard/fixtures/SDM630/data.json +++ b/tests/components/homewizard/fixtures/SDM630/data.json @@ -6,5 +6,32 @@ "active_power_w": -900.194, "active_power_l1_w": -1058.296, "active_power_l2_w": 158.102, - "active_power_l3_w": 0.0 + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 } diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index e1fdfcf7c12..eb7716c2037 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -308,15 +308,15 @@ 'active_apparent_power_l1_va': None, 'active_apparent_power_l2_va': None, 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, + 'active_apparent_power_va': 74.052, + 'active_current_a': 0.273, 'active_current_l1_a': None, 'active_current_l2_a': None, 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'active_frequency_hz': 50, 'active_liter_lpm': None, 'active_power_average_w': None, - 'active_power_factor': None, + 'active_power_factor': 0.611, 'active_power_factor_l1': None, 'active_power_factor_l2': None, 'active_power_factor_l3': None, @@ -327,12 +327,12 @@ 'active_reactive_power_l1_var': None, 'active_reactive_power_l2_var': None, 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, + 'active_reactive_power_var': -58.612, 'active_tariff': None, 'active_voltage_l1_v': None, 'active_voltage_l2_v': None, 'active_voltage_l3_v': None, - 'active_voltage_v': None, + 'active_voltage_v': 228.472, 'any_power_fail_count': None, 'external_devices': None, 'gas_timestamp': None, @@ -388,33 +388,33 @@ dict({ 'data': dict({ 'data': dict({ - 'active_apparent_power_l1_va': None, - 'active_apparent_power_l2_va': None, - 'active_apparent_power_l3_va': None, - 'active_apparent_power_va': None, - 'active_current_a': None, - 'active_current_l1_a': None, - 'active_current_l2_a': None, - 'active_current_l3_a': None, - 'active_frequency_hz': None, + 'active_apparent_power_l1_va': 0, + 'active_apparent_power_l2_va': 3548.879, + 'active_apparent_power_l3_va': 3563.414, + 'active_apparent_power_va': 7112.293, + 'active_current_a': 30.999, + 'active_current_l1_a': 0, + 'active_current_l2_a': 15.521, + 'active_current_l3_a': 15.477, + 'active_frequency_hz': 49.926, 'active_liter_lpm': None, 'active_power_average_w': None, 'active_power_factor': None, - 'active_power_factor_l1': None, - 'active_power_factor_l2': None, - 'active_power_factor_l3': None, + 'active_power_factor_l1': 1, + 'active_power_factor_l2': 0.999, + 'active_power_factor_l3': 0.997, 'active_power_l1_w': -1058.296, 'active_power_l2_w': 158.102, 'active_power_l3_w': 0.0, 'active_power_w': -900.194, - 'active_reactive_power_l1_var': None, - 'active_reactive_power_l2_var': None, - 'active_reactive_power_l3_var': None, - 'active_reactive_power_var': None, + 'active_reactive_power_l1_var': 0, + 'active_reactive_power_l2_var': -166.675, + 'active_reactive_power_l3_var': -262.35, + 'active_reactive_power_var': -429.025, 'active_tariff': None, - 'active_voltage_l1_v': None, - 'active_voltage_l2_v': None, - 'active_voltage_l3_v': None, + 'active_voltage_l1_v': 230.751, + 'active_voltage_l2_v': 228.391, + 'active_voltage_l3_v': 229.612, 'active_voltage_v': None, 'any_power_fail_count': None, 'external_devices': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 0aceea52026..863403f3406 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -12230,6 +12230,246 @@ 'state': '84', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_a', + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12313,6 +12553,86 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_factor:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_factor:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor', + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12396,6 +12716,327 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'entity_id': 'sensor.device_active_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_active_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12556,6 +13197,86 @@ 'state': '2.705', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12639,6 +13360,86 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_factor:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_factor:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12722,6 +13523,86 @@ 'state': '-1058.296', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -12882,6 +13763,86 @@ 'state': '2.705', }) # --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM230-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM230-entity_ids4][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- # name: test_sensors[SDM230-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13036,6 +13997,726 @@ 'state': '92', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Active apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_a', + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '15.521', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Active current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '15.477', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_frequency_hz', + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Active frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_frequency', + 'last_changed': , + 'last_updated': , + 'state': '49.926', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_active_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13119,6 +14800,246 @@ 'state': '-900.194', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Active power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_active_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_active_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13368,6 +15289,1209 @@ 'state': '0.0', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'entity_id': 'sensor.device_active_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Active reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_active_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '228.391', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_active_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Active voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_active_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '229.612', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '15.521', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '15.477', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13528,6 +16652,86 @@ 'state': '0.101', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '49.926', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13611,6 +16815,246 @@ 'state': '-900.194', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -13860,6 +17304,326 @@ 'state': '0.0', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_total_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -14020,6 +17784,246 @@ 'state': '0.101', }) # --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '228.391', + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'SDM630-wifi', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[SDM630-entity_ids5][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '229.612', + }) +# --- # name: test_sensors[SDM630-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 848acc170e7..c1acd49a590 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -28,131 +28,155 @@ pytestmark = [ ( "HWE-P1", [ + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", "sensor.device_dsmr_version", - "sensor.device_smart_meter_model", - "sensor.device_smart_meter_identifier", - "sensor.device_wi_fi_ssid", - "sensor.device_tariff", - "sensor.device_wi_fi_strength", - "sensor.device_energy_import", - "sensor.device_energy_import_tariff_1", - "sensor.device_energy_import_tariff_2", - "sensor.device_energy_import_tariff_3", - "sensor.device_energy_import_tariff_4", - "sensor.device_energy_export", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", "sensor.device_energy_export_tariff_4", - "sensor.device_power", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", "sensor.device_power_phase_1", "sensor.device_power_phase_2", "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", - "sensor.device_current_phase_1", - "sensor.device_current_phase_2", - "sensor.device_current_phase_3", - "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", - "sensor.device_power_failures_detected", - "sensor.device_long_power_failures_detected", - "sensor.device_average_demand", - "sensor.device_peak_demand_current_month", "sensor.device_water_usage", - "sensor.device_total_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", "sensor.gas_meter_gas", - "sensor.water_meter_water", - "sensor.warm_water_meter_water", "sensor.heat_meter_energy", "sensor.inlet_heat_meter_none", + "sensor.warm_water_meter_water", + "sensor.water_meter_water", ], ), ( "HWE-P1-zero-values", [ - "sensor.device_energy_import", - "sensor.device_energy_import_tariff_1", - "sensor.device_energy_import_tariff_2", - "sensor.device_energy_import_tariff_3", - "sensor.device_energy_import_tariff_4", - "sensor.device_energy_export", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", "sensor.device_energy_export_tariff_4", - "sensor.device_power", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_power_failures_detected", "sensor.device_power_phase_1", "sensor.device_power_phase_2", "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", - "sensor.device_current_phase_1", - "sensor.device_current_phase_2", - "sensor.device_current_phase_3", - "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", - "sensor.device_power_failures_detected", - "sensor.device_long_power_failures_detected", - "sensor.device_average_demand", "sensor.device_water_usage", - "sensor.device_total_water_usage", ], ), ( "HWE-SKT", [ + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_power_phase_1", + "sensor.device_power", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_energy_import", - "sensor.device_energy_export", - "sensor.device_power", - "sensor.device_power_phase_1", ], ), ( "HWE-WTR", [ + "sensor.device_total_water_usage", + "sensor.device_water_usage", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_water_usage", - "sensor.device_total_water_usage", ], ), ( "SDM230", [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_power", + "sensor.device_reactive_power", + "sensor.device_voltage", "sensor.device_wi_fi_ssid", "sensor.device_wi_fi_strength", - "sensor.device_energy_import", - "sensor.device_energy_export", - "sensor.device_power", - "sensor.device_power_phase_1", ], ), ( "SDM630", [ - "sensor.device_wi_fi_ssid", - "sensor.device_wi_fi_strength", - "sensor.device_energy_import", + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", "sensor.device_energy_export", - "sensor.device_power", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", "sensor.device_power_phase_1", "sensor.device_power_phase_2", "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", ], ), ], @@ -183,24 +207,24 @@ async def test_sensors( ( "HWE-P1", [ - "sensor.device_wi_fi_strength", - "sensor.device_voltage_phase_1", - "sensor.device_voltage_phase_2", - "sensor.device_voltage_phase_3", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", "sensor.device_frequency", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_strength", ], ), ( "HWE-P1-unused-exports", [ - "sensor.device_energy_export", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", ], ), ( @@ -218,12 +242,37 @@ async def test_sensors( ( "SDM230", [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_reactive_power", + "sensor.device_voltage", "sensor.device_wi_fi_strength", ], ), ( "SDM630", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_wi_fi_strength", ], ), @@ -281,24 +330,16 @@ async def test_external_sensors_unreachable( ( "HWE-SKT", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", "sensor.device_average_demand", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", - "sensor.device_frequency", - "sensor.device_power_phase_2", - "sensor.device_power_phase_3", - "sensor.device_tariff", - "sensor.device_voltage_phase_1", - "sensor.device_voltage_phase_2", - "sensor.device_voltage_phase_3", - "sensor.device_water_usage", + "sensor.device_current", "sensor.device_dsmr_version", - "sensor.device_long_power_failures_detected", - "sensor.device_peak_demand_current_month", - "sensor.device_power_failures_detected", - "sensor.device_smart_meter_identifier", - "sensor.device_smart_meter_model", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", @@ -307,76 +348,103 @@ async def test_external_sensors_unreachable( "sensor.device_energy_import_tariff_2", "sensor.device_energy_import_tariff_3", "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_voltage", + "sensor.device_water_usage", ], ), ( "HWE-WTR", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", "sensor.device_dsmr_version", - "sensor.device_smart_meter_model", - "sensor.device_smart_meter_identifier", - "sensor.device_tariff", - "sensor.device_energy_import", - "sensor.device_energy_import_tariff_1", - "sensor.device_energy_import_tariff_2", - "sensor.device_energy_import_tariff_3", - "sensor.device_energy_import_tariff_4", - "sensor.device_energy_export", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", "sensor.device_energy_export_tariff_4", - "sensor.device_power", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", "sensor.device_power_phase_1", "sensor.device_power_phase_2", "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", - "sensor.device_current_phase_1", - "sensor.device_current_phase_2", - "sensor.device_current_phase_3", - "sensor.device_frequency", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", - "sensor.device_power_failures_detected", - "sensor.device_long_power_failures_detected", - "sensor.device_average_demand", - "sensor.device_peak_demand_current_month", + "sensor.device_voltage", ], ), ( "SDM230", [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_average_demand", "sensor.device_average_demand", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", - "sensor.device_frequency", - "sensor.device_power_phase_2", - "sensor.device_power_phase_3", - "sensor.device_tariff", - "sensor.device_voltage_phase_1", - "sensor.device_voltage_phase_2", - "sensor.device_voltage_phase_3", - "sensor.device_water_usage", "sensor.device_dsmr_version", - "sensor.device_long_power_failures_detected", - "sensor.device_peak_demand_current_month", - "sensor.device_power_failures_detected", - "sensor.device_smart_meter_identifier", - "sensor.device_smart_meter_model", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", @@ -385,13 +453,32 @@ async def test_external_sensors_unreachable( "sensor.device_energy_import_tariff_2", "sensor.device_energy_import_tariff_3", "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", ], ), ( @@ -401,18 +488,7 @@ async def test_external_sensors_unreachable( "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", - "sensor.device_frequency", - "sensor.device_tariff", - "sensor.device_voltage_phase_1", - "sensor.device_voltage_phase_2", - "sensor.device_voltage_phase_3", - "sensor.device_water_usage", "sensor.device_dsmr_version", - "sensor.device_long_power_failures_detected", - "sensor.device_peak_demand_current_month", - "sensor.device_power_failures_detected", - "sensor.device_smart_meter_identifier", - "sensor.device_smart_meter_model", "sensor.device_energy_export_tariff_1", "sensor.device_energy_export_tariff_2", "sensor.device_energy_export_tariff_3", @@ -421,13 +497,25 @@ async def test_external_sensors_unreachable( "sensor.device_energy_import_tariff_2", "sensor.device_energy_import_tariff_3", "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", "sensor.device_voltage_sags_detected_phase_1", "sensor.device_voltage_sags_detected_phase_2", "sensor.device_voltage_sags_detected_phase_3", "sensor.device_voltage_swells_detected_phase_1", "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_voltage", + "sensor.device_water_usage", ], ), ], From f82fb63dce901608437d60d6d263a01ddb2ba3e8 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Mon, 29 Jan 2024 15:08:11 +0100 Subject: [PATCH 1105/1544] Add bring integration (#108027) * add bring integration * fix typings and remove from strictly typed - wait for python-bring-api to be ready for strictly typed * make entity unique to user and list - before it was only list, therefore the same list imported by two users would have failed * simplify bring attribute Co-authored-by: Joost Lekkerkerker * cleanup and code simplification * remove empty fields in manifest * __init__.py aktualisieren Co-authored-by: Joost Lekkerkerker * __init__.py aktualisieren Co-authored-by: Joost Lekkerkerker * strings.json aktualisieren Co-authored-by: Joost Lekkerkerker * streamline async calls * use coordinator refresh * fix order in update call and simplify bring list * simplify the config_flow * Update homeassistant/components/bring/manifest.json Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * add unit testing for __init__.py * cleanup comments * use dict instead of list * Update homeassistant/components/bring/todo.py Co-authored-by: Joost Lekkerkerker * clean up * update attribute name * update more attribute name * improve unit tests - remove patch and use mock in conftest * clean up tests even more * more unit test inprovements * remove optional type * minor unit test cleanup * Update .coveragerc Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/bring/__init__.py | 76 ++++++++ homeassistant/components/bring/config_flow.py | 75 ++++++++ homeassistant/components/bring/const.py | 3 + homeassistant/components/bring/coordinator.py | 66 +++++++ homeassistant/components/bring/manifest.json | 10 + homeassistant/components/bring/strings.json | 20 ++ homeassistant/components/bring/todo.py | 173 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/bring/__init__.py | 1 + tests/components/bring/conftest.py | 49 +++++ tests/components/bring/test_config_flow.py | 111 +++++++++++ tests/components/bring/test_init.py | 63 +++++++ 17 files changed, 664 insertions(+) create mode 100644 homeassistant/components/bring/__init__.py create mode 100644 homeassistant/components/bring/config_flow.py create mode 100644 homeassistant/components/bring/const.py create mode 100644 homeassistant/components/bring/coordinator.py create mode 100644 homeassistant/components/bring/manifest.json create mode 100644 homeassistant/components/bring/strings.json create mode 100644 homeassistant/components/bring/todo.py create mode 100644 tests/components/bring/__init__.py create mode 100644 tests/components/bring/conftest.py create mode 100644 tests/components/bring/test_config_flow.py create mode 100644 tests/components/bring/test_init.py diff --git a/.coveragerc b/.coveragerc index e20b26ff182..78420ac5836 100644 --- a/.coveragerc +++ b/.coveragerc @@ -150,6 +150,8 @@ omit = homeassistant/components/braviatv/coordinator.py homeassistant/components/braviatv/media_player.py homeassistant/components/braviatv/remote.py + homeassistant/components/bring/coordinator.py + homeassistant/components/bring/todo.py homeassistant/components/broadlink/climate.py homeassistant/components/broadlink/light.py homeassistant/components/broadlink/remote.py diff --git a/CODEOWNERS b/CODEOWNERS index 56148d9e1be..1fb5f2d7e55 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -180,6 +180,8 @@ build.json @home-assistant/supervisor /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed +/homeassistant/components/bring/ @miaucl @tr4nt0r +/tests/components/bring/ @miaucl @tr4nt0r /homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /homeassistant/components/brother/ @bieniu diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py new file mode 100644 index 00000000000..e9501fc64b3 --- /dev/null +++ b/homeassistant/components/bring/__init__.py @@ -0,0 +1,76 @@ +"""The Bring! integration.""" +from __future__ import annotations + +import logging + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import ( + BringAuthException, + BringParseException, + BringRequestException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import BringDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Bring! from a config entry.""" + + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + bring = Bring(email, password) + + def login_and_load_lists() -> None: + bring.login() + bring.loadLists() + + try: + await hass.async_add_executor_job(login_and_load_lists) + except BringRequestException as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for email '{email}'" + ) from e + except BringAuthException as e: + _LOGGER.error( + "Authentication failed for '%s', check your email and password", + email, + ) + raise ConfigEntryError( + f"Authentication failed for '{email}', check your email and password" + ) from e + except BringParseException as e: + _LOGGER.error( + "Failed to parse request '%s', check your email and password", + email, + ) + raise ConfigEntryNotReady( + "Failed to parse response request from server, try again later" + ) from e + + coordinator = BringDataUpdateCoordinator(hass, bring) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py new file mode 100644 index 00000000000..21774117ff6 --- /dev/null +++ b/homeassistant/components/bring/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Bring! integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import BringAuthException, BringRequestException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bring!.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) + + def login_and_load_lists() -> None: + bring.login() + bring.loadLists() + + try: + await self.hass.async_add_executor_job(login_and_load_lists) + except BringRequestException: + errors["base"] = "cannot_connect" + except BringAuthException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(bring.uuid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py new file mode 100644 index 00000000000..64a6ec67f85 --- /dev/null +++ b/homeassistant/components/bring/const.py @@ -0,0 +1,3 @@ +"""Constants for the Bring! integration.""" + +DOMAIN = "bring" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py new file mode 100644 index 00000000000..a7bd4a35f43 --- /dev/null +++ b/homeassistant/components/bring/coordinator.py @@ -0,0 +1,66 @@ +"""DataUpdateCoordinator for the Bring! integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from python_bring_api.bring import Bring +from python_bring_api.exceptions import BringParseException, BringRequestException +from python_bring_api.types import BringItemsResponse, BringList + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BringData(BringList): + """Coordinator data class.""" + + items: list[BringItemsResponse] + + +class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): + """A Bring Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, bring: Bring) -> None: + """Initialize the Bring data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + ) + self.bring = bring + + async def _async_update_data(self) -> dict[str, BringData]: + try: + lists_response = await self.hass.async_add_executor_job( + self.bring.loadLists + ) + except BringRequestException as e: + raise UpdateFailed("Unable to connect and retrieve data from bring") from e + except BringParseException as e: + raise UpdateFailed("Unable to parse response from bring") from e + + list_dict = {} + for lst in lists_response["lists"]: + try: + items = await self.hass.async_add_executor_job( + self.bring.getItems, lst["listUuid"] + ) + except BringRequestException as e: + raise UpdateFailed( + "Unable to connect and retrieve data from bring" + ) from e + except BringParseException as e: + raise UpdateFailed("Unable to parse response from bring") from e + lst["items"] = items["purchase"] + list_dict[lst["listUuid"]] = lst + + return list_dict diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json new file mode 100644 index 00000000000..bc249ecea98 --- /dev/null +++ b/homeassistant/components/bring/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bring", + "name": "Bring!", + "codeowners": ["@miaucl", "@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bring", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["python-bring-api==2.0.0"] +} diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json new file mode 100644 index 00000000000..de3677bf5f1 --- /dev/null +++ b/homeassistant/components/bring/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py new file mode 100644 index 00000000000..bd87a2d18de --- /dev/null +++ b/homeassistant/components/bring/todo.py @@ -0,0 +1,173 @@ +"""Todo platform for the Bring! integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_bring_api.exceptions import BringRequestException + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +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.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BringData, BringDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + unique_id = config_entry.unique_id + + if TYPE_CHECKING: + assert unique_id + + async_add_entities( + BringTodoListEntity( + coordinator, + bring_list=bring_list, + unique_id=unique_id, + ) + for bring_list in coordinator.data.values() + ) + + +class BringTodoListEntity( + CoordinatorEntity[BringDataUpdateCoordinator], TodoListEntity +): + """A To-do List representation of the Bring! Shopping List.""" + + _attr_icon = "mdi:cart" + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringData, + unique_id: str, + ) -> None: + """Initialize BringTodoListEntity.""" + super().__init__(coordinator) + self._list_uuid = bring_list["listUuid"] + self._attr_name = bring_list["name"] + self._attr_unique_id = f"{unique_id}_{self._list_uuid}" + + @property + def todo_items(self) -> list[TodoItem]: + """Return the todo items.""" + return [ + TodoItem( + uid=item["name"], + summary=item["name"], + description=item["specification"] or "", + status=TodoItemStatus.NEEDS_ACTION, + ) + for item in self.bring_list["items"] + ] + + @property + def bring_list(self) -> BringData: + """Return the bring list.""" + return self.coordinator.data[self._list_uuid] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.bring.saveItem, + self.bring_list["listUuid"], + item.summary, + item.description or "", + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to save todo item for bring") from e + + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list. + + Bring has an internal 'recent' list which we want to use instead of a todo list + status, therefore completed todo list items will directly be deleted + + This results in following behaviour: + + - Completed items will move to the "completed" section in home assistant todo + list and get deleted in bring, which will remove them from the home + assistant todo list completely after a short delay + - Bring items do not have unique identifiers and are using the + name/summery/title. Therefore the name is not to be changed! Should a name + be changed anyway, a new item will be created instead and no update for + this item is performed and on the next cloud pull update, it will get + cleared + """ + + bring_list = self.bring_list + + if TYPE_CHECKING: + assert item.uid + + if item.status == TodoItemStatus.COMPLETED: + await self.hass.async_add_executor_job( + self.coordinator.bring.removeItem, + bring_list["listUuid"], + item.uid, + ) + + elif item.summary == item.uid: + try: + await self.hass.async_add_executor_job( + self.coordinator.bring.updateItem, + bring_list["listUuid"], + item.uid, + item.description or "", + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to update todo item for bring") from e + else: + try: + await self.hass.async_add_executor_job( + self.coordinator.bring.removeItem, + bring_list["listUuid"], + item.uid, + ) + await self.hass.async_add_executor_job( + self.coordinator.bring.saveItem, + bring_list["listUuid"], + item.summary, + item.description or "", + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to replace todo item for bring") from e + + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item from the To-do list.""" + for uid in uids: + try: + await self.hass.async_add_executor_job( + self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to delete todo item for bring") from e + + await self.coordinator.async_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 17d4e6bcfa7..7c3e8a78940 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = { "bond", "bosch_shc", "braviatv", + "bring", "broadlink", "brother", "brottsplatskartan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9cd0ad8785b..aa2ba3eae9c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -732,6 +732,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "bring": { + "name": "Bring!", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "broadlink": { "name": "Broadlink", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index b50feb91260..9c66b6ce866 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2177,6 +2177,9 @@ python-awair==0.2.4 # homeassistant.components.blockchain python-blockchain-api==0.0.2 +# homeassistant.components.bring +python-bring-api==2.0.0 + # homeassistant.components.bsblan python-bsblan==0.5.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57e69672dd1..196faa6ded6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1677,6 +1677,9 @@ python-MotionMount==0.3.1 # homeassistant.components.awair python-awair==0.2.4 +# homeassistant.components.bring +python-bring-api==2.0.0 + # homeassistant.components.bsblan python-bsblan==0.5.18 diff --git a/tests/components/bring/__init__.py b/tests/components/bring/__init__.py new file mode 100644 index 00000000000..1b13247f52e --- /dev/null +++ b/tests/components/bring/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bring! integration.""" diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py new file mode 100644 index 00000000000..f8749d3dea9 --- /dev/null +++ b/tests/components/bring/conftest.py @@ -0,0 +1,49 @@ +"""Common fixtures for the Bring! tests.""" +from collections.abc import Generator +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.bring import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + +EMAIL = "test-email" +PASSWORD = "test-password" + +UUID = "00000000-00000000-00000000-00000000" + + +@pytest.fixture +def mock_setup_entry() -> Generator[Mock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bring.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_bring_client() -> Generator[Mock, None, None]: + """Mock a Bring client.""" + with patch( + "homeassistant.components.bring.Bring", + autospec=True, + ) as mock_client, patch( + "homeassistant.components.bring.config_flow.Bring", + new=mock_client, + ): + client = mock_client.return_value + client.uuid = UUID + client.login.return_value = True + client.loadLists.return_value = {"lists": []} + yield client + + +@pytest.fixture(name="bring_config_entry") +def mock_bring_config_entry() -> MockConfigEntry: + """Mock bring configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_EMAIL: EMAIL, CONF_PASSWORD: PASSWORD}, unique_id=UUID + ) diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py new file mode 100644 index 00000000000..063d84a0e97 --- /dev/null +++ b/tests/components/bring/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Bring! config flow.""" +from unittest.mock import AsyncMock, Mock + +import pytest +from python_bring_api.exceptions import ( + BringAuthException, + BringParseException, + BringRequestException, +) + +from homeassistant import config_entries +from homeassistant.components.bring.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import EMAIL, PASSWORD + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_EMAIL: EMAIL, + CONF_PASSWORD: PASSWORD, +} + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bring_client: Mock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DATA_STEP["email"] + assert result["data"] == MOCK_DATA_STEP + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, mock_bring_client: Mock, raise_error, text_error +) -> None: + """Test unknown errors.""" + mock_bring_client.login.side_effect = raise_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == text_error + + # Recover + mock_bring_client.login.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == MOCK_DATA_STEP["email"] + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured( + hass: HomeAssistant, mock_bring_client: Mock, bring_config_entry: MockConfigEntry +) -> None: + """Test we abort user data set when entry is already configured.""" + + bring_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py new file mode 100644 index 00000000000..3c605143ba0 --- /dev/null +++ b/tests/components/bring/test_init.py @@ -0,0 +1,63 @@ +"""Unit tests for the bring integration.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.components.bring import ( + BringAuthException, + BringParseException, + BringRequestException, +) +from homeassistant.components.bring.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the bring integration.""" + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_load_unload( + hass: HomeAssistant, + mock_bring_client: Mock, + bring_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading of the config entry.""" + await setup_integration(hass, bring_config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert bring_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(bring_config_entry.entry_id) + assert bring_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (BringRequestException, ConfigEntryState.SETUP_RETRY), + (BringAuthException, ConfigEntryState.SETUP_ERROR), + (BringParseException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_failure( + hass: HomeAssistant, + mock_bring_client: Mock, + status: ConfigEntryState, + exception: Exception, + bring_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + mock_bring_client.login.side_effect = exception + await setup_integration(hass, bring_config_entry) + assert bring_config_entry.state == status From 6cf48068b57879315554a43468b82f83c6bb3f3c Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Tue, 30 Jan 2024 01:52:20 +1100 Subject: [PATCH 1106/1544] Bump aio-geojson-nsw-rfs-incidents to 0.7 (#108885) --- .../components/nsw_rural_fire_service_feed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index cea62996e6d..9d1f60e33d1 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_nsw_rfs_incidents"], - "requirements": ["aio-geojson-nsw-rfs-incidents==0.6"] + "requirements": ["aio-geojson-nsw-rfs-incidents==0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c66b6ce866..78a676da7a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-geojson-geonetnz-quakes==0.15 aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio-geojson-nsw-rfs-incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.7 # homeassistant.components.usgs_earthquakes_feed aio-geojson-usgs-earthquakes==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 196faa6ded6..eac1e9ad401 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -155,7 +155,7 @@ aio-geojson-geonetnz-quakes==0.15 aio-geojson-geonetnz-volcano==0.8 # homeassistant.components.nsw_rural_fire_service_feed -aio-geojson-nsw-rfs-incidents==0.6 +aio-geojson-nsw-rfs-incidents==0.7 # homeassistant.components.usgs_earthquakes_feed aio-geojson-usgs-earthquakes==0.2 From 075dab250edf97a3c1ed4f4333872097c228a489 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 29 Jan 2024 16:02:51 +0100 Subject: [PATCH 1107/1544] Code quality for Shelly tests (#109054) * Code quality for Shelly tests * clean-up --- tests/components/shelly/__init__.py | 9 +++++- tests/components/shelly/conftest.py | 7 +++-- tests/components/shelly/test_coordinator.py | 34 +++++++++++---------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 26040e13557..daf96db13d3 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -74,7 +74,7 @@ def mutate_rpc_device_status( def inject_rpc_device_event( monkeypatch: pytest.MonkeyPatch, mock_rpc_device: Mock, - event: dict[str, dict[str, Any]], + event: Mapping[str, list[dict[str, Any]] | float], ) -> None: """Inject event for rpc device.""" monkeypatch.setattr(mock_rpc_device, "event", event) @@ -121,6 +121,13 @@ def register_entity( return f"{domain}.{object_id}" +def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: + """Return entity state.""" + entity = hass.states.get(entity_id) + assert entity + return entity.state + + def register_device(device_reg, config_entry: ConfigEntry): """Register Shelly device.""" device_reg.async_get_or_create( diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8a863a852f5..af373f33c23 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -12,6 +12,7 @@ from homeassistant.components.shelly.const import ( EVENT_SHELLY_CLICK, REST_SENSORS_UPDATE_INTERVAL, ) +from homeassistant.core import HomeAssistant from . import MOCK_MAC @@ -252,19 +253,19 @@ def mock_ws_server(): @pytest.fixture -def device_reg(hass): +def device_reg(hass: HomeAssistant): """Return an empty, loaded, registry.""" return mock_device_registry(hass) @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture -def events(hass): +def events(hass: HomeAssistant): """Yield caught shelly_click events.""" return async_capture_events(hass, EVENT_SHELLY_CLICK) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 27aa8710621..8e288ba1687 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -33,6 +33,7 @@ import homeassistant.helpers.issue_registry as ir from . import ( MOCK_MAC, + get_entity_state, init_integration, inject_rpc_device_event, mock_polling_rpc_update, @@ -196,14 +197,14 @@ async def test_block_polling_connection_error( ) await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_ON # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1").state == STATE_UNAVAILABLE + assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE async def test_block_rest_update_connection_error( @@ -216,7 +217,7 @@ async def test_block_rest_update_connection_error( await init_integration(hass, 1) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_ON + assert get_entity_state(hass, entity_id) == STATE_ON monkeypatch.setattr( mock_block_device, @@ -225,7 +226,7 @@ async def test_block_rest_update_connection_error( ) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( @@ -239,14 +240,14 @@ async def test_block_sleeping_device_no_periodic_updates( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.1" + assert get_entity_state(hass, entity_id) == "22.1" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_block_device_push_updates_failure( @@ -496,14 +497,14 @@ async def test_rpc_sleeping_device_no_periodic_updates( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + 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() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_rpc_reconnect_auth_error( @@ -581,7 +582,7 @@ async def test_rpc_reconnect_error( """Test RPC reconnect error.""" await init_integration(hass, 2) - assert hass.states.get("switch.test_switch_0").state == STATE_ON + assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr( @@ -597,7 +598,7 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0").state == STATE_UNAVAILABLE + assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE async def test_rpc_polling_connection_error( @@ -615,11 +616,11 @@ async def test_rpc_polling_connection_error( ), ) - assert hass.states.get(entity_id).state == "-63" + assert get_entity_state(hass, entity_id) == "-63" await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( @@ -631,11 +632,11 @@ async def test_rpc_polling_disconnected( monkeypatch.setattr(mock_rpc_device, "connected", False) - assert hass.states.get(entity_id).state == "-63" + assert get_entity_state(hass, entity_id) == "-63" await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE async def test_rpc_update_entry_fw_ver( @@ -649,11 +650,12 @@ async def test_rpc_update_entry_fw_ver( mock_rpc_device.mock_update() await hass.async_block_till_done() + assert entry.unique_id device = dev_reg.async_get_device( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) - + assert device assert device.sw_version == "some fw string" monkeypatch.setattr(mock_rpc_device, "firmware_version", "99.0.0") @@ -665,5 +667,5 @@ async def test_rpc_update_entry_fw_ver( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) - + assert device assert device.sw_version == "99.0.0" From f456e3a0718abd6b79ab4b484e2ebcf139221f6d Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 29 Jan 2024 11:09:23 -0500 Subject: [PATCH 1108/1544] Allow delete_all_refresh_tokens to delete a specific token_type (#106119) * Allow delete_all_refresh_tokens to delete a specific token_type * add a test * minor string change * test updates * more test updates * more test updates * fix tests * do not delete current token * Update tests/components/auth/test_init.py * Update tests/components/auth/test_init.py * Option to not delete the current token --------- Co-authored-by: J. Nick Koston --- homeassistant/components/auth/__init__.py | 15 +++++- tests/components/auth/test_init.py | 63 ++++++++++++++++++++--- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index f4a59f13486..f97647fff0e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -604,6 +604,8 @@ async def websocket_delete_refresh_token( @websocket_api.websocket_command( { vol.Required("type"): "auth/delete_all_refresh_tokens", + vol.Optional("token_type"): cv.string, + vol.Optional("delete_current_token", default=True): bool, } ) @websocket_api.ws_require_user() @@ -614,6 +616,10 @@ async def websocket_delete_all_refresh_tokens( """Handle delete all refresh tokens request.""" current_refresh_token: RefreshToken remove_failed = False + token_type = msg.get("token_type") + delete_current_token = msg.get("delete_current_token") + limit_token_types = token_type is not None + for token in list(connection.user.refresh_tokens.values()): if token.id == connection.refresh_token_id: # Skip the current refresh token as it has revoke_callback, @@ -621,6 +627,8 @@ async def websocket_delete_all_refresh_tokens( # It will be removed after sending the result. current_refresh_token = token continue + if limit_token_types and token_type != token.token_type: + continue try: hass.auth.async_remove_refresh_token(token) except Exception as err: # pylint: disable=broad-except @@ -637,8 +645,11 @@ async def websocket_delete_all_refresh_tokens( else: connection.send_result(msg["id"], {}) - # This will close the connection so we need to send the result first. - hass.loop.call_soon(hass.auth.async_remove_refresh_token, current_refresh_token) + if delete_current_token and ( + not limit_token_types or current_refresh_token.token_type == token_type + ): + # This will close the connection so we need to send the result first. + hass.loop.call_soon(hass.auth.async_remove_refresh_token, current_refresh_token) @websocket_api.websocket_command( diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 666ee4cac07..3926cd2f82b 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -8,7 +8,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError -from homeassistant.auth.models import Credentials +from homeassistant.auth.models import ( + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + TOKEN_TYPE_NORMAL, + Credentials, +) from homeassistant.components import auth from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -567,22 +571,50 @@ async def test_ws_delete_all_refresh_tokens_error( assert refresh_token is None +@pytest.mark.parametrize( + ( + "delete_token_type", + "delete_current_token", + "expected_remaining_normal_tokens", + "expected_remaining_long_lived_tokens", + ), + [ + ({}, {}, 0, 0), + ({"token_type": TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN}, {}, 3, 0), + ({"token_type": TOKEN_TYPE_NORMAL}, {}, 0, 1), + ({"token_type": TOKEN_TYPE_NORMAL}, {"delete_current_token": False}, 1, 1), + ], +) async def test_ws_delete_all_refresh_tokens( hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials, hass_ws_client: WebSocketGenerator, hass_access_token: str, + delete_token_type: dict[str:str], + delete_current_token: dict[str:bool], + expected_remaining_normal_tokens: int, + expected_remaining_long_lived_tokens: int, ) -> None: - """Test deleting all refresh tokens.""" + """Test deleting all or some refresh tokens.""" assert await async_setup_component(hass, "auth", {"http": {}}) # one token already exists await hass.auth.async_create_refresh_token( hass_admin_user, CLIENT_ID, credential=hass_admin_credential ) + + # create a long lived token await hass.auth.async_create_refresh_token( - hass_admin_user, CLIENT_ID + "_1", credential=hass_admin_credential + hass_admin_user, + f"{CLIENT_ID}_LL", + client_name="client_ll", + credential=hass_admin_credential, + token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + ) + + await hass.auth.async_create_refresh_token( + hass_admin_user, f"{CLIENT_ID}_1", credential=hass_admin_credential ) ws_client = await hass_ws_client(hass, hass_access_token) @@ -592,20 +624,35 @@ async def test_ws_delete_all_refresh_tokens( result = await ws_client.receive_json() assert result["success"], result - tokens = result["result"] - await ws_client.send_json( { "id": 6, "type": "auth/delete_all_refresh_tokens", + **delete_token_type, + **delete_current_token, } ) result = await ws_client.receive_json() assert result, result["success"] - for token in tokens: - refresh_token = hass.auth.async_get_refresh_token(token["id"]) - assert refresh_token is None + + # We need to enumerate the user since we may remove the token + # that is used to authenticate the user which will prevent the websocket + # connection from working + remaining_tokens_by_type: dict[str, int] = { + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN: 0, + TOKEN_TYPE_NORMAL: 0, + } + for refresh_token in hass_admin_user.refresh_tokens.values(): + remaining_tokens_by_type[refresh_token.token_type] += 1 + + assert ( + remaining_tokens_by_type[TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN] + == expected_remaining_long_lived_tokens + ) + assert ( + remaining_tokens_by_type[TOKEN_TYPE_NORMAL] == expected_remaining_normal_tokens + ) async def test_ws_sign_path( From be8af7bea304d7c978c34f5e390034cbd335f836 Mon Sep 17 00:00:00 2001 From: kpine Date: Mon, 29 Jan 2024 09:04:05 -0800 Subject: [PATCH 1109/1544] Fix zwave_js set_config_parameter WS api regression (#109042) --- homeassistant/components/zwave_js/api.py | 2 +- tests/components/zwave_js/test_api.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 7f4855bfbe5..8d14c8ed5b6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1693,7 +1693,7 @@ async def websocket_set_config_parameter( msg[ID], { VALUE_ID: zwave_value.value_id, - STATUS: cmd_status, + STATUS: cmd_status.status, }, ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index aa20bd3bb84..bf5ad88447e 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2794,6 +2794,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] @@ -2826,6 +2827,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] @@ -2857,6 +2859,7 @@ async def test_set_config_parameter( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"]["status"] == "queued" assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args[0][0] From b386960661302d5132bdeb2517a6453987b13b90 Mon Sep 17 00:00:00 2001 From: Martijn van der Pol Date: Mon, 29 Jan 2024 18:05:44 +0100 Subject: [PATCH 1110/1544] Add default parameter to as_datetime template function/filter (#107229) * improve as_datetime * Improve `as_datetime` Jinja filter/function * review * resolve more review items * change test for datetime input * Update docstring * update docstrings for tests * remove whitespace * only_default * Update do string and comment * improve comment * Adjust comment --------- Co-authored-by: Martin Hjelmare --- homeassistant/helpers/template.py | 16 +++++-- tests/helpers/test_template.py | 79 +++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7c1113bdda8..8d837bc9bc6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1836,14 +1836,24 @@ def forgiving_as_timestamp(value, default=_SENTINEL): return default -def as_datetime(value): +def as_datetime(value: Any, default: Any = _SENTINEL) -> Any: """Filter and to convert a time string or UNIX timestamp to datetime object.""" try: # Check for a valid UNIX timestamp string, int or float timestamp = float(value) return dt_util.utc_from_timestamp(timestamp) - except ValueError: - return dt_util.parse_datetime(value) + except (ValueError, TypeError): + # Try to parse datetime string to datetime object + try: + return dt_util.parse_datetime(value, raise_on_error=True) + except (ValueError, TypeError): + if default is _SENTINEL: + # Return None on string input + # to ensure backwards compatibility with HA Core 2024.1 and before. + if isinstance(value, str): + return None + raise_no_default("as_datetime", value) + return default def as_timedelta(value: str) -> timedelta | None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 90af925ddca..955cd2fd65e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1151,7 +1151,6 @@ def test_as_datetime(hass: HomeAssistant, input) -> None: expected = dt_util.parse_datetime(input) if expected is not None: expected = str(expected) - assert ( template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() == expected @@ -1162,34 +1161,64 @@ def test_as_datetime(hass: HomeAssistant, input) -> None: ) -def test_as_datetime_from_timestamp(hass: HomeAssistant) -> None: - """Test converting a UNIX timestamp to a date object.""" - tests = [ +@pytest.mark.parametrize( + ("input", "output"), + [ (1469119144, "2016-07-21 16:39:04+00:00"), (1469119144.0, "2016-07-21 16:39:04+00:00"), (-1, "1969-12-31 23:59:59+00:00"), - ] - for input, output in tests: - # expected = dt_util.parse_datetime(input) - if output is not None: - output = str(output) + ], +) +def test_as_datetime_from_timestamp( + hass: HomeAssistant, + input: int | float, + output: str, +) -> None: + """Test converting a UNIX timestamp to a date object.""" + assert ( + template.Template(f"{{{{ as_datetime({input}) }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ {input} | as_datetime }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() + == output + ) + assert ( + template.Template(f"{{{{ '{input}' | as_datetime }}}}", hass).async_render() + == output + ) - assert ( - template.Template(f"{{{{ as_datetime({input}) }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ {input} | as_datetime }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ as_datetime('{input}') }}}}", hass).async_render() - == output - ) - assert ( - template.Template(f"{{{{ '{input}' | as_datetime }}}}", hass).async_render() - == output - ) + +@pytest.mark.parametrize( + ("input", "default", "output"), + [ + (1469119144, 123, "2016-07-21 16:39:04+00:00"), + ('"invalid"', ["default output"], ["default output"]), + (["a", "list"], 0, 0), + ({"a": "dict"}, None, None), + ], +) +def test_as_datetime_default( + hass: HomeAssistant, input: Any, default: Any, output: str +) -> None: + """Test invalid input and return default value.""" + + assert ( + template.Template( + f"{{{{ as_datetime({input}, default={default}) }}}}", hass + ).async_render() + == output + ) + assert ( + template.Template( + f"{{{{ {input} | as_datetime({default}) }}}}", hass + ).async_render() + == output + ) def test_as_local(hass: HomeAssistant) -> None: From 0a0d4c37a900cf02dcc8f7a7584573b18add7f7b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 29 Jan 2024 18:09:00 +0100 Subject: [PATCH 1111/1544] Use constants instead of literals for api (#105955) --- homeassistant/components/api/__init__.py | 43 +++++++++++++----------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a27c386de43..d012dfc372f 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -13,7 +13,12 @@ import voluptuous as vol from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING -from homeassistant.components.http import HomeAssistantView, require_admin +from homeassistant.components.http import ( + KEY_HASS, + KEY_HASS_USER, + HomeAssistantView, + require_admin, +) from homeassistant.const import ( CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, @@ -112,7 +117,7 @@ class APICoreStateView(HomeAssistantView): Home Assistant core is running. Its primary use case is for supervisor to check if Home Assistant is running. """ - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return self.json({"state": hass.state.value}) @@ -125,7 +130,7 @@ class APIEventStream(HomeAssistantView): @require_admin async def get(self, request: web.Request) -> web.StreamResponse: """Provide a streaming interface for the event bus.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] stop_obj = object() to_write: asyncio.Queue[object | str] = asyncio.Queue() @@ -192,7 +197,7 @@ class APIConfigView(HomeAssistantView): @ha.callback def get(self, request: web.Request) -> web.Response: """Get current configuration.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return self.json(hass.config.as_dict()) @@ -205,8 +210,8 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request: web.Request) -> web.Response: """Get current states.""" - user: User = request["hass_user"] - hass: HomeAssistant = request.app["hass"] + user: User = request[KEY_HASS_USER] + hass: HomeAssistant = request.app[KEY_HASS] if user.is_admin: states = (state.as_dict_json for state in hass.states.async_all()) else: @@ -234,8 +239,8 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request: web.Request, entity_id: str) -> web.Response: """Retrieve state of entity.""" - user: User = request["hass_user"] - hass: HomeAssistant = request.app["hass"] + user: User = request[KEY_HASS_USER] + hass: HomeAssistant = request.app[KEY_HASS] if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) @@ -248,10 +253,10 @@ class APIEntityStateView(HomeAssistantView): async def post(self, request: web.Request, entity_id: str) -> web.Response: """Update state of entity.""" - user: User = request["hass_user"] + user: User = request[KEY_HASS_USER] if not user.is_admin: raise Unauthorized(entity_id=entity_id) - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] try: data = await request.json() except ValueError: @@ -289,9 +294,9 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def delete(self, request: web.Request, entity_id: str) -> web.Response: """Remove entity.""" - if not request["hass_user"].is_admin: + if not request[KEY_HASS_USER].is_admin: raise Unauthorized(entity_id=entity_id) - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] if hass.states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) @@ -306,7 +311,7 @@ class APIEventListenersView(HomeAssistantView): @ha.callback def get(self, request: web.Request) -> web.Response: """Get event listeners.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return self.json(async_events_json(hass)) @@ -341,7 +346,7 @@ class APIEventView(HomeAssistantView): if state: event_data[key] = state - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] hass.bus.async_fire( event_type, event_data, ha.EventOrigin.remote, self.context(request) ) @@ -357,7 +362,7 @@ class APIServicesView(HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Get registered services.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] services = await async_services_json(hass) return self.json(services) @@ -375,7 +380,7 @@ class APIDomainServicesView(HomeAssistantView): Returns a list of changed states. """ - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] body = await request.text() try: data = json_loads(body) if body else None @@ -428,7 +433,7 @@ class APIComponentsView(HomeAssistantView): @ha.callback def get(self, request: web.Request) -> web.Response: """Get current loaded components.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return self.json(list(hass.config.components)) @@ -449,7 +454,7 @@ class APITemplateView(HomeAssistantView): """Render a template.""" try: data = await request.json() - tpl = _cached_template(data["template"], request.app["hass"]) + tpl = _cached_template(data["template"], request.app[KEY_HASS]) return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return] except (ValueError, TemplateError) as ex: return self.json_message( @@ -466,7 +471,7 @@ class APIErrorLog(HomeAssistantView): @require_admin async def get(self, request: web.Request) -> web.FileResponse: """Retrieve API error log.""" - hass: HomeAssistant = request.app["hass"] + hass: HomeAssistant = request.app[KEY_HASS] return web.FileResponse(hass.data[DATA_LOGGING]) From be4631cbf8cbb62844f48bf7123f8cb7528ce41c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Jan 2024 19:15:36 +0100 Subject: [PATCH 1112/1544] Use unique artifact names for db jobs [ci] (#108653) --- .github/workflows/ci.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0b380400031..ecd2737ad72 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -898,6 +898,7 @@ jobs: python --version set -o pipefail mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g") + echo "mariadb=${mariadb}" >> $GITHUB_OUTPUT cov_params=() if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant.components.recorder") @@ -929,7 +930,8 @@ jobs: if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: - name: coverage-${{ matrix.python-version }}-mariadb + name: coverage-${{ matrix.python-version }}-mariadb-${{ + steps.pytest-partial.outputs.mariadb }} path: coverage.xml - name: Check dirty run: | @@ -1022,6 +1024,7 @@ jobs: python --version set -o pipefail postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g") + echo "postgresql=${postgresql}" >> $GITHUB_OUTPUT cov_params=() if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant.components.recorder") @@ -1054,7 +1057,8 @@ jobs: if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.0 with: - name: coverage-${{ matrix.python-version }}-postgresql + name: coverage-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.postgresql }} path: coverage.xml - name: Check dirty run: | From 4170a447fc0f5d0395790576948d3c794aca7058 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 29 Jan 2024 19:26:55 +0100 Subject: [PATCH 1113/1544] Allow system and helper integrations to provide entity_component icons (#109045) --- .../components/automation/icons.json | 18 +++++++++++ script/hassfest/icons.py | 32 ++++++++++--------- 2 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/automation/icons.json diff --git a/homeassistant/components/automation/icons.json b/homeassistant/components/automation/icons.json new file mode 100644 index 00000000000..7a95b4070d5 --- /dev/null +++ b/homeassistant/components/automation/icons.json @@ -0,0 +1,18 @@ +{ + "entity_component": { + "_": { + "default": "mdi:robot", + "state": { + "off": "mdi:robot-off", + "unavailable": "mdi:robot-confused" + } + } + }, + "services": { + "turn_on": "mdi:robot", + "turn_off": "mdi:robot-off", + "toggle": "mdi:robot", + "trigger": "mdi:robot", + "reload": "mdi:robot" + } +} diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index f5e07a0d348..2b28312284a 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -70,14 +70,14 @@ def icon_schema(integration_type: str) -> vol.Schema: ), } - base_schema = vol.Schema( + schema = vol.Schema( { vol.Optional("services"): state_validator, } ) - if integration_type == "entity": - return base_schema.extend( + if integration_type in ("entity", "helper", "system"): + schema = schema.extend( { vol.Required("entity_component"): vol.All( cv.schema_with_slug_keys( @@ -89,20 +89,22 @@ def icon_schema(integration_type: str) -> vol.Schema: ) } ) - return base_schema.extend( - { - vol.Optional("entity"): vol.All( - cv.schema_with_slug_keys( + if integration_type not in ("entity", "system"): + schema = schema.extend( + { + vol.Optional("entity"): vol.All( cv.schema_with_slug_keys( - icon_schema_slug(vol.Optional), - slug_validator=translation_key_validator, + cv.schema_with_slug_keys( + icon_schema_slug(vol.Optional), + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, ), - slug_validator=cv.slug, - ), - ensure_not_same_as_default, - ) - } - ) + ensure_not_same_as_default, + ) + } + ) + return schema def validate_icon_file(config: Config, integration: Integration) -> None: # noqa: C901 From a6b426ca3e289c27e894d5b803672d7d44a9c993 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 08:33:10 -1000 Subject: [PATCH 1114/1544] Add discovery support for the 2023 pro check model to mopkea (#109033) --- homeassistant/components/mopeka/manifest.json | 6 ++++++ homeassistant/generated/bluetooth.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 766af715485..69452bf1fec 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -13,6 +13,12 @@ "manufacturer_id": 89, "manufacturer_data_start": [8], "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [12], + "connectable": false } ], "codeowners": ["@bdraco"], diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 9e3504efca0..7d32dbfe963 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -349,6 +349,15 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 12, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "domain": "oralb", "manufacturer_id": 220, From aefae5bdae00a82520a3e24e6e65c3da5adb3df6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Jan 2024 19:34:53 +0100 Subject: [PATCH 1115/1544] Prepare Analytics insights for more sensors (#108976) --- .../components/analytics_insights/__init__.py | 4 +- .../analytics_insights/coordinator.py | 17 ++++-- .../components/analytics_insights/icons.json | 9 +++ .../components/analytics_insights/sensor.py | 60 ++++++++++++++----- .../snapshots/test_sensor.ambr | 6 +- 5 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/analytics_insights/icons.json diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 1c5118ca004..a59d2a1c97c 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -17,7 +17,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] @dataclass(frozen=True) -class AnalyticsData: +class AnalyticsInsightsData: """Analytics data class.""" coordinator: HomeassistantAnalyticsDataUpdateCoordinator @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsData( + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsInsightsData( coordinator=coordinator, names=names ) diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index f8eefe7db27..0c2a0f16aa9 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -1,6 +1,7 @@ """DataUpdateCoordinator for the Homeassistant Analytics integration.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from python_homeassistant_analytics import ( @@ -16,9 +17,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN, LOGGER -class HomeassistantAnalyticsDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, int]] -): +@dataclass(frozen=True, kw_only=True) +class AnalyticsData: + """Analytics data class.""" + + core_integrations: dict[str, int] + + +class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[AnalyticsData]): """A Homeassistant Analytics Data Update Coordinator.""" config_entry: ConfigEntry @@ -38,7 +44,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator( CONF_TRACKED_INTEGRATIONS ] - async def _async_update_data(self) -> dict[str, int]: + async def _async_update_data(self) -> AnalyticsData: try: data = await self._client.get_current_analytics() except HomeassistantAnalyticsConnectionError as err: @@ -47,7 +53,8 @@ class HomeassistantAnalyticsDataUpdateCoordinator( ) from err except HomeassistantAnalyticsNotModifiedError: return self.data - return { + core_integrations = { integration: data.integrations.get(integration, 0) for integration in self._tracked_integrations } + return AnalyticsData(core_integrations=core_integrations) diff --git a/homeassistant/components/analytics_insights/icons.json b/homeassistant/components/analytics_insights/icons.json new file mode 100644 index 00000000000..b1358e478b4 --- /dev/null +++ b/homeassistant/components/analytics_insights/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "core_integrations": { + "default": "mdi:puzzle" + } + } + } +} diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index f8a8b244c60..ae24abd8b07 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -1,16 +1,45 @@ """Sensor for Home Assistant analytics.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AnalyticsData +from . import AnalyticsInsightsData from .const import DOMAIN -from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator +from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class AnalyticsSensorEntityDescription(SensorEntityDescription): + """Analytics sensor entity description.""" + + value_fn: Callable[[AnalyticsData], StateType] + + +def get_core_integration_entity_description( + domain: str, name: str +) -> AnalyticsSensorEntityDescription: + """Get core integration entity description.""" + return AnalyticsSensorEntityDescription( + key=f"core_{domain}_active_installations", + translation_key="core_integrations", + name=name, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="active installations", + value_fn=lambda data: data.core_integrations.get(domain), + ) async def async_setup_entry( @@ -20,14 +49,15 @@ async def async_setup_entry( ) -> None: """Initialize the entries.""" - analytics_data: AnalyticsData = hass.data[DOMAIN][entry.entry_id] + analytics_data: AnalyticsInsightsData = hass.data[DOMAIN][entry.entry_id] async_add_entities( HomeassistantAnalyticsSensor( analytics_data.coordinator, - integration_domain, - analytics_data.names[integration_domain], + get_core_integration_entity_description( + integration_domain, analytics_data.names[integration_domain] + ), ) - for integration_domain in analytics_data.coordinator.data + for integration_domain in analytics_data.coordinator.data.core_integrations ) @@ -37,26 +67,24 @@ class HomeassistantAnalyticsSensor( """Home Assistant Analytics Sensor.""" _attr_has_entity_name = True - _attr_state_class = SensorStateClass.TOTAL - _attr_native_unit_of_measurement = "active installations" + + entity_description: AnalyticsSensorEntityDescription def __init__( self, coordinator: HomeassistantAnalyticsDataUpdateCoordinator, - integration_domain: str, - name: str, + entity_description: AnalyticsSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = name - self._attr_unique_id = f"core_{integration_domain}_active_installations" + self.entity_description = entity_description + self._attr_unique_id = entity_description.key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, DOMAIN)}, entry_type=DeviceEntryType.SERVICE, ) - self._integration_domain = integration_domain @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.coordinator.data.get(self._integration_domain) + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 8a9688cb60c..404850baa4e 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -27,7 +27,7 @@ 'platform': 'analytics_insights', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'core_integrations', 'unique_id': 'core_myq_active_installations', 'unit_of_measurement': 'active installations', }) @@ -74,7 +74,7 @@ 'platform': 'analytics_insights', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'core_integrations', 'unique_id': 'core_spotify_active_installations', 'unit_of_measurement': 'active installations', }) @@ -121,7 +121,7 @@ 'platform': 'analytics_insights', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'core_integrations', 'unique_id': 'core_youtube_active_installations', 'unit_of_measurement': 'active installations', }) From 80bfd4cef77815db0f58b7f70b297b769ff495a6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 29 Jan 2024 20:21:35 +0100 Subject: [PATCH 1116/1544] Raise ValueError when `last_reset` set and not `total` state class (#108391) * Raise ValueError when last_reset set and not total state class * Fix test * Break long string into smaller ones --- homeassistant/components/sensor/__init__.py | 21 ++++++--------------- tests/components/sensor/test_init.py | 5 ++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 694af903a3e..45d8c8b3c06 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -416,21 +416,12 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return state attributes.""" if last_reset := self.last_reset: state_class = self.state_class - if state_class != SensorStateClass.TOTAL and not self._last_reset_reported: - self._last_reset_reported = True - report_issue = self._suggest_report_issue() - # This should raise in Home Assistant Core 2022.5 - _LOGGER.warning( - ( - "Entity %s (%s) with state_class %s has set last_reset. Setting" - " last_reset for entities with state_class other than 'total'" - " is not supported. Please update your configuration if" - " state_class is manually configured, otherwise %s" - ), - self.entity_id, - type(self), - state_class, - report_issue, + if state_class != SensorStateClass.TOTAL: + raise ValueError( + f"Entity {self.entity_id} ({type(self)}) with state_class {state_class}" + " has set last_reset. Setting last_reset for entities with state_class" + " other than 'total' is not supported. Please update your configuration" + " if state_class is manually configured." ) if state_class == SensorStateClass.TOTAL: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 522afe3b992..8e28a4fe382 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -170,12 +170,11 @@ async def test_deprecated_last_reset( "Entity sensor.test () " f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " - "your configuration if state_class is manually configured, otherwise report it " - "to the author of the 'test' custom integration" + "your configuration if state_class is manually configured." ) in caplog.text state = hass.states.get("sensor.test") - assert "last_reset" not in state.attributes + assert state is None async def test_datetime_conversion( From a9fe63ed904f9444070eae9d98a7ea9c8a27b69a Mon Sep 17 00:00:00 2001 From: Stephen Eisenhauer Date: Mon, 29 Jan 2024 11:30:07 -0800 Subject: [PATCH 1117/1544] Reject unifi uptime sensor updates if time delta is small (#108464) * Reject unifi uptime sensor updates if time delta is small * Revise uptime change threshold tuning * Use StateType helper * Treat missing or zero uptime as None (unknown) --- homeassistant/components/unifi/sensor.py | 36 +++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index ef158b99e4e..28db9abb94f 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -7,7 +7,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta +from decimal import Decimal from typing import Generic from aiounifi.interfaces.api_handlers import ItemEvent @@ -35,6 +36,7 @@ from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util from .const import DEVICE_STATES @@ -110,11 +112,22 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: @callback def async_device_uptime_value_fn( controller: UniFiController, device: Device -) -> datetime: - """Calculate the uptime of the device.""" - return (dt_util.now() - timedelta(seconds=device.uptime)).replace( - second=0, microsecond=0 - ) +) -> datetime | None: + """Calculate the approximate time the device started (based on uptime returned from API, in seconds).""" + if device.uptime <= 0: + # Library defaults to 0 if uptime is not provided, e.g. when offline + return None + return (dt_util.now() - timedelta(seconds=device.uptime)).replace(microsecond=0) + + +@callback +def async_device_uptime_value_changed_fn( + old: StateType | date | datetime | Decimal, new: datetime | float | str | None +) -> bool: + """Reject the new uptime value if it's too similar to the old one. Avoids unwanted fluctuation.""" + if isinstance(old, datetime) and isinstance(new, datetime): + return new != old and abs((new - old).total_seconds()) > 120 + return old is None or (new != old) @callback @@ -169,6 +182,11 @@ class UnifiSensorEntityDescription( """Class describing UniFi sensor entity.""" is_connected_fn: Callable[[UniFiController, str], bool] | None = None + # Custom function to determine whether a state change should be recorded + value_changed_fn: Callable[ + [StateType | date | datetime | Decimal, datetime | float | str | None], + bool, + ] = lambda old, new: old != new ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( @@ -349,6 +367,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", value_fn=async_device_uptime_value_fn, + value_changed_fn=async_device_uptime_value_changed_fn, ), UnifiSensorEntityDescription[Devices, Device]( key="Device temperature", @@ -425,7 +444,10 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """ description = self.entity_description obj = description.object_fn(self.controller.api, self._obj_id) - if (value := description.value_fn(self.controller, obj)) != self.native_value: + # Update the value only if value is considered to have changed relative to its previous state + if description.value_changed_fn( + self.native_value, (value := description.value_fn(self.controller, obj)) + ): self._attr_native_value = value if description.is_connected_fn is not None: From a289ab90449ace2fdb430c20928c4b95f49858d7 Mon Sep 17 00:00:00 2001 From: Ellis Michael Date: Mon, 29 Jan 2024 11:41:53 -0800 Subject: [PATCH 1118/1544] Don't check SSL certificate retrieving webos image (#104014) I didn't test this in HA, but I did test this in a Python REPL, manually querying my TV. The old method for ignoring SSL certificate validation doesn't work at all. This method does and is supported by the aiohttp documentation. https://docs.aiohttp.org/en/stable/client_reference.html Fixes #102109 --- homeassistant/components/webostv/media_player.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index f12b1c08c60..554d5e0b1d6 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -8,7 +8,6 @@ from datetime import timedelta from functools import wraps from http import HTTPStatus import logging -import ssl from typing import Any, Concatenate, ParamSpec, TypeVar, cast from aiowebostv import WebOsClient, WebOsTvPairError @@ -473,14 +472,11 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): SSLContext to bypass validation errors if url starts with https. """ content = None - ssl_context = None - if url.startswith("https"): - ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) websession = async_get_clientsession(self.hass) with suppress(asyncio.TimeoutError): async with asyncio.timeout(10): - response = await websession.get(url, ssl=ssl_context) + response = await websession.get(url, ssl=False) if response.status == HTTPStatus.OK: content = await response.read() From 39d263599e31d7580ab8056973206266591dba7c Mon Sep 17 00:00:00 2001 From: wilburCforce <109390391+wilburCforce@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:58:12 -0600 Subject: [PATCH 1119/1544] Add lutron fan entity (#107402) * add support for fan entity * removed unused variables * removed preset leftovers - not needed * added deprecation for fans * Update __init__.py * fix typing * initial updates based on review * updated to search on unique ID instead of entity ID. * updates for nits * nits updates * updates for new callback * removed async per nits * wrapped comments into shorter lines * Add comment comma --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + homeassistant/components/lutron/__init__.py | 7 ++ homeassistant/components/lutron/fan.py | 89 ++++++++++++++++++++ homeassistant/components/lutron/light.py | 85 ++++++++++++++++++- homeassistant/components/lutron/strings.json | 12 +++ 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/lutron/fan.py diff --git a/.coveragerc b/.coveragerc index 78420ac5836..e37e70e2c93 100644 --- a/.coveragerc +++ b/.coveragerc @@ -718,6 +718,7 @@ omit = homeassistant/components/lutron/binary_sensor.py homeassistant/components/lutron/cover.py homeassistant/components/lutron/entity.py + homeassistant/components/lutron/fan.py homeassistant/components/lutron/light.py homeassistant/components/lutron/switch.py homeassistant/components/lutron_caseta/__init__.py diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 486b1643f59..ad1cbfe5ca6 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -27,6 +27,7 @@ from .const import DOMAIN PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, + Platform.FAN, Platform.LIGHT, Platform.SCENE, Platform.SWITCH, @@ -167,6 +168,7 @@ class LutronData: binary_sensors: list[tuple[str, OccupancyGroup]] buttons: list[LutronButton] covers: list[tuple[str, Output]] + fans: list[tuple[str, Output]] lights: list[tuple[str, Output]] scenes: list[tuple[str, Keypad, Button, Led]] switches: list[tuple[str, Output]] @@ -189,6 +191,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b binary_sensors=[], buttons=[], covers=[], + fans=[], lights=[], scenes=[], switches=[], @@ -201,6 +204,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Working on output %s", output.type) if output.type == "SYSTEM_SHADE": entry_data.covers.append((area.name, output)) + elif output.type == "CEILING_FAN_TYPE": + entry_data.fans.append((area.name, output)) + # Deprecated, should be removed in 2024.8 + entry_data.lights.append((area.name, output)) elif output.is_dimmable: entry_data.lights.append((area.name, output)) else: diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py new file mode 100644 index 00000000000..4aac95759aa --- /dev/null +++ b/homeassistant/components/lutron/fan.py @@ -0,0 +1,89 @@ +"""Lutron fan platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from pylutron import Output + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, LutronData +from .entity import LutronDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Lutron fan platform. + + Adds fan controls from the Main Repeater associated with the config_entry as + fan entities. + """ + entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + LutronFan(area_name, device, entry_data.client) + for area_name, device in entry_data.fans + ], + True, + ) + + +class LutronFan(LutronDevice, FanEntity): + """Representation of a Lutron fan.""" + + _attr_name = None + _attr_should_poll = False + _attr_speed_count = 3 + _attr_supported_features = FanEntityFeature.SET_SPEED + _lutron_device: Output + _prev_percentage: int | None = None + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage > 0: + self._prev_percentage = percentage + self._lutron_device.level = percentage + self.schedule_update_ha_state() + + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + new_percentage: int | None = None + + if percentage is not None: + new_percentage = percentage + elif not self._prev_percentage: + # Default to medium speed + new_percentage = 67 + else: + new_percentage = self._prev_percentage + self.set_percentage(new_percentage) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + self.set_percentage(0) + + def _request_state(self) -> None: + """Request the state from the device.""" + self._lutron_device.level # pylint: disable=pointless-statement + + def _update_attrs(self) -> None: + """Update the state attributes.""" + level = self._lutron_device.last_level() + self._attr_is_on = level > 0 + self._attr_percentage = level + if self._prev_percentage is None or level != 0: + self._prev_percentage = level diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index da991969228..d728cfac890 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -2,18 +2,30 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pylutron import Output +from homeassistant.components.automation import automations_with_entity from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + create_issue, +) from . import DOMAIN, LutronData from .entity import LutronDevice +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -25,12 +37,50 @@ async def async_setup_entry( Adds dimmers from the Main Repeater associated with the config_entry as light entities. """ + ent_reg = er.async_get(hass) entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + lights = [] + + for area_name, device in entry_data.lights: + if device.type == "CEILING_FAN_TYPE2": + # If this is a fan, check to see if this entity already exists. + # If not, do not create a new one. + entity_id = ent_reg.async_get_entity_id( + Platform.LIGHT, + DOMAIN, + f"{entry_data.client.guid}_{device.uuid}", + ) + if entity_id: + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + if entity_entry.disabled: + # If the entity exists and is disabled then we want to remove + # the entity so that the user is using the new fan entity instead. + ent_reg.async_remove(entity_id) + else: + lights.append(LutronLight(area_name, device, entry_data.client)) + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + hass, + DOMAIN, + f"deprecated_light_fan_{entity_id}_{item}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_entity", + translation_placeholders={ + "entity": entity_id, + "info": item, + }, + ) + else: + lights.append(LutronLight(area_name, device, entry_data.client)) + async_add_entities( - [ - LutronLight(area_name, device, entry_data.client) - for area_name, device in entry_data.lights - ], + lights, True, ) @@ -54,8 +104,24 @@ class LutronLight(LutronDevice, LightEntity): _prev_brightness: int | None = None _attr_name = None + def __init__(self, area_name, lutron_device, controller) -> None: + """Initialize the light.""" + super().__init__(area_name, lutron_device, controller) + self._is_fan = lutron_device.type == "CEILING_FAN_TYPE" + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" + if self._is_fan: + create_issue( + self.hass, + DOMAIN, + "deprecated_light_fan_on", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_on", + ) if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: brightness = kwargs[ATTR_BRIGHTNESS] elif self._prev_brightness == 0: @@ -67,6 +133,17 @@ class LutronLight(LutronDevice, LightEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + if self._is_fan: + create_issue( + self.hass, + DOMAIN, + "deprecated_light_fan_off", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_light_fan_off", + ) self._lutron_device.level = 0 @property diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index 20a8d9bf971..efa0a35d81a 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -30,6 +30,18 @@ "deprecated_yaml_import_issue_unknown": { "title": "The Lutron YAML configuration import request failed due to an unknown error", "description": "Configuring Lutron using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nThe specific error can be found in the logs. The most likely cause is a networking error or the Main Repeater is down or has an invalid configuration.\n\nVerify that your Lutron system is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + }, + "deprecated_light_fan_entity": { + "title": "Detected Lutron fan entity created as a light", + "description": "Fan entities have been added to the Lutron integration.\nWe detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new fan entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant." + }, + "deprecated_light_fan_on": { + "title": "The Lutron integration deprecated fan turned on", + "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned on a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate." + }, + "deprecated_light_fan_off": { + "title": "The Lutron integration deprecated fan turned off", + "description": "Fan entities have been added to the Lutron integration.\nPreviously fans were created as lights; this behavior is now deprecated.\n\nYour configuration just turned off a fan created as a light. You should migrate your scenes and automations to use the new fan entity.\n\nWhen you are done migrating your automations and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant.\n\nAn issue will be created each time the incorrect entity is used to remind you to migrate." } } } From 6f88cd3273d68682f26a586ef3bd61ef7a4f5b33 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 29 Jan 2024 21:19:44 +0100 Subject: [PATCH 1120/1544] Bump python-kasa to 0.6.2 (#109064) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/__init__.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 0c76b068f59..15748e83737 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -205,5 +205,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.6.1"] + "requirements": ["python-kasa[speedups]==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78a676da7a1..82e73d6bbe7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2229,7 +2229,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.1 +python-kasa[speedups]==0.6.2 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eac1e9ad401..a0c493ebdb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1702,7 +1702,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.1 +python-kasa[speedups]==0.6.2 # homeassistant.components.matter python-matter-server==5.1.1 diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 4a79f39f6a7..30e59014bbf 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -15,7 +15,7 @@ from kasa import ( SmartStrip, ) from kasa.exceptions import SmartDeviceException -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.protocol import BaseProtocol from homeassistant.components.tplink import ( CONF_ALIAS, @@ -89,8 +89,8 @@ CREATE_ENTRY_DATA_AUTH2 = { } -def _mock_protocol() -> TPLinkSmartHomeProtocol: - protocol = MagicMock(auto_spec=TPLinkSmartHomeProtocol) +def _mock_protocol() -> BaseProtocol: + protocol = MagicMock(auto_spec=BaseProtocol) protocol.close = AsyncMock() return protocol From f1392f85191830f0cf6e428ab2966fb32e37e078 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 29 Jan 2024 21:39:15 +0100 Subject: [PATCH 1121/1544] Try to reconnect to UniFi on 403 (#109067) --- homeassistant/components/unifi/__init__.py | 5 +++-- homeassistant/components/unifi/controller.py | 9 +-------- tests/components/unifi/test_controller.py | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 4337899a50f..e435b68fc39 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: api = await get_unifi_controller(hass, config_entry.data) - controller = UniFiController(hass, config_entry, api) - await controller.initialize() except CannotConnect as err: raise ConfigEntryNotReady from err @@ -45,7 +43,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err + controller = UniFiController(hass, config_entry, api) + await controller.initialize() hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) controller.async_update_device_registry() diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 833d2001980..de97631c036 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -501,6 +501,7 @@ async def get_unifi_controller( except ( asyncio.TimeoutError, aiounifi.BadGateway, + aiounifi.Forbidden, aiounifi.ServiceUnavailable, aiounifi.RequestError, aiounifi.ResponseError, @@ -510,14 +511,6 @@ async def get_unifi_controller( ) raise CannotConnect from err - except aiounifi.Forbidden as err: - LOGGER.warning( - "Access forbidden to UniFi Network at %s, check access rights: %s", - config[CONF_HOST], - err, - ) - raise AuthenticationRequired from err - except aiounifi.LoginRequired as err: LOGGER.warning( "Connected to UniFi Network at %s but login required: %s", diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 268f4e8493a..8953351f9fe 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -461,11 +461,11 @@ async def test_get_unifi_controller_verify_ssl_false(hass: HomeAssistant) -> Non [ (asyncio.TimeoutError, CannotConnect), (aiounifi.BadGateway, CannotConnect), + (aiounifi.Forbidden, CannotConnect), (aiounifi.ServiceUnavailable, CannotConnect), (aiounifi.RequestError, CannotConnect), (aiounifi.ResponseError, CannotConnect), (aiounifi.Unauthorized, AuthenticationRequired), - (aiounifi.Forbidden, AuthenticationRequired), (aiounifi.LoginRequired, AuthenticationRequired), (aiounifi.AiounifiException, AuthenticationRequired), ], From 7ef3ed6107400c246c6945c3b5a336e6b21194cf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 Jan 2024 21:40:21 +0100 Subject: [PATCH 1122/1544] Fix light color mode in govee_light_local (#108762) --- .../components/govee_light_local/light.py | 28 ++++++++++--------- .../govee_light_local/test_light.py | 4 +-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index fec0ff5a898..836f48d2ea9 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ColorMode, LightEntity, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -52,6 +53,8 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_supported_color_modes: set[ColorMode] + _fixed_color_mode: ColorMode | None = None def __init__( self, @@ -67,7 +70,7 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._attr_unique_id = device.fingerprint capabilities = device.capabilities - color_modes = set() + color_modes = {ColorMode.ONOFF} if capabilities: if GoveeLightCapability.COLOR_RGB in capabilities: color_modes.add(ColorMode.RGB) @@ -77,10 +80,11 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): self._attr_min_color_temp_kelvin = 2000 if GoveeLightCapability.BRIGHTNESS in capabilities: color_modes.add(ColorMode.BRIGHTNESS) - else: - color_modes.add(ColorMode.ONOFF) - self._attr_supported_color_modes = color_modes + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) self._attr_device_info = DeviceInfo( identifiers={ @@ -116,20 +120,18 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): @property def color_mode(self) -> ColorMode | str | None: """Return the color mode.""" + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode + + # The light supports both color temperature and RGB, determine which + # mode the light is in if ( self._device.temperature_color is not None and self._device.temperature_color > 0 ): return ColorMode.COLOR_TEMP - if self._device.rgb_color is not None and any(self._device.rgb_color): - return ColorMode.RGB - - if ( - self._attr_supported_color_modes - and ColorMode.BRIGHTNESS in self._attr_supported_color_modes - ): - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF + return ColorMode.RGB async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 63ffd7179a2..1e211610d7a 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -46,9 +46,7 @@ async def test_light_known_device( assert light is not None color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] - assert ColorMode.RGB in color_modes - assert ColorMode.BRIGHTNESS in color_modes - assert ColorMode.COLOR_TEMP in color_modes + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} # Remove assert await hass.config_entries.async_remove(entry.entry_id) From 872a59f405926757761bad53a10c6467ff4ae51d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 29 Jan 2024 21:45:10 +0100 Subject: [PATCH 1123/1544] Bump deebot-client to 5.0.0 (#109066) --- .../components/ecovacs/config_flow.py | 8 ++-- .../components/ecovacs/controller.py | 46 +++++++++---------- .../components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 28 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 75a0d28ae91..7b56417f93e 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -5,9 +5,8 @@ import logging from typing import Any, cast from aiohttp import ClientError -from deebot_client.authentication import Authenticator +from deebot_client.authentication import Authenticator, create_rest_config from deebot_client.exceptions import InvalidAuthenticationError -from deebot_client.models import Configuration from deebot_client.util import md5 from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent import voluptuous as vol @@ -32,15 +31,14 @@ async def _validate_input( """Validate user input.""" errors: dict[str, str] = {} - deebot_config = Configuration( + rest_config = create_rest_config( aiohttp_client.async_get_clientsession(hass), device_id=get_client_device_id(), country=user_input[CONF_COUNTRY], - continent=user_input.get(CONF_CONTINENT), ) authenticator = Authenticator( - deebot_config, + rest_config, user_input[CONF_USERNAME], md5(user_input[CONF_PASSWORD]), ) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 645c5b9bc19..e0c3497c178 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -6,20 +6,16 @@ import logging from typing import Any from deebot_client.api_client import ApiClient -from deebot_client.authentication import Authenticator +from deebot_client.authentication import Authenticator, create_rest_config from deebot_client.device import Device from deebot_client.exceptions import DeebotError, InvalidAuthenticationError -from deebot_client.models import Configuration, DeviceInfo -from deebot_client.mqtt_client import MqttClient, MqttConfiguration +from deebot_client.models import DeviceInfo +from deebot_client.mqtt_client import MqttClient, create_mqtt_config from deebot_client.util import md5 +from deebot_client.util.continents import get_continent from sucks import EcoVacsAPI, VacBot -from homeassistant.const import ( - CONF_COUNTRY, - CONF_PASSWORD, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -37,25 +33,27 @@ class EcovacsController: self._hass = hass self.devices: list[Device] = [] self.legacy_devices: list[VacBot] = [] - verify_ssl = config.get(CONF_VERIFY_SSL, True) - device_id = get_client_device_id() - - self._config = Configuration( - aiohttp_client.async_get_clientsession(self._hass, verify_ssl=verify_ssl), - device_id=device_id, - country=config[CONF_COUNTRY], - verify_ssl=verify_ssl, - ) + self._device_id = get_client_device_id() + country = config[CONF_COUNTRY] + self._continent = get_continent(country) self._authenticator = Authenticator( - self._config, + create_rest_config( + aiohttp_client.async_get_clientsession(self._hass), + device_id=self._device_id, + country=country, + ), config[CONF_USERNAME], md5(config[CONF_PASSWORD]), ) self._api_client = ApiClient(self._authenticator) - - mqtt_config = MqttConfiguration(config=self._config) - self._mqtt = MqttClient(mqtt_config, self._authenticator) + self._mqtt = MqttClient( + create_mqtt_config( + device_id=self._device_id, + country=country, + ), + self._authenticator, + ) async def initialize(self) -> None: """Init controller.""" @@ -72,10 +70,10 @@ class EcovacsController: bot = VacBot( credentials.user_id, EcoVacsAPI.REALM, - self._config.device_id[0:8], + self._device_id[0:8], credentials.token, device_config, - self._config.continent, + self._continent, monitor=True, ) self.legacy_devices.append(bot) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index d08602bbba8..3472e4746f8 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.8", "deebot-client==4.3.0"] + "requirements": ["py-sucks==0.9.8", "deebot-client==5.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 82e73d6bbe7..316a8216bbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -681,7 +681,7 @@ debugpy==1.8.0 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==4.3.0 +deebot-client==5.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0c493ebdb8..f9e5b61efbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -556,7 +556,7 @@ dbus-fast==2.21.1 debugpy==1.8.0 # homeassistant.components.ecovacs -deebot-client==4.3.0 +deebot-client==5.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 02ac985120a7eec33734084b238df0c88ba0ae1a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 29 Jan 2024 21:46:04 +0100 Subject: [PATCH 1124/1544] Use right initial attribute value for demo climate (#108719) --- homeassistant/components/demo/climate.py | 8 ++++---- tests/components/demo/test_climate.py | 16 ++++++++-------- .../google_assistant/test_google_assistant.py | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 0eaa7d5f41f..b857f98e2da 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -56,10 +56,10 @@ async def async_setup_entry( unit_of_measurement=UnitOfTemperature.CELSIUS, preset=None, current_temperature=22, - fan_mode="On High", + fan_mode="on_high", target_humidity=67, current_humidity=54, - swing_mode="Off", + swing_mode="off", hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING, aux=False, @@ -75,10 +75,10 @@ async def async_setup_entry( preset="home", preset_modes=["home", "eco", "away"], current_temperature=23, - fan_mode="Auto Low", + fan_mode="auto_low", target_humidity=None, current_humidity=None, - swing_mode="Auto", + swing_mode="auto", hvac_mode=HVACMode.HEAT_COOL, hvac_action=None, aux=None, diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 97b436ea2b0..18992c0d0f4 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -76,10 +76,10 @@ def test_setup_params(hass: HomeAssistant) -> None: assert state.state == HVACMode.COOL assert state.attributes.get(ATTR_TEMPERATURE) == 21 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22 - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" assert state.attributes.get(ATTR_HUMIDITY) == 67 assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 54 - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" assert state.attributes.get(ATTR_AUX_HEAT) == STATE_OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -256,7 +256,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: async def test_set_fan_mode_bad_attr(hass: HomeAssistant) -> None: """Test setting fan mode without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -267,13 +267,13 @@ async def test_set_fan_mode_bad_attr(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" async def test_set_fan_mode(hass: HomeAssistant) -> None: """Test setting of new fan mode.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On High" + assert state.attributes.get(ATTR_FAN_MODE) == "on_high" await hass.services.async_call( DOMAIN, @@ -289,7 +289,7 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: """Test setting swing mode without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" with pytest.raises(vol.Invalid): await hass.services.async_call( @@ -300,13 +300,13 @@ async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" async def test_set_swing(hass: HomeAssistant) -> None: """Test setting of new swing mode.""" state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" await hass.services.async_call( DOMAIN, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 177220cc02f..6d3a9b34cce 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -243,7 +243,7 @@ async def test_query_climate_request( "thermostatTemperatureAmbient": 23, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": 21, - "currentFanSpeedSetting": "Auto Low", + "currentFanSpeedSetting": "auto_low", } assert devices["climate.hvac"] == { "online": True, @@ -251,7 +251,7 @@ async def test_query_climate_request( "thermostatTemperatureAmbient": 22, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, - "currentFanSpeedSetting": "On High", + "currentFanSpeedSetting": "on_high", } @@ -304,7 +304,7 @@ async def test_query_climate_request_f( "thermostatTemperatureAmbient": -5, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": -6.1, - "currentFanSpeedSetting": "Auto Low", + "currentFanSpeedSetting": "auto_low", } assert devices["climate.hvac"] == { "online": True, @@ -312,7 +312,7 @@ async def test_query_climate_request_f( "thermostatTemperatureAmbient": -5.6, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, - "currentFanSpeedSetting": "On High", + "currentFanSpeedSetting": "on_high", } hass_fixture.config.units.temperature_unit = UnitOfTemperature.CELSIUS From b711c491d5d828c03c305dfcc78d5d0b71efb37c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 30 Jan 2024 07:00:26 +1000 Subject: [PATCH 1125/1544] Add doors and charge cable binary sensors to Tessie (#107172) * Add doors * Update homeassistant/components/tessie/strings.json Co-authored-by: Joost Lekkerkerker * Add charge cable --------- Co-authored-by: Joost Lekkerkerker --- .../components/tessie/binary_sensor.py | 26 +++++++++++++++++++ homeassistant/components/tessie/strings.json | 15 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index e4c0d5d5c66..594098cddfe 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -54,6 +54,12 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( key="charge_state_trip_charging", entity_category=EntityCategory.DIAGNOSTIC, ), + TessieBinarySensorEntityDescription( + key="charge_state_conn_charge_cable", + is_on=lambda x: x != "", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), TessieBinarySensorEntityDescription( key="climate_state_auto_seat_climate_left", device_class=BinarySensorDeviceClass.HEAT, @@ -130,6 +136,26 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), + TessieBinarySensorEntityDescription( + key="vehicle_state_df", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_dr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_pf", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_pr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 1f0a42bb781..57ba1f12bec 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -249,6 +249,9 @@ "charge_state_trip_charging": { "name": "Trip charging" }, + "charge_state_conn_charge_cable": { + "name": "Charge cable" + }, "climate_state_auto_seat_climate_left": { "name": "Auto seat climate left" }, @@ -293,6 +296,18 @@ }, "vehicle_state_rp_window": { "name": "Rear passenger window" + }, + "vehicle_state_df": { + "name": "Front driver door" + }, + "vehicle_state_pf": { + "name": "Front passenger door" + }, + "vehicle_state_dr": { + "name": "Rear driver door" + }, + "vehicle_state_pr": { + "name": "Rear passenger door" } }, "button": { From b5c1d3feebf0e31fa94009bd3e268def88064f7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 11:50:06 -1000 Subject: [PATCH 1126/1544] Bump tesla-powerwall to 0.5.1 (#109069) --- homeassistant/components/powerwall/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 4de9cf8b982..4185e90ab7b 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/powerwall", "iot_class": "local_polling", "loggers": ["tesla_powerwall"], - "requirements": ["tesla-powerwall==0.5.0"] + "requirements": ["tesla-powerwall==0.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 316a8216bbb..912c2769f19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2666,7 +2666,7 @@ temperusb==1.6.1 tesla-fleet-api==0.2.3 # homeassistant.components.powerwall -tesla-powerwall==0.5.0 +tesla-powerwall==0.5.1 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9e5b61efbc..5ecdee55312 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2028,7 +2028,7 @@ temperusb==1.6.1 tesla-fleet-api==0.2.3 # homeassistant.components.powerwall -tesla-powerwall==0.5.0 +tesla-powerwall==0.5.1 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 From 0013f184b38753d0648715d6e7a2ef3686f1a2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Mon, 29 Jan 2024 23:02:19 +0100 Subject: [PATCH 1127/1544] Extract foscam base entity for reuse (#108893) * Extract foscam base entity for reuse * Cleanup * More cleanup * Cleanup constructor * Use more constants --- .coveragerc | 1 + homeassistant/components/foscam/camera.py | 17 +++---------- homeassistant/components/foscam/entity.py | 30 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/foscam/entity.py diff --git a/.coveragerc b/.coveragerc index e37e70e2c93..fed57af6ad7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -437,6 +437,7 @@ omit = homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py homeassistant/components/foscam/coordinator.py + homeassistant/components/foscam/entity.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 343868afb56..6674bff81e0 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -10,9 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_RTSP_PORT, @@ -23,6 +21,7 @@ from .const import ( SERVICE_PTZ_PRESET, ) from .coordinator import FoscamCoordinator +from .entity import FoscamEntity DIR_UP = "up" DIR_DOWN = "down" @@ -94,7 +93,7 @@ async def async_setup_entry( async_add_entities([HassFoscamCamera(coordinator, config_entry)]) -class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): +class HassFoscamCamera(FoscamEntity, Camera): """An implementation of a Foscam IP camera.""" _attr_has_entity_name = True @@ -106,7 +105,7 @@ class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): config_entry: ConfigEntry, ) -> None: """Initialize a Foscam camera.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry.entry_id) Camera.__init__(self) self._foscam_session = coordinator.session @@ -118,16 +117,6 @@ class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): if self._rtsp_port: self._attr_supported_features = CameraEntityFeature.STREAM - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="Foscam", - name=config_entry.title, - ) - if dev_info := coordinator.data.get("dev_info"): - self._attr_device_info["model"] = dev_info["productName"] - self._attr_device_info["sw_version"] = dev_info["firmwareVer"] - self._attr_device_info["hw_version"] = dev_info["hardwareVer"] - async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" # Get motion detection status diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py new file mode 100644 index 00000000000..ebcd9574e32 --- /dev/null +++ b/homeassistant/components/foscam/entity.py @@ -0,0 +1,30 @@ +"""Component providing basic support for Foscam IP cameras.""" +from __future__ import annotations + +from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FoscamCoordinator + + +class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): + """Base entity for Foscam camera.""" + + def __init__( + self, + coordinator: FoscamCoordinator, + entry_id: str, + ) -> None: + """Initialize the base Foscam entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="Foscam", + ) + if dev_info := coordinator.data.get("dev_info"): + self._attr_device_info[ATTR_MODEL] = dev_info["productName"] + self._attr_device_info[ATTR_SW_VERSION] = dev_info["firmwareVer"] + self._attr_device_info[ATTR_HW_VERSION] = dev_info["hardwareVer"] From 825fed8319e4f667350ee8fb9e879f6e4101f525 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Jan 2024 12:15:18 -1000 Subject: [PATCH 1128/1544] Bump aiohttp to 3.9.3 (#109025) Co-authored-by: Joost Lekkerkerker --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/websocket_api/test_auth.py | 2 +- tests/components/websocket_api/test_http.py | 6 +++--- tests/components/websocket_api/test_init.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8a5ae2dde36..2863b53b684 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 -aiohttp==3.9.1 +aiohttp==3.9.3 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.38.1 diff --git a/pyproject.toml b/pyproject.toml index 535cac1292f..55299f5c2f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.1", + "aiohttp==3.9.3", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.3.1", diff --git a/requirements.txt b/requirements.txt index 75fd75f6177..f48dfe81935 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.1 +aiohttp==3.9.3 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 7c5a34a755a..65cf3012e30 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -220,7 +220,7 @@ async def test_auth_close_after_revoke( hass.auth.async_remove_refresh_token(refresh_token) msg = await websocket_client.receive() - assert msg.type == aiohttp.WSMsgType.CLOSE + assert msg.type == aiohttp.WSMsgType.CLOSED assert websocket_client.closed diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index e69b5629b63..f6723f0a592 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -42,7 +42,7 @@ async def test_pending_msg_overflow( for idx in range(10): await websocket_client.send_json({"id": idx + 1, "type": "ping"}) msg = await websocket_client.receive() - assert msg.type == WSMsgType.close + assert msg.type == WSMsgType.CLOSED async def test_cleanup_on_cancellation( @@ -248,7 +248,7 @@ async def test_pending_msg_peak( ) msg = await websocket_client.receive() - assert msg.type == WSMsgType.close + assert msg.type == WSMsgType.CLOSED assert "Client unable to keep up with pending messages" in caplog.text assert "Stayed over 5 for 5 seconds" in caplog.text assert "overload" in caplog.text @@ -296,7 +296,7 @@ async def test_pending_msg_peak_recovery( msg = await websocket_client.receive() assert msg.type == WSMsgType.TEXT msg = await websocket_client.receive() - assert msg.type == WSMsgType.close + assert msg.type == WSMsgType.CLOSED assert "Client unable to keep up with pending messages" not in caplog.text diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index 468b35fef51..c4c83925311 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -40,7 +40,7 @@ async def test_quiting_hass(hass: HomeAssistant, websocket_client) -> None: msg = await websocket_client.receive() - assert msg.type == WSMsgType.CLOSE + assert msg.type == WSMsgType.CLOSED async def test_unknown_command(websocket_client) -> None: From 3dec2064216e7a879c21595050b1f8127e2bc5eb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 29 Jan 2024 23:17:44 +0100 Subject: [PATCH 1129/1544] Update apprise to 1.7.2 (#109071) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 851aaae0f19..dd630ccc872 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.7.1"] + "requirements": ["apprise==1.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 912c2769f19..5314e568191 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -446,7 +446,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.7.1 +apprise==1.7.2 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ecdee55312..034cf040678 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.7.1 +apprise==1.7.2 # homeassistant.components.aprs aprslib==0.7.0 From 6ce16286d58490494e26bbd67cdbd9eac3bc974e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 29 Jan 2024 23:24:17 +0100 Subject: [PATCH 1130/1544] Update colorlog to 6.8.2 (#109072) --- homeassistant/scripts/check_config.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index dcccdbccf40..5a9f62c938e 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -27,7 +27,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.7.0",) +REQUIREMENTS = ("colorlog==6.8.2",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { diff --git a/requirements_all.txt b/requirements_all.txt index 5314e568191..bc5790d35e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -639,7 +639,7 @@ clx-sdk-xms==1.0.0 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.7.0 +colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 034cf040678..8d4b924ecce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -526,7 +526,7 @@ caldav==1.3.8 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.7.0 +colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 From 769da1ee23622c1161fc48822fb4f523957bf68c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 29 Jan 2024 23:26:37 +0100 Subject: [PATCH 1131/1544] Bump python-matter-server to version 5.3.0 (#109068) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 848e89660ed..32ede276357 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.1.1"] + "requirements": ["python-matter-server==5.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc5790d35e9..5b300641e35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ python-kasa[speedups]==0.6.2 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.1.1 +python-matter-server==5.3.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d4b924ecce..d6f40ca3c01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1705,7 +1705,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2 # homeassistant.components.matter -python-matter-server==5.1.1 +python-matter-server==5.3.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From c4fbf59e16208aa8f3c55f8c789097dbf1d57491 Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Tue, 30 Jan 2024 11:34:06 +1300 Subject: [PATCH 1132/1544] Fix duplicate Windy values showing in automations state selector (#108062) --- homeassistant/components/weather/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 0b712a4de05..8879bf158f3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -18,7 +18,7 @@ "snowy-rainy": "Snowy, rainy", "sunny": "Sunny", "windy": "Windy", - "windy-variant": "Windy" + "windy-variant": "Windy, cloudy" }, "state_attributes": { "forecast": { From b9f48f62dec6d4ae4482b8cffc2aaf8f31840b33 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:59:56 -0500 Subject: [PATCH 1133/1544] Bump ZHA dependency zigpy to 0.60.7 (#109082) --- 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 de429b299c0..024fea9227a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.109", "zigpy-deconz==0.22.4", - "zigpy==0.60.6", + "zigpy==0.60.7", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 5b300641e35..c6eb6ab51e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2927,7 +2927,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.6 +zigpy==0.60.7 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6f40ca3c01..1f09b1e2482 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2238,7 +2238,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.6 +zigpy==0.60.7 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From 03df4fde97defceb720189ff4eb929968d44dfee Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 00:17:55 +0100 Subject: [PATCH 1134/1544] Code quality for Comelit tests (#109077) --- tests/components/comelit/test_config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index f17c46c6f5b..0a0dc04eae0 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -65,8 +65,8 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" with patch( "aiocomelit.api.ComeliteSerialBridgeApi.login", @@ -82,6 +82,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] is not None assert result["errors"]["base"] == error @@ -158,4 +159,5 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["errors"] is not None assert result["errors"]["base"] == error From 18d395821d4fdc133ac538f088827b4282d3374d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 29 Jan 2024 21:36:41 -0500 Subject: [PATCH 1135/1544] Don't remove zwave_js devices automatically (#98145) --- homeassistant/components/zwave_js/__init__.py | 90 +++++++-- tests/components/zwave_js/test_init.py | 181 ++++++++++++++++-- 2 files changed, 240 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index ccadc452bc7..1321ef36f85 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -282,7 +282,8 @@ class DriverEvents: for node in controller.nodes.values() ] - # Devices that are in the device registry that are not known by the controller can be removed + # Devices that are in the device registry that are not known by the controller + # can be removed for device in stored_devices: if device not in known_devices: self.dev_reg.async_remove_device(device.id) @@ -509,25 +510,46 @@ class ControllerEvents: driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) - device = self.dev_reg.async_get_device(identifiers={device_id}) + node_id_device = self.dev_reg.async_get_device(identifiers={device_id}) via_device_id = None controller = driver.controller # Get the controller node device ID if this node is not the controller if controller.own_node and controller.own_node != node: via_device_id = get_device_id(driver, controller.own_node) - # Replace the device if it can be determined that this node is not the - # same product as it was previously. - if ( - device_id_ext - and device - and len(device.identifiers) == 2 - and device_id_ext not in device.identifiers - ): - self.remove_device(device) - device = None - if device_id_ext: + # If there is a device with this node ID but with a different hardware + # signature, remove the node ID based identifier from it. The hardware + # signature can be different for one of two reasons: 1) in the ideal + # scenario, the node was replaced with a different node that's a different + # device entirely, or 2) the device erroneously advertised the wrong + # hardware identifiers (this is known to happen due to poor RF conditions). + # While we would like to remove the old device automatically for case 1, we + # have no way to distinguish between these reasons so we leave it up to the + # user to remove the old device manually. + if ( + node_id_device + and len(node_id_device.identifiers) == 2 + and device_id_ext not in node_id_device.identifiers + ): + new_identifiers = node_id_device.identifiers.copy() + new_identifiers.remove(device_id) + self.dev_reg.async_update_device( + node_id_device.id, new_identifiers=new_identifiers + ) + # If there is an orphaned device that already exists with this hardware + # based identifier, add the node ID based identifier to the orphaned + # device. + if ( + hardware_device := self.dev_reg.async_get_device( + identifiers={device_id_ext} + ) + ) and len(hardware_device.identifiers) == 1: + new_identifiers = hardware_device.identifiers.copy() + new_identifiers.add(device_id) + self.dev_reg.async_update_device( + hardware_device.id, new_identifiers=new_identifiers + ) ids = {device_id, device_id_ext} else: ids = {device_id} @@ -769,9 +791,12 @@ class NodeEvents: return driver = self.controller_events.driver_events.driver - notification: EntryControlNotification | NotificationNotification | PowerLevelNotification | MultilevelSwitchNotification = event[ - "notification" - ] + notification: ( + EntryControlNotification + | NotificationNotification + | PowerLevelNotification + | MultilevelSwitchNotification + ) = event["notification"] device = self.dev_reg.async_get_device( identifiers={get_device_id(driver, notification.node)} ) @@ -984,6 +1009,39 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + entry_hass_data = hass.data[DOMAIN][config_entry.entry_id] + client: ZwaveClient = entry_hass_data[DATA_CLIENT] + + # Driver may not be ready yet so we can't allow users to remove a device since + # we need to check if the device is still known to the controller + if (driver := client.driver) is None: + LOGGER.error("Driver for %s is not ready", config_entry.title) + return False + + # If a node is found on the controller that matches the hardware based identifier + # on the device, prevent the device from being removed. + if next( + ( + node + for node in driver.controller.nodes.values() + if get_device_id_ext(driver, node) in device_entry.identifiers + ), + None, + ): + return False + + controller_events: ControllerEvents = entry_hass_data[ + DATA_DRIVER_EVENTS + ].controller_events + controller_events.registered_unique_ids.pop(device_entry.id, None) + controller_events.discovered_value_ids.pop(device_entry.id, None) + return True + + async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 77b1fcb8b3a..7f3a9428dad 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -30,6 +30,7 @@ from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY from tests.common import MockConfigEntry, async_get_persistent_notifications +from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") @@ -1008,6 +1009,7 @@ async def test_node_removed( client.driver.controller.receive_event(Event("node added", event)) await hass.async_block_till_done() old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device assert old_device.id event = {"node": node, "reason": 0} @@ -1131,6 +1133,7 @@ async def test_replace_different_node( hank_binary_switch_state, client, integration, + hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" dev_reg = dr.async_get(hass) @@ -1139,11 +1142,11 @@ async def test_replace_different_node( state["nodeId"] = node_id device_id = f"{client.driver.controller.home_id}-{node_id}" - multisensor_6_device_id = ( + multisensor_6_device_id_ext = ( f"{device_id}-{multisensor_6.manufacturer_id}:" f"{multisensor_6.product_type}:{multisensor_6.product_id}" ) - hank_device_id = ( + hank_device_id_ext = ( f"{device_id}-{state['manufacturerId']}:" f"{state['productType']}:" f"{state['productId']}" @@ -1152,7 +1155,7 @@ async def test_replace_different_node( device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device == dev_reg.async_get_device( - identifiers={(DOMAIN, multisensor_6_device_id)} + identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device.manufacturer == "AEON Labs" assert device.model == "ZW100" @@ -1160,8 +1163,7 @@ async def test_replace_different_node( assert hass.states.get(AIR_TEMPERATURE_SENSOR) - # A replace node event has the extra field "replaced" set to True - # to distinguish it from an exclusion + # Remove existing node event = Event( type="node removed", data={ @@ -1175,8 +1177,11 @@ async def test_replace_different_node( await hass.async_block_till_done() # Device should still be there after the node was removed - device = dev_reg.async_get(dev_id) + device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) assert device + assert len(device.identifiers) == 2 # When the node is replaced, a non-ready node added event is emitted event = Event( @@ -1230,18 +1235,164 @@ async def test_replace_different_node( client.driver.receive_event(event) await hass.async_block_till_done() - # Old device and entities were removed, but the ID is re-used - device = dev_reg.async_get(dev_id) - assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)}) - assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)}) - assert device.manufacturer == "HANK Electronics Ltd." - assert device.model == "HKZW-SO01" + # node ID based device identifier should be moved from the old multisensor device + # to the new hank device and both the old and new devices should exist. + new_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert new_device + hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert hank_device + assert hank_device == new_device + assert hank_device.identifiers == { + (DOMAIN, device_id), + (DOMAIN, hank_device_id_ext), + } + multisensor_6_device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) + assert multisensor_6_device + assert multisensor_6_device != new_device + assert multisensor_6_device.identifiers == {(DOMAIN, multisensor_6_device_id_ext)} - assert not hass.states.get(AIR_TEMPERATURE_SENSOR) + assert new_device.manufacturer == "HANK Electronics Ltd." + assert new_device.model == "HKZW-SO01" + + # We keep the old entities in case there are customizations that a user wants to + # keep. They can always delete the device and that will remove the entities as well. + assert hass.states.get(AIR_TEMPERATURE_SENSOR) assert hass.states.get("switch.smart_plug_with_two_usb_ports") + # Try to add back the first node to see if the device IDs are correct + + # Remove existing node + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "reason": 3, + "node": state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Device should still be there after the node was removed + device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert device + assert len(device.identifiers) == 2 + + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": multisensor_6.node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": False, + "interviewAttempts": 1, + "endpoints": [ + {"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None} + ], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + "isControllerNode": False, + }, + "result": {}, + }, + ) + + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Mark node as ready + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": multisensor_6_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "config", {}) + + # node ID based device identifier should be moved from the new hank device + # to the old multisensor device and both the old and new devices should exist. + old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert old_device + hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + assert hank_device + assert hank_device != old_device + assert hank_device.identifiers == {(DOMAIN, hank_device_id_ext)} + multisensor_6_device = dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id_ext)} + ) + assert multisensor_6_device + assert multisensor_6_device == old_device + assert multisensor_6_device.identifiers == { + (DOMAIN, device_id), + (DOMAIN, multisensor_6_device_id_ext), + } + + ws_client = await hass_ws_client(hass) + + # Simulate the driver not being ready to ensure that the device removal handler + # does not crash + 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() + 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() + 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.receive_json() + assert not response["success"] + async def test_node_model_change( hass: HomeAssistant, zp3111, client, integration From 1069693292f463326059a2b75f80cee433956b8a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 29 Jan 2024 21:36:14 -0800 Subject: [PATCH 1136/1544] Update calendar tests to use mock entities instead of demo platform (#105317) * Update calendar tests to use mock entities instead of demo platform * Add Generator type to fixture * Fix generator syntax --------- Co-authored-by: Martin Hjelmare --- tests/components/calendar/conftest.py | 183 +++++++++++++++++- .../calendar/snapshots/test_init.ambr | 28 +-- tests/components/calendar/test_init.py | 101 +++++----- tests/components/calendar/test_recorder.py | 33 ++-- tests/components/calendar/test_trigger.py | 123 +++++------- 5 files changed, 306 insertions(+), 162 deletions(-) diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 5d506d67c6f..f42cc6fd508 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -1,14 +1,29 @@ """Test fixtures for calendar sensor platforms.""" +from collections.abc import Generator +import datetime +import secrets +from typing import Any +from unittest.mock import AsyncMock + import pytest +from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) -@pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): - """Set up the homeassistant integration.""" - await async_setup_component(hass, "homeassistant", {}) +TEST_DOMAIN = "test" @pytest.fixture @@ -17,3 +32,161 @@ def set_time_zone(hass: HomeAssistant) -> None: # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round hass.config.set_time_zone("America/Regina") + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +class MockCalendarEntity(CalendarEntity): + """Test Calendar entity.""" + + _attr_has_entity_name = True + + def __init__(self, name: str, events: list[CalendarEvent] | None = None) -> None: + """Initialize entity.""" + self._attr_name = name.capitalize() + self._events = events or [] + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return self._events[0] if self._events else None + + def create_event( + self, + start: datetime.datetime, + end: datetime.datetime, + summary: str | None = None, + description: str | None = None, + location: str | None = None, + ) -> dict[str, Any]: + """Create a new fake event, used by tests.""" + event = CalendarEvent( + start=start, + end=end, + summary=summary if summary else f"Event {secrets.token_hex(16)}", + description=description, + location=location, + ) + self._events.append(event) + return event.as_dict() + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + assert start_date < end_date + events = [] + for event in self._events: + if event.start_datetime_local >= end_date: + continue + if event.end_datetime_local < start_date: + continue + events.append(event) + return events + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture +def mock_setup_integration(hass: HomeAssistant, config_flow_fixture: None) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms( + config_entry, [Platform.CALENDAR] + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[CalendarEntity], +) -> MockConfigEntry: + """Create a calendar platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="test_entities") +def mock_test_entities() -> list[MockCalendarEntity]: + """Fixture to create fake entities used in the test.""" + half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) + entity1 = MockCalendarEntity( + "Calendar 1", + [ + CalendarEvent( + start=half_hour_from_now, + end=half_hour_from_now + datetime.timedelta(minutes=60), + summary="Future Event", + description="Future Description", + location="Future Location", + ) + ], + ) + entity1.async_get_events = AsyncMock(wraps=entity1.async_get_events) + + middle_of_event = dt_util.now() - datetime.timedelta(minutes=30) + entity2 = MockCalendarEntity( + "Calendar 2", + [ + CalendarEvent( + start=middle_of_event, + end=middle_of_event + datetime.timedelta(minutes=60), + summary="Current Event", + ) + ], + ) + entity2.async_get_events = AsyncMock(wraps=entity2.async_get_events) + + return [entity1, entity2] diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr index 67e8839f7a5..fe23c5dbac9 100644 --- a/tests/components/calendar/snapshots/test_init.ambr +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-get_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-00:15:00-get_events] dict({ 'calendar.calendar_1': dict({ 'events': list([ @@ -7,59 +7,59 @@ }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-00:15:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-00:15:00-list_events] dict({ 'events': list([ ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-get_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-01:00:00-get_events] dict({ 'calendar.calendar_1': dict({ 'events': list([ dict({ 'description': 'Future Description', - 'end': '2023-10-19T08:20:05-07:00', + 'end': '2023-10-19T09:20:05-06:00', 'location': 'Future Location', - 'start': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T08:20:05-06:00', 'summary': 'Future Event', }), ]), }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_1-01:00:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_1-01:00:00-list_events] dict({ 'events': list([ dict({ 'description': 'Future Description', - 'end': '2023-10-19T08:20:05-07:00', + 'end': '2023-10-19T09:20:05-06:00', 'location': 'Future Location', - 'start': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T08:20:05-06:00', 'summary': 'Future Event', }), ]), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-get_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_2-00:15:00-get_events] dict({ 'calendar.calendar_2': dict({ 'events': list([ dict({ - 'end': '2023-10-19T07:20:05-07:00', - 'start': '2023-10-19T06:20:05-07:00', + 'end': '2023-10-19T08:20:05-06:00', + 'start': '2023-10-19T07:20:05-06:00', 'summary': 'Current Event', }), ]), }), }) # --- -# name: test_list_events_service_duration[calendar.calendar_2-00:15:00-list_events] +# name: test_list_events_service_duration[frozen_time-calendar.calendar_2-00:15:00-list_events] dict({ 'events': list([ dict({ - 'end': '2023-10-19T07:20:05-07:00', - 'start': '2023-10-19T06:20:05-07:00', + 'end': '2023-10-19T08:20:05-06:00', + 'start': '2023-10-19T07:20:05-06:00', 'summary': 'Current Event', }), ]), diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 25804287172..52d5855271d 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -1,17 +1,16 @@ """The tests for the calendar component.""" from __future__ import annotations +from collections.abc import Generator from datetime import timedelta from http import HTTPStatus from typing import Any -from unittest.mock import patch from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.bootstrap import async_setup_component from homeassistant.components.calendar import ( DOMAIN, LEGACY_SERVICE_LIST_EVENTS, @@ -22,15 +21,46 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util +from .conftest import TEST_DOMAIN, MockCalendarEntity, create_mock_platform + from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(name="frozen_time") +def mock_frozen_time() -> None: + """Fixture to set a frozen time used in tests. + + This is needed so that it can run before other fixtures. + """ + return None + + +@pytest.fixture(autouse=True) +def mock_set_frozen_time(frozen_time: Any) -> Generator[None, None, None]: + """Fixture to freeze time that also can work for other fixtures.""" + if not frozen_time: + yield + else: + with freeze_time(frozen_time): + yield + + +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( + hass: HomeAssistant, + set_time_zone: Any, + frozen_time: Any, + mock_setup_integration: Any, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture to setup platforms used in the test and fixtures are set up in the right order.""" + await create_mock_platform(hass, test_entities) + + async def test_events_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=1) @@ -46,40 +76,34 @@ async def test_events_http_api_missing_fields( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() response = await client.get("/api/calendars/calendar.calendar_2") assert response.status == HTTPStatus.BAD_REQUEST async def test_events_http_api_error( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + test_entities: list[MockCalendarEntity], ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=1) - with patch( - "homeassistant.components.demo.calendar.DemoCalendar.async_get_events", - side_effect=HomeAssistantError("Failure"), - ): - response = await client.get( - f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" - ) - assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert await response.json() == {"message": "Error reading events: Failure"} + test_entities[0].async_get_events.side_effect = HomeAssistantError("Failure") + + response = await client.get( + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert await response.json() == {"message": "Error reading events: Failure"} async def test_events_http_api_dates_wrong_order( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() start = dt_util.now() end = start + timedelta(days=-1) @@ -93,8 +117,6 @@ async def test_calendars_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test the calendar demo view.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_client() response = await client.get("/api/calendars") assert response.status == HTTPStatus.OK @@ -180,8 +202,6 @@ async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload, code ) -> None: """Test unsupported websocket command.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() client = await hass_ws_client(hass) await client.send_json( { @@ -198,9 +218,6 @@ async def test_unsupported_websocket( async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: """Test unsupported service call.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(HomeAssistantError, match="does not support this service"): await hass.services.async_call( DOMAIN, @@ -377,9 +394,6 @@ async def test_create_event_service_invalid_params( ) -> None: """Test creating an event using the create_event service.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(expected_error, match=error_match): await hass.services.async_call( "calendar", @@ -393,7 +407,9 @@ async def test_create_event_service_invalid_params( ) -@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) @pytest.mark.parametrize( ("service", "expected"), [ @@ -439,7 +455,6 @@ async def test_create_event_service_invalid_params( ) async def test_list_events_service( hass: HomeAssistant, - set_time_zone: None, start_time: str, end_time: str, service: str, @@ -451,9 +466,6 @@ async def test_list_events_service( string output values. """ - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - response = await hass.services.async_call( DOMAIN, service, @@ -487,7 +499,7 @@ async def test_list_events_service( ("calendar.calendar_2", "00:15:00"), ], ) -@pytest.mark.freeze_time("2023-10-19 13:50:05") +@pytest.mark.parametrize("frozen_time", ["2023-10-19 13:50:05"], ids=["frozen_time"]) async def test_list_events_service_duration( hass: HomeAssistant, entity: str, @@ -496,9 +508,6 @@ async def test_list_events_service_duration( snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - response = await hass.services.async_call( DOMAIN, service, @@ -514,9 +523,6 @@ async def test_list_events_service_duration( async def test_list_events_positive_duration(hass: HomeAssistant) -> None: """Test listing events requires a positive duration.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(vol.Invalid, match="should be positive"): await hass.services.async_call( DOMAIN, @@ -532,9 +538,6 @@ async def test_list_events_positive_duration(hass: HomeAssistant) -> None: async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: """Test listing events specifying fields that are exclusive.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - end = dt_util.now() + timedelta(days=1) with pytest.raises(vol.Invalid, match="at most one of"): @@ -553,9 +556,6 @@ async def test_list_events_exclusive_fields(hass: HomeAssistant) -> None: async def test_list_events_missing_fields(hass: HomeAssistant) -> None: """Test listing events missing some required fields.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - with pytest.raises(vol.Invalid, match="at least one of"): await hass.services.async_call( DOMAIN, @@ -575,9 +575,6 @@ async def test_issue_deprecated_service_calendar_list_events( ) -> None: """Test the issue is raised on deprecated service weather.get_forecast.""" - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() - _ = await hass.services.async_call( DOMAIN, LEGACY_SERVICE_LIST_EVENTS, @@ -594,7 +591,7 @@ async def test_issue_deprecated_service_calendar_list_events( "calendar", "deprecated_service_calendar_list_events" ) assert issue - assert issue.issue_domain == "demo" + assert issue.issue_domain == TEST_DOMAIN assert issue.issue_id == "deprecated_service_calendar_list_events" assert issue.translation_key == "deprecated_service_calendar_list_events" diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index c529789b596..441d757aa4e 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -1,41 +1,36 @@ """The tests for calendar recorder.""" from datetime import timedelta -from unittest.mock import patch +from typing import Any import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.const import ATTR_FRIENDLY_NAME, Platform +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .conftest import MockCalendarEntity, create_mock_platform + from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done @pytest.fixture(autouse=True) -async def setup_homeassistant(): - """Override the fixture in calendar.conftest.""" +async def mock_setup_dependencies( + recorder_mock: Recorder, + hass: HomeAssistant, + set_time_zone: Any, + mock_setup_integration: None, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture that ensures the recorder is setup in the right order.""" + await create_mock_platform(hass, test_entities) -@pytest.fixture(autouse=True) -async def calendar_only() -> None: - """Enable only the calendar platform.""" - with patch( - "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", - [Platform.CALENDAR], - ): - yield - - -async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" now = dt_util.utcnow() - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) - await hass.async_block_till_done() state = hass.states.get("calendar.calendar_1") assert state diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 02aebf3ce92..120d2e8bfca 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -12,7 +12,6 @@ from collections.abc import AsyncIterator, Callable, Generator from contextlib import asynccontextmanager import datetime import logging -import secrets from typing import Any from unittest.mock import patch import zoneinfo @@ -28,13 +27,14 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .conftest import MockCalendarEntity, create_mock_platform + from tests.common import async_fire_time_changed, async_mock_service _LOGGER = logging.getLogger(__name__) CALENDAR_ENTITY_ID = "calendar.calendar_2" -CONFIG = {calendar.DOMAIN: {"platform": "demo"}} TEST_AUTOMATION_ACTION = { "service": "test.automation", @@ -59,44 +59,6 @@ class FakeSchedule: """Initiailize FakeSchedule.""" self.hass = hass self.freezer = freezer - # Map of event start time to event - self.events: list[calendar.CalendarEvent] = [] - - def create_event( - self, - start: datetime.datetime, - end: datetime.datetime, - summary: str | None = None, - description: str | None = None, - location: str | None = None, - ) -> dict[str, Any]: - """Create a new fake event, used by tests.""" - event = calendar.CalendarEvent( - start=start, - end=end, - summary=summary if summary else f"Event {secrets.token_hex(16)}", - description=description, - location=location, - ) - self.events.append(event) - return event.as_dict() - - async def async_get_events( - self, - hass: HomeAssistant, - start_date: datetime.datetime, - end_date: datetime.datetime, - ) -> list[calendar.CalendarEvent]: - """Get all events in a specific time frame, used by the demo calendar.""" - assert start_date < end_date - values = [] - for event in self.events: - if event.start_datetime_local >= end_date: - continue - if event.end_datetime_local < start_date: - continue - values.append(event) - return values async def fire_time(self, trigger_time: datetime.datetime) -> None: """Fire an alarm and wait.""" @@ -130,19 +92,23 @@ def fake_schedule( # Setup start time for all tests freezer.move_to("2022-04-19 10:31:02+00:00") - schedule = FakeSchedule(hass, freezer) - with patch( - "homeassistant.components.demo.calendar.DemoCalendar.async_get_events", - new=schedule.async_get_events, - ): - yield schedule + return FakeSchedule(hass, freezer) -@pytest.fixture(autouse=True) -async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> None: - """Initialize the demo calendar.""" - assert await async_setup_component(hass, calendar.DOMAIN, CONFIG) - await hass.async_block_till_done() +@pytest.fixture(name="test_entity") +def mock_test_entity(test_entities: list[MockCalendarEntity]) -> MockCalendarEntity: + """Fixture to expose the calendar entity used in tests.""" + return test_entities[1] + + +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( + hass: HomeAssistant, + mock_setup_integration: Any, + test_entities: list[MockCalendarEntity], +) -> None: + """Fixture to setup platforms used in the test.""" + await create_mock_platform(hass, test_entities) @asynccontextmanager @@ -207,9 +173,10 @@ async def test_event_start_trigger( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test the a calendar trigger based on start time.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -240,11 +207,12 @@ async def test_event_start_trigger_with_offset( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, offset_str, offset_delta, ) -> None: """Test the a calendar trigger based on start time with an offset.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) @@ -272,9 +240,10 @@ async def test_event_end_trigger( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test the a calendar trigger based on end time.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), ) @@ -309,11 +278,12 @@ async def test_event_end_trigger_with_offset( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, offset_str, offset_delta, ) -> None: """Test the a calendar trigger based on end time with an offset.""" - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"), ) @@ -356,14 +326,15 @@ async def test_multiple_start_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for multiple events.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -389,14 +360,15 @@ async def test_multiple_end_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for multiple events.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -423,14 +395,15 @@ async def test_multiple_events_sharing_start_time( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for every event sharing a start time.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -457,14 +430,15 @@ async def test_overlap_events( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that a trigger fires for events that overlap.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), ) @@ -533,10 +507,11 @@ async def test_update_next_event( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test detection of a new event after initial trigger is setup.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) @@ -548,7 +523,7 @@ async def test_update_next_event( assert len(calls()) == 0 # Create a new event between now and when the event fires - event_data2 = fake_schedule.create_event( + event_data2 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00"), ) @@ -575,10 +550,11 @@ async def test_update_missed( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, ) -> None: """Test that new events are missed if they arrive outside the update interval.""" - event_data1 = fake_schedule.create_event( + event_data1 = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -590,7 +566,7 @@ async def test_update_missed( ) assert len(calls()) == 0 - fake_schedule.create_event( + test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), ) @@ -664,13 +640,14 @@ async def test_event_payload( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, set_time_zone: None, create_data, fire_time, payload_data, ) -> None: """Test the fields in the calendar event payload are set.""" - fake_schedule.create_event(**create_data) + test_entity.create_event(**create_data) async with create_automation(hass, EVENT_START): assert len(calls()) == 0 @@ -688,13 +665,14 @@ async def test_trigger_timestamp_window_edge( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test that events in the edge of a scan are included.""" freezer.move_to("2022-04-19 11:00:00+00:00") # Exactly at a TEST_UPDATE_INTERVAL boundary the start time, # making this excluded from the first window. - event_data = fake_schedule.create_event( + event_data = test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 11:14:00+00:00"), end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) @@ -717,6 +695,7 @@ async def test_event_start_trigger_dst( hass: HomeAssistant, calls: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, + test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" @@ -725,19 +704,19 @@ async def test_event_start_trigger_dst( freezer.move_to("2023-03-12 01:00:00-08:00") # Before DST transition starts - event1_data = fake_schedule.create_event( + event1_data = test_entity.create_event( summary="Event 1", start=datetime.datetime(2023, 3, 12, 1, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 1, 45, tzinfo=tzinfo), ) # During DST transition (Clocks are turned forward at 2am to 3am) - event2_data = fake_schedule.create_event( + event2_data = test_entity.create_event( summary="Event 2", start=datetime.datetime(2023, 3, 12, 2, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 2, 45, tzinfo=tzinfo), ) # After DST transition has ended - event3_data = fake_schedule.create_event( + event3_data = test_entity.create_event( summary="Event 3", start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), From 7268e9aa568ac2a46a5638890df73ae7b673017b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 Jan 2024 08:57:24 +0100 Subject: [PATCH 1137/1544] Bump/flush mypy cache (#109101) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ecd2737ad72..f7854ef88df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,7 +35,7 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 6 + MYPY_CACHE_VERSION: 7 HA_SHORT_VERSION: "2024.2" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" From b3c1e165db1487545cd5ac3f93885d8ed6f2128c Mon Sep 17 00:00:00 2001 From: peebles Date: Mon, 29 Jan 2024 23:58:02 -0800 Subject: [PATCH 1138/1544] Bump simplisafe-python to 2024.01.0 (#109091) --- homeassistant/components/simplisafe/alarm_control_panel.py | 6 +++--- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index b895be83f2e..71f250b0e02 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -13,7 +13,7 @@ from simplipy.websocket import ( EVENT_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, EVENT_AWAY_EXIT_DELAY_BY_REMOTE, - EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_KEYPAD, EVENT_DISARMED_BY_REMOTE, EVENT_ENTRY_DELAY, EVENT_HOME_EXIT_DELAY, @@ -86,7 +86,7 @@ STATE_MAP_FROM_WEBSOCKET_EVENT = { EVENT_ARMED_HOME: STATE_ALARM_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD: STATE_ALARM_ARMING, EVENT_AWAY_EXIT_DELAY_BY_REMOTE: STATE_ALARM_ARMING, - EVENT_DISARMED_BY_MASTER_PIN: STATE_ALARM_DISARMED, + EVENT_DISARMED_BY_KEYPAD: STATE_ALARM_DISARMED, EVENT_DISARMED_BY_REMOTE: STATE_ALARM_DISARMED, EVENT_ENTRY_DELAY: STATE_ALARM_PENDING, EVENT_HOME_EXIT_DELAY: STATE_ALARM_ARMING, @@ -103,7 +103,7 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( EVENT_ARMED_HOME, EVENT_AWAY_EXIT_DELAY_BY_KEYPAD, EVENT_AWAY_EXIT_DELAY_BY_REMOTE, - EVENT_DISARMED_BY_MASTER_PIN, + EVENT_DISARMED_BY_KEYPAD, EVENT_DISARMED_BY_REMOTE, EVENT_HOME_EXIT_DELAY, ) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index d0d2a4c5689..17afd74cd06 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["simplipy"], - "requirements": ["simplisafe-python==2023.08.0"] + "requirements": ["simplisafe-python==2024.01.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c6eb6ab51e1..925d838427e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2525,7 +2525,7 @@ simplehound==0.3 simplepush==2.2.3 # homeassistant.components.simplisafe -simplisafe-python==2023.08.0 +simplisafe-python==2024.01.0 # homeassistant.components.sisyphus sisyphus-control==3.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f09b1e2482..dcfc3271f2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1920,7 +1920,7 @@ simplehound==0.3 simplepush==2.2.3 # homeassistant.components.simplisafe -simplisafe-python==2023.08.0 +simplisafe-python==2024.01.0 # homeassistant.components.slack slackclient==2.5.0 From 7359449636e200f459e444213d85d7e604d5c3c0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 09:47:52 +0100 Subject: [PATCH 1139/1544] Code quality for Shelly integration (#109061) --- homeassistant/components/shelly/binary_sensor.py | 2 +- homeassistant/components/shelly/config_flow.py | 12 +++++++++--- homeassistant/components/shelly/coordinator.py | 5 ++--- homeassistant/components/shelly/cover.py | 4 ++-- homeassistant/components/shelly/entity.py | 1 + homeassistant/components/shelly/light.py | 8 ++++---- homeassistant/components/shelly/number.py | 4 ++-- homeassistant/components/shelly/sensor.py | 2 +- 8 files changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 4ad51e5cc0f..e9c8e909e87 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -55,7 +55,7 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr """Class to describe a REST binary sensor.""" -SENSORS: Final = { +SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { ("device", "overtemp"): BlockBinarySensorDescription( key="device|overtemp", name="Overheating", diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 70268fd23c4..2ae5a74bb42 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -201,12 +201,18 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if get_info_gen(self.info) in RPC_GENERATIONS: schema = { - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } else: schema = { - vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME)): str, - vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ): str, } return self.async_show_form( diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 7f88cce1134..86fd98b527e 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -7,10 +7,9 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any, Generic, TypeVar, cast -import aioshelly from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType -from aioshelly.const import MODEL_VALVE +from aioshelly.const import MODEL_NAMES, MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType @@ -137,7 +136,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, manufacturer="Shelly", - model=aioshelly.const.MODEL_NAMES.get(self.model, self.model), + model=MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.entry)} ({self.model})", configuration_url=f"http://{self.entry.data[CONF_HOST]}", diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 4390790c794..caff64d7707 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -71,7 +71,7 @@ class BlockShellyCover(ShellyBlockEntity, CoverEntity): """Entity that controls a cover on block based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_supported_features = ( + _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) @@ -147,7 +147,7 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): """Entity that controls a cover on RPC based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER - _attr_supported_features = ( + _attr_supported_features: CoverEntityFeature = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 3132f1f571e..3dd156e9e30 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -172,6 +172,7 @@ def async_setup_rpc_attribute_entities( coordinator = get_entry_data(hass)[config_entry.entry_id].rpc assert coordinator + polling_coordinator = None if not (sleep_period := config_entry.data[CONF_SLEEP_PERIOD]): polling_coordinator = get_entry_data(hass)[config_entry.entry_id].rpc_poll assert polling_coordinator diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 7e49dc78e4d..234f376e85f 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -221,7 +221,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): red = self.block.red green = self.block.green blue = self.block.blue - return (red, green, blue) + return (cast(int, red), cast(int, green), cast(int, blue)) @property def rgbw_color(self) -> tuple[int, int, int, int]: @@ -231,7 +231,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): else: white = self.block.white - return (*self.rgb_color, white) + return (*self.rgb_color, cast(int, white)) @property def color_temp_kelvin(self) -> int: @@ -262,9 +262,9 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): effect_index = self.block.effect if self.coordinator.model == MODEL_BULB: - return SHBLB_1_RGB_EFFECTS[effect_index] + return SHBLB_1_RGB_EFFECTS[cast(int, effect_index)] - return STANDARD_RGB_EFFECTS[effect_index] + return STANDARD_RGB_EFFECTS[cast(int, effect_index)] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5d35e71ce5d..4cab817e67c 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final, cast +from typing import Any, cast from aioshelly.block_device import Block from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError @@ -37,7 +37,7 @@ class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): rest_arg: str = "" -NUMBERS: Final = { +NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", icon="mdi:pipe-valve", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b439a19e318..7ae709ae84f 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -69,7 +69,7 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" -SENSORS: Final = { +SENSORS: dict[tuple[str, str], BlockSensorDescription] = { ("device", "battery"): BlockSensorDescription( key="device|battery", name="Battery", From dcb5c0d43971c667338e1b51e70c2a3ab3da1d9e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Jan 2024 10:07:23 +0100 Subject: [PATCH 1140/1544] Add missing abort message for Spotify (#109102) * Shield for unregistered Spotify users * Shield for unregistered Spotify users --- homeassistant/components/spotify/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 02077cbdb43..e58d2098bde 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -17,7 +17,8 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "connection_error": "Could not fetch account information. Is the user registered in the Spotify Developer Dashboard?" }, "create_entry": { "default": "Successfully authenticated with Spotify." From 821d273e4d7489de714f6b99ef283f2846c927db Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 Jan 2024 04:16:08 -0500 Subject: [PATCH 1141/1544] Add support for ignoring zwave_js device config file changes (#108990) * Add support for ignoring zwave_js device config file changes * mistake * fixes * Small tweaks and add/update tests --- homeassistant/components/zwave_js/repairs.py | 46 +++++---- .../components/zwave_js/strings.json | 11 ++- tests/components/zwave_js/test_repairs.py | 93 +++++++++++++++++-- 3 files changed, 123 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 83ee0523a3b..1010d9abd90 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -1,12 +1,12 @@ """Repairs for Z-Wave JS.""" from __future__ import annotations -import voluptuous as vol - from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from .const import DOMAIN from .helpers import async_get_node_from_device_id @@ -15,34 +15,44 @@ class DeviceConfigFileChangedFlow(RepairsFlow): def __init__(self, data: dict[str, str]) -> None: """Initialize.""" - self.device_name: str = data["device_name"] + self.description_placeholders: dict[str, str] = { + "device_name": data["device_name"] + } self.device_id: str = data["device_id"] async def async_step_init( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await self.async_step_confirm() + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) async def async_step_confirm( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - if user_input is not None: - try: - node = async_get_node_from_device_id(self.hass, self.device_id) - except ValueError: - return self.async_abort( - reason="cannot_connect", - description_placeholders={"device_name": self.device_name}, - ) - self.hass.async_create_task(node.async_refresh_info()) - return self.async_create_entry(title="", data={}) + try: + node = async_get_node_from_device_id(self.hass, self.device_id) + except ValueError: + return self.async_abort( + reason="cannot_connect", + description_placeholders=self.description_placeholders, + ) + self.hass.async_create_task(node.async_refresh_info()) + return self.async_create_entry(title="", data={}) - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders={"device_name": self.device_name}, + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"device_config_file_changed.{self.device_id}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, ) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index ee6a7c3d0b7..8dadac12af1 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -155,13 +155,18 @@ "title": "Device configuration file changed: {device_name}", "fix_flow": { "step": { - "confirm": { + "init": { + "menu_options": { + "confirm": "Re-interview device", + "ignore": "Ignore device config update" + }, "title": "Device configuration file changed: {device_name}", - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background." } }, "abort": { - "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant." + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant.", + "issue_ignored": "Device config file update for {device_name} ignored." } } } diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d18bcfa09aa..d2b702089f2 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -50,7 +50,7 @@ async def _trigger_repair_issue( return node -async def test_device_config_file_changed( +async def test_device_config_file_changed_confirm_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, @@ -58,7 +58,7 @@ async def test_device_config_file_changed( multisensor_6_state, integration, ) -> None: - """Test the device_config_file_changed issue.""" + """Test the device_config_file_changed issue confirm step.""" dev_reg = dr.async_get(hass) node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -87,16 +87,25 @@ async def test_device_config_file_changed( data = await resp.json() flow_id = data["flow_id"] - assert data["step_id"] == "confirm" + assert data["step_id"] == "init" assert data["description_placeholders"] == {"device_name": device.name} - # Apply fix url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu resp = await http_client.post(url) assert resp.status == HTTPStatus.OK data = await resp.json() + assert data["type"] == "menu" + + # Apply fix + resp = await http_client.post(url, json={"next_step_id": "confirm"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -114,6 +123,78 @@ async def test_device_config_file_changed( assert len(msg["result"]["issues"]) == 0 +async def test_device_config_file_changed_ignore_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue ignore step.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + client.async_send_command_no_wait.reset_mock() + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + http_client = await hass_client() + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + assert issue["translation_placeholders"] == {"device_name": device.name} + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == {"device_name": device.name} + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + # Show menu + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "menu" + + # Ignore the issue + resp = await http_client.post(url, json={"next_step_id": "ignore"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "issue_ignored" + assert data["description_placeholders"] == {"device_name": device.name} + + await hass.async_block_till_done() + + assert len(client.async_send_command_no_wait.call_args_list) == 0 + + # Assert the issue still exists but is ignored + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert msg["result"]["issues"][0].get("dismissed_version") is not None + + async def test_invalid_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -196,14 +277,14 @@ async def test_abort_confirm( data = await resp.json() flow_id = data["flow_id"] - assert data["step_id"] == "confirm" + assert data["step_id"] == "init" # Unload config entry so we can't connect to the node await hass.config_entries.async_unload(integration.entry_id) # Apply fix url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) + resp = await http_client.post(url, json={"next_step_id": "confirm"}) assert resp.status == HTTPStatus.OK data = await resp.json() From 09fb043f65b99b60d7dddd0ae55c4d5b3c96bc39 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 10:28:01 +0100 Subject: [PATCH 1142/1544] Add configure option to Vodafone Station for consider home (#108594) * Add configure option to Vodafone Station for consider home * add test * improve tests * reload on option change --- .../components/vodafone_station/__init__.py | 8 ++++ .../vodafone_station/config_flow.py | 47 +++++++++++++++++-- .../components/vodafone_station/strings.json | 9 ++++ .../vodafone_station/test_config_flow.py | 36 ++++++++++---- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 66960921750..b4c44ea9130 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -24,6 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.async_on_unload(entry.add_update_listener(update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -38,3 +40,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update when config_entry options update.""" + if entry.options: + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index dc33d0db52b..987d4d71f41 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -7,9 +7,18 @@ from typing import Any from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions import voluptuous as vol -from homeassistant import core -from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN @@ -30,9 +39,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) -async def validate_input( - hass: core.HomeAssistant, data: dict[str, Any] -) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" api = VodafoneStationSercommApi( @@ -54,6 +61,12 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return VodafoneStationOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -133,3 +146,27 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_REAUTH_DATA_SCHEMA, errors=errors, ) + + +class VodafoneStationOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle a option flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_CONSIDER_HOME, + default=self.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index fab266ac47f..8910d7178b7 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -36,6 +36,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Seconds to consider a device at 'home'" + } + } + } + }, "entity": { "button": { "dsl_reconnect": { "name": "DSL reconnect" }, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 00b1ae6e72a..c04b8364f93 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -4,6 +4,8 @@ from unittest.mock import patch from aiovodafone import exceptions as aiovodafone_exceptions import pytest +from homeassistant import data_entry_flow +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -23,11 +25,7 @@ async def test_user(hass: HomeAssistant) -> None: "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -123,11 +121,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry", - ), patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, @@ -216,3 +210,25 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } From 128700d41ba9305d4579e573a4ae3f4f3b04ae23 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 30 Jan 2024 20:34:30 +1000 Subject: [PATCH 1143/1544] Fix tessie tests (#109113) * Fix device tracker test * Snapshot cleanup --- .../tessie/snapshots/test_binary_sensors.ambr | 220 +++++ .../tessie/snapshots/test_climate.ambr | 921 ------------------ .../tessie/snapshots/test_device_tracker.ambr | 885 +---------------- .../tessie/snapshots/test_media_player.ambr | 15 - .../tessie/snapshots/test_sensor.ambr | 191 ---- .../components/tessie/test_device_tracker.py | 2 +- 6 files changed, 251 insertions(+), 1983 deletions(-) diff --git a/tests/components/tessie/snapshots/test_binary_sensors.ambr b/tests/components/tessie/snapshots/test_binary_sensors.ambr index 73ea5f3989a..2fbd6764081 100644 --- a/tests/components/tessie/snapshots/test_binary_sensors.ambr +++ b/tests/components/tessie/snapshots/test_binary_sensors.ambr @@ -219,6 +219,50 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.test_charge_cable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge cable', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_conn_charge_cable', + 'unique_id': 'VINVINVIN-charge_state_conn_charge_cable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_charge_cable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Charge cable', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.test_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -307,6 +351,50 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.test_front_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_df', + 'unique_id': 'VINVINVIN-vehicle_state_df', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.test_front_driver_window-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -351,6 +439,50 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pf', + 'unique_id': 'VINVINVIN-vehicle_state_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_front_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.test_front_passenger_window-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -482,6 +614,50 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dr', + 'unique_id': 'VINVINVIN-vehicle_state_dr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.test_rear_driver_window-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -526,6 +702,50 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger door', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pr', + 'unique_id': 'VINVINVIN-vehicle_state_pr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_rear_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.test_rear_passenger_window-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr index 47f310849ca..9afc3d4e903 100644 --- a/tests/components/tessie/snapshots/test_climate.ambr +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -1,925 +1,4 @@ # serializer version: 1 -# name: test_climate[binary_sensor.test_auto_seat_climate_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Auto seat climate left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_seat_climate_left', - 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_auto_seat_climate_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Auto seat climate left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_left', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_auto_seat_climate_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Auto seat climate right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_seat_climate_right', - 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_auto_seat_climate_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Auto seat climate right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_right', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_battery_heater-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_battery_heater', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery heater', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_battery_heater_on', - 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_battery_heater-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Battery heater', - }), - 'context': , - 'entity_id': 'binary_sensor.test_battery_heater', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_cabin_overheat_protection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cabin overheat protection', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_cabin_overheat_protection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'Test Cabin overheat protection', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', - 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_charging_state', - 'unique_id': 'VINVINVIN-charge_state_charging_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'Test Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charging', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_dashcam-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_dashcam', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dashcam', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_dashcam_state', - 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_dashcam-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'Test Dashcam', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dashcam', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_front_driver_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_driver_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Front driver window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_fd_window', - 'unique_id': 'VINVINVIN-vehicle_state_fd_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_front_driver_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front driver window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_front_driver_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_front_passenger_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_passenger_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Front passenger window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_fp_window', - 'unique_id': 'VINVINVIN-vehicle_state_fp_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_front_passenger_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front passenger window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_front_passenger_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_heat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_heat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Heat', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_steering_wheel_heat', - 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_heat', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_preconditioning_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Preconditioning enabled', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_preconditioning_enabled', - 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_preconditioning_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Preconditioning enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_rear_driver_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_driver_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear driver window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_rd_window', - 'unique_id': 'VINVINVIN-vehicle_state_rd_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_rear_driver_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear driver window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_driver_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_rear_passenger_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear passenger window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_rp_window', - 'unique_id': 'VINVINVIN-vehicle_state_rp_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_rear_passenger_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear passenger window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_scheduled_charging_pending-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Scheduled charging pending', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_scheduled_charging_pending', - 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_scheduled_charging_pending-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Scheduled charging pending', - }), - 'context': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Status', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'state', - 'unique_id': 'VINVINVIN-state', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Status', - }), - 'context': , - 'entity_id': 'binary_sensor.test_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_front_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning front left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_fl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_front_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning front left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_front_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning front right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_fr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_front_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning front right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning rear left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_rl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning rear left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning rear right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_rr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_tire_pressure_warning_rear_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning rear right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_trip_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_trip_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Trip charging', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_trip_charging', - 'unique_id': 'VINVINVIN-charge_state_trip_charging', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_trip_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Trip charging', - }), - 'context': , - 'entity_id': 'binary_sensor.test_trip_charging', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_climate[binary_sensor.test_user_present-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_user_present', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'User present', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_is_user_present', - 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_climate[binary_sensor.test_user_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'presence', - 'friendly_name': 'Test User present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_user_present', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_climate[climate.test_climate-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index bb96de2f4c6..ff47c73e8cd 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_device_tracker[binary_sensor.test_auto_seat_climate_left-entry] +# name: test_device_tracker[device_tracker.test_location-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9,449 +9,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'binary_sensor', + 'domain': 'device_tracker', 'entity_category': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Auto seat climate left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_seat_climate_left', - 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_left', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_auto_seat_climate_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Auto seat climate left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_left', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_auto_seat_climate_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Auto seat climate right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_seat_climate_right', - 'unique_id': 'VINVINVIN-climate_state_auto_seat_climate_right', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_auto_seat_climate_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Auto seat climate right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_auto_seat_climate_right', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_battery_heater-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_battery_heater', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery heater', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_battery_heater_on', - 'unique_id': 'VINVINVIN-charge_state_battery_heater_on', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_battery_heater-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Battery heater', - }), - 'context': , - 'entity_id': 'binary_sensor.test_battery_heater', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cabin overheat protection', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_cabin_overheat_protection', - 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'Test Cabin overheat protection', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cabin overheat protection actively cooling', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', - 'unique_id': 'VINVINVIN-climate_state_cabin_overheat_protection_actively_cooling', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Cabin overheat protection actively cooling', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_charging_state', - 'unique_id': 'VINVINVIN-charge_state_charging_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'Test Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charging', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_dashcam-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_dashcam', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dashcam', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_dashcam_state', - 'unique_id': 'VINVINVIN-vehicle_state_dashcam_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_dashcam-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'running', - 'friendly_name': 'Test Dashcam', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dashcam', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_front_driver_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_driver_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Front driver window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_fd_window', - 'unique_id': 'VINVINVIN-vehicle_state_fd_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_front_driver_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front driver window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_front_driver_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_front_passenger_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_front_passenger_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Front passenger window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_fp_window', - 'unique_id': 'VINVINVIN-vehicle_state_fp_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_front_passenger_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Front passenger window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_front_passenger_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_heat-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_heat', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Heat', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'climate_state_auto_steering_wheel_heat', - 'unique_id': 'VINVINVIN-climate_state_auto_steering_wheel_heat', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'heat', - 'friendly_name': 'Test Heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_heat', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_preconditioning_enabled-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'entity_id': 'device_tracker.test_location', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -461,28 +21,34 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Preconditioning enabled', + 'original_name': 'Location', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_preconditioning_enabled', - 'unique_id': 'VINVINVIN-charge_state_preconditioning_enabled', + 'translation_key': 'location', + 'unique_id': 'VINVINVIN-location', 'unit_of_measurement': None, }) # --- -# name: test_device_tracker[binary_sensor.test_preconditioning_enabled-state] +# name: test_device_tracker[device_tracker.test_location-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Preconditioning enabled', + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'heading': 185, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + 'speed': None, }), 'context': , - 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'entity_id': 'device_tracker.test_location', 'last_changed': , 'last_updated': , - 'state': 'off', + 'state': 'not_home', }) # --- -# name: test_device_tracker[binary_sensor.test_rear_driver_window-entry] +# name: test_device_tracker[device_tracker.test_route-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -492,97 +58,9 @@ 'device_class': None, 'device_id': , 'disabled_by': None, - 'domain': 'binary_sensor', + 'domain': 'device_tracker', 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_driver_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear driver window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_rd_window', - 'unique_id': 'VINVINVIN-vehicle_state_rd_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_rear_driver_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear driver window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_driver_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_rear_passenger_window-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear passenger window', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_rp_window', - 'unique_id': 'VINVINVIN-vehicle_state_rp_window', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_rear_passenger_window-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'window', - 'friendly_name': 'Test Rear passenger window', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_passenger_window', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_scheduled_charging_pending-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'entity_id': 'device_tracker.test_route', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -592,331 +70,28 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Scheduled charging pending', + 'original_name': 'Route', 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_scheduled_charging_pending', - 'unique_id': 'VINVINVIN-charge_state_scheduled_charging_pending', + 'translation_key': 'route', + 'unique_id': 'VINVINVIN-route', 'unit_of_measurement': None, }) # --- -# name: test_device_tracker[binary_sensor.test_scheduled_charging_pending-state] +# name: test_device_tracker[device_tracker.test_route-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Scheduled charging pending', + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , }), 'context': , - 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'entity_id': 'device_tracker.test_route', 'last_changed': , 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_status-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Status', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'state', - 'unique_id': 'VINVINVIN-state', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Status', - }), - 'context': , - 'entity_id': 'binary_sensor.test_status', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning front left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_fl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fl', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning front left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning front right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_fr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_fr', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_front_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning front right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_left-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning rear left', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_rl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rl', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_left-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning rear left', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_right-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tire pressure warning rear right', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_tpms_soft_warning_rr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_soft_warning_rr', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_tire_pressure_warning_rear_right-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test Tire pressure warning rear right', - }), - 'context': , - 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_trip_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': , - 'entity_id': 'binary_sensor.test_trip_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Trip charging', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_trip_charging', - 'unique_id': 'VINVINVIN-charge_state_trip_charging', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_trip_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Trip charging', - }), - 'context': , - 'entity_id': 'binary_sensor.test_trip_charging', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_device_tracker[binary_sensor.test_user_present-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_user_present', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'User present', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'vehicle_state_is_user_present', - 'unique_id': 'VINVINVIN-vehicle_state_is_user_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_device_tracker[binary_sensor.test_user_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'presence', - 'friendly_name': 'Test User present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_user_present', - 'last_changed': , - 'last_updated': , - 'state': 'off', + 'state': 'not_home', }) # --- diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index 34856626b66..c3747174a4a 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -61,18 +61,3 @@ 'state': 'idle', }) # --- -# name: test_media_player[media_player.test_media_player-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'speaker', - 'friendly_name': 'Test Media player', - 'supported_features': , - 'volume_level': 0.22580323309042688, - }), - 'context': , - 'entity_id': 'media_player.test_media_player', - 'last_changed': , - 'last_updated': , - 'state': 'idle', - }) -# --- diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index be6e62b3635..921aba0b330 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1,52 +1,4 @@ # serializer version: 1 -# name: test_sensors[sensor.test_battery-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_battery', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'drive_state_active_route_energy_at_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[sensor.test_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Test Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.test_battery', - 'last_changed': , - 'last_updated': , - 'state': '65', - }) -# --- # name: test_sensors[sensor.test_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -439,57 +391,6 @@ 'state': 'Giga Texas', }) # --- -# name: test_sensors[sensor.test_distance-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.test_distance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Distance', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'drive_state_active_route_miles_to_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.test_distance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Test Distance', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_distance', - 'last_changed': , - 'last_updated': , - 'state': '75.168198', - }) -# --- # name: test_sensors[sensor.test_distance_to_arrival-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -640,54 +541,6 @@ 'state': '59.2', }) # --- -# name: test_sensors[sensor.test_duration_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_duration_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'drive_state_active_route_minutes_to_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.test_duration_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Test Duration', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_duration_2', - 'last_changed': , - 'last_updated': , - 'state': '59.2', - }) -# --- # name: test_sensors[sensor.test_inside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1144,50 +997,6 @@ 'state': 'unknown', }) # --- -# name: test_sensors[sensor.test_timestamp-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_timestamp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Timestamp', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_state_minutes_to_full_charge', - 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.test_timestamp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Test Timestamp', - }), - 'context': , - 'entity_id': 'sensor.test_timestamp', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_sensors[sensor.test_tire_pressure_front_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py index 5b856b31aec..08d96b7303e 100644 --- a/tests/components/tessie/test_device_tracker.py +++ b/tests/components/tessie/test_device_tracker.py @@ -14,6 +14,6 @@ async def test_device_tracker( ) -> None: """Tests that the device tracker entities are correct.""" - entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) From bea3e638719a6f2e1587fa4c5cda1075ee396238 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 30 Jan 2024 11:49:23 +0100 Subject: [PATCH 1144/1544] Add person icon translations (#109106) --- homeassistant/components/person/icons.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 homeassistant/components/person/icons.json diff --git a/homeassistant/components/person/icons.json b/homeassistant/components/person/icons.json new file mode 100644 index 00000000000..130819bf7f6 --- /dev/null +++ b/homeassistant/components/person/icons.json @@ -0,0 +1,13 @@ +{ + "entity_component": { + "_": { + "default": "mdi:account", + "state": { + "not_home": "mdi:account-arrow-right" + } + } + }, + "services": { + "reload": "mdi:account-sync" + } +} From 8ad0226241da28444f50854ff05deb3ebf47fba3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:22:41 +0100 Subject: [PATCH 1145/1544] Update attrs to 23.2.0 (#109115) --- 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 2863b53b684..330917437b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.38.1 atomicwrites-homeassistant==1.4.1 -attrs==23.1.0 +attrs==23.2.0 awesomeversion==23.11.0 bcrypt==4.0.1 bleak-retry-connector==3.4.0 diff --git a/pyproject.toml b/pyproject.toml index 55299f5c2f5..462d3d326d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.3.1", "astral==2.2", - "attrs==23.1.0", + "attrs==23.2.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==23.11.0", "bcrypt==4.0.1", diff --git a/requirements.txt b/requirements.txt index f48dfe81935..67aad2e9f0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 astral==2.2 -attrs==23.1.0 +attrs==23.2.0 atomicwrites-homeassistant==1.4.1 awesomeversion==23.11.0 bcrypt==4.0.1 From 6fdad449412a94d9c48858be1295f9d605dee40c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 12:24:19 +0100 Subject: [PATCH 1146/1544] Improve invalid error messages in the config flows (#108075) --- homeassistant/data_entry_flow.py | 61 +++++++++++- homeassistant/helpers/data_entry_flow.py | 6 +- .../components/config/test_config_entries.py | 94 +++++++++++++++++-- tests/components/eafm/test_config_flow.py | 4 +- tests/components/homekit/test_config_flow.py | 4 +- tests/components/imap/test_config_flow.py | 2 +- tests/components/melnor/test_config_flow.py | 4 +- tests/components/mqtt/test_config_flow.py | 2 +- tests/components/peco/test_config_flow.py | 4 +- tests/components/risco/test_config_flow.py | 4 +- 10 files changed, 162 insertions(+), 23 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 36c68008a8e..d08e76edbd2 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -104,6 +104,23 @@ class UnknownStep(FlowError): """Unknown step specified.""" +# ignore misc is required as vol.Invalid is not typed +# mypy error: Class cannot subclass "Invalid" (has type "Any") +class InvalidData(vol.Invalid): # type: ignore[misc] + """Invalid data provided.""" + + def __init__( + self, + message: str, + path: list[str | vol.Marker] | None, + error_message: str | None, + schema_errors: dict[str, Any], + **kwargs: Any, + ) -> None: + super().__init__(message, path, error_message, **kwargs) + self.schema_errors = schema_errors + + class AbortFlow(FlowError): """Exception to indicate a flow needs to be aborted.""" @@ -165,6 +182,29 @@ def _async_flow_handler_to_flow_result( return results +def _map_error_to_schema_errors( + schema_errors: dict[str, Any], + error: vol.Invalid, + data_schema: vol.Schema, +) -> None: + """Map an error to the correct position in the schema_errors. + + Raises ValueError if the error path could not be found in the schema. + Limitation: Nested schemas are not supported and a ValueError will be raised. + """ + schema = data_schema.schema + error_path = error.path + if not error_path or (path_part := error_path[0]) not in schema: + raise ValueError("Could not find path in schema") + + if len(error_path) > 1: + raise ValueError("Nested schemas are not supported") + + # path_part can also be vol.Marker, but we need a string key + path_part_str = str(path_part) + schema_errors[path_part_str] = error.error_message + + class FlowManager(abc.ABC): """Manage all the flows that are in progress.""" @@ -334,7 +374,26 @@ class FlowManager(abc.ABC): if ( data_schema := cur_step.get("data_schema") ) is not None and user_input is not None: - user_input = data_schema(user_input) + try: + user_input = data_schema(user_input) + except vol.Invalid as ex: + raised_errors = [ex] + if isinstance(ex, vol.MultipleInvalid): + raised_errors = ex.errors + + schema_errors: dict[str, Any] = {} + for error in raised_errors: + try: + _map_error_to_schema_errors(schema_errors, error, data_schema) + except ValueError: + # If we get here, the path in the exception does not exist in the schema. + schema_errors.setdefault("base", []).append(str(error)) + raise InvalidData( + "Schema validation failed", + path=ex.path, + error_message=ex.error_message, + schema_errors=schema_errors, + ) from ex # Handle a menu navigation choice if cur_step["type"] == FlowResultType.MENU and user_input: diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index aa4ef36b251..9fdd48b59f0 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -110,10 +110,8 @@ class FlowManagerResourceView(_BaseFlowManagerView): result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: return self.json_message("Invalid flow specified", HTTPStatus.NOT_FOUND) - except vol.Invalid as ex: - return self.json_message( - f"User input malformed: {ex}", HTTPStatus.BAD_REQUEST - ) + except data_entry_flow.InvalidData as ex: + return self.json({"errors": ex.schema_errors}, HTTPStatus.BAD_REQUEST) result = self._prepare_result_json(result) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 414f4eb39f2..84afee245a6 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow from homeassistant.components.config import config_entries from homeassistant.config_entries import HANDLERS, ConfigFlow +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.generated import config_flows from homeassistant.helpers import config_entry_flow, config_validation as cv @@ -1019,12 +1020,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No ) assert resp.status == HTTPStatus.BAD_REQUEST data = await resp.json() - assert data == { - "message": ( - "User input malformed: invalid is not a valid option for " - "dictionary value @ data['choices']" - ) - } + assert data == {"errors": {"choices": "invalid is not a valid option"}} async def test_get_single( @@ -2027,3 +2023,89 @@ async def test_subscribe_entries_ws_filtered( "type": "added", } ] + + +async def test_flow_with_multiple_schema_errors(hass: HomeAssistant, client) -> None: + """Test an config flow with multiple schema errors.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_RADIUS): vol.All(int, vol.Range(min=5)), + } + ), + ) + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + "/api/config/config_entries/flow", json={"handler": "test"} + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + + resp = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + json={"latitude": 30000, "longitude": 30000, "radius": 1}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == { + "errors": { + "latitude": "invalid latitude", + "longitude": "invalid longitude", + "radius": "value must be at least 5", + } + } + + +async def test_flow_with_multiple_schema_errors_base( + hass: HomeAssistant, client +) -> None: + """Test an config flow with multiple schema errors where fields are not in the schema.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + } + ), + ) + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await client.post( + "/api/config/config_entries/flow", json={"handler": "test"} + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + + resp = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + json={"invalid": 30000, "invalid_2": 30000}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == { + "errors": { + "base": [ + "extra keys not allowed @ data['invalid']", + "extra keys not allowed @ data['invalid_2']", + ], + "latitude": "required key not provided", + } + } diff --git a/tests/components/eafm/test_config_flow.py b/tests/components/eafm/test_config_flow.py index 5addc80e5ae..208f406d8b9 100644 --- a/tests/components/eafm/test_config_flow.py +++ b/tests/components/eafm/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch import pytest -from voluptuous.error import MultipleInvalid +from voluptuous.error import Invalid from homeassistant import config_entries from homeassistant.components.eafm import const @@ -32,7 +32,7 @@ async def test_flow_invalid_station(hass: HomeAssistant, mock_get_stations) -> N ) assert result["type"] == "form" - with pytest.raises(MultipleInvalid): + with pytest.raises(Invalid): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"station": "My other station"} ) diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index b925fcb341c..838f72be3c6 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1444,7 +1444,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( # sonos_config_switch.entity_id is a config category entity # so it should not be selectable since it will always be excluded - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result2["flow_id"], user_input={"entities": [sonos_config_switch.entity_id]}, @@ -1539,7 +1539,7 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( # sonos_hidden_switch.entity_id is a hidden entity # so it should not be selectable since it will always be excluded - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result2["flow_id"], user_input={"entities": [sonos_hidden_switch.entity_id]}, diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 8c91797ae92..0561085823d 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -465,7 +465,7 @@ async def test_advanced_options_form( # Check if entry was updated for key, value in new_config.items(): assert entry.data[key] == value - except vol.MultipleInvalid: + except vol.Invalid: # Check if form was expected with these options assert assert_result == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index 95a67644606..bb0a017611f 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -48,7 +48,7 @@ async def test_user_step_discovered_devices( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "pick_device" - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ADDRESS: "wrong_address"} ) @@ -95,7 +95,7 @@ async def test_user_step_with_existing_device( assert result["type"] == FlowResultType.FORM - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} ) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index c2a7e0065ce..9e86a3554d6 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -672,7 +672,7 @@ async def test_keepalive_validation( assert result["step_id"] == "broker" if error: - with pytest.raises(vol.MultipleInvalid): + with pytest.raises(vol.Invalid): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=test_input, diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 9ce87d707ff..833c66ab37a 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest -from voluptuous.error import MultipleInvalid +from voluptuous.error import Invalid from homeassistant import config_entries from homeassistant.components.peco.const import DOMAIN @@ -51,7 +51,7 @@ async def test_invalid_county(hass: HomeAssistant) -> None: with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ), pytest.raises(MultipleInvalid): + ), pytest.raises(Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py index 8207ad819b7..cc6cefc1325 100644 --- a/tests/components/risco/test_config_flow.py +++ b/tests/components/risco/test_config_flow.py @@ -383,14 +383,14 @@ async def test_ha_to_risco_schema(hass: HomeAssistant) -> None: ) # Test an HA state that isn't used - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result["flow_id"], user_input={**TEST_HA_TO_RISCO, "armed_custom_bypass": "D"}, ) # Test a combo that can't be selected - with pytest.raises(vol.error.MultipleInvalid): + with pytest.raises(vol.error.Invalid): await hass.config_entries.options.async_configure( result["flow_id"], user_input={**TEST_HA_TO_RISCO, "armed_night": "A"}, From 30dec53b078d5334485dffaf7e6645812b442ab3 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 30 Jan 2024 12:32:02 +0100 Subject: [PATCH 1147/1544] Add device tracker icon translations (#109109) --- homeassistant/components/device_tracker/icons.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 homeassistant/components/device_tracker/icons.json diff --git a/homeassistant/components/device_tracker/icons.json b/homeassistant/components/device_tracker/icons.json new file mode 100644 index 00000000000..c89053701ba --- /dev/null +++ b/homeassistant/components/device_tracker/icons.json @@ -0,0 +1,13 @@ +{ + "entity_component": { + "_": { + "default": "mdi:account", + "state": { + "not_home": "mdi:account-arrow-right" + } + } + }, + "services": { + "see": "mdi:account-eye" + } +} From a1f36c25d44eee8d59bffd598712a82c21eeb86d Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Wed, 31 Jan 2024 00:34:01 +1300 Subject: [PATCH 1148/1544] Remove erroneous reference to Google from Calendar integration (#109089) --- homeassistant/components/calendar/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 41e13b798b6..bef0e2fc09f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,4 +1,4 @@ -"""Support for Google Calendar event device sensors.""" +"""Support for Calendar event device sensors.""" from __future__ import annotations from collections.abc import Callable, Iterable From 9752e70675db0e24e524ae3aa9bec2ecf248fed7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 30 Jan 2024 05:38:29 -0600 Subject: [PATCH 1149/1544] Intents package combines sentences/responses per language (#109079) --- .../components/conversation/default_agent.py | 52 ++++++++--------- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../assist_pipeline/snapshots/test_init.ambr | 40 ++++++------- .../snapshots/test_websocket.ambr | 42 +++++++------- .../conversation/snapshots/test_init.ambr | 58 ++++++------------- .../conversation/test_default_agent.py | 19 +++--- tests/components/conversation/test_init.py | 7 ++- 10 files changed, 107 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bebf8cf4b6a..c9119935213 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -6,7 +6,6 @@ from collections import defaultdict from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass import functools -import itertools import logging from pathlib import Path import re @@ -28,7 +27,7 @@ from hassil.recognize import ( recognize_all, ) from hassil.util import merge_dict -from home_assistant_intents import get_domains_and_languages, get_intents +from home_assistant_intents import get_intents, get_languages import yaml from homeassistant import core, setup @@ -156,7 +155,7 @@ class DefaultAgent(AbstractConversationAgent): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return get_domains_and_languages()["homeassistant"] + return get_languages() async def async_initialize(self, config_intents: dict[str, Any] | None) -> None: """Initialize the default agent.""" @@ -387,6 +386,7 @@ class DefaultAgent(AbstractConversationAgent): return maybe_result # Try again with missing entities enabled + best_num_unmatched_entities = 0 for result in recognize_all( user_input.text, lang_intents.intents, @@ -394,20 +394,28 @@ class DefaultAgent(AbstractConversationAgent): intent_context=intent_context, allow_unmatched_entities=True, ): - # Remove missing entities that couldn't be filled from context - for entity_key, entity in list(result.unmatched_entities.items()): - if isinstance(entity, UnmatchedTextEntity) and ( - entity.text == MISSING_ENTITY - ): - result.unmatched_entities.pop(entity_key) + if result.text_chunks_matched < 1: + # Skip results that don't match any literal text + continue + + # Don't count missing entities that couldn't be filled from context + num_unmatched_entities = 0 + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text != MISSING_ENTITY: + num_unmatched_entities += 1 + else: + num_unmatched_entities += 1 if maybe_result is None: # First result maybe_result = result - elif len(result.unmatched_entities) < len(maybe_result.unmatched_entities): + best_num_unmatched_entities = num_unmatched_entities + elif num_unmatched_entities < best_num_unmatched_entities: # Fewer unmatched entities maybe_result = result - elif len(result.unmatched_entities) == len(maybe_result.unmatched_entities): + best_num_unmatched_entities = num_unmatched_entities + elif num_unmatched_entities == best_num_unmatched_entities: if (result.text_chunks_matched > maybe_result.text_chunks_matched) or ( (result.text_chunks_matched == maybe_result.text_chunks_matched) and ("name" in result.unmatched_entities) # prefer entities @@ -536,14 +544,12 @@ class DefaultAgent(AbstractConversationAgent): intents_dict = lang_intents.intents_dict language_variant = lang_intents.language_variant - domains_langs = get_domains_and_languages() + supported_langs = set(get_languages()) if not language_variant: # Choose a language variant upfront and commit to it for custom # sentences, etc. - all_language_variants = { - lang.lower(): lang for lang in itertools.chain(*domains_langs.values()) - } + all_language_variants = {lang.lower(): lang for lang in supported_langs} # en-US, en_US, en, ... for maybe_variant in _get_language_variations(language): @@ -558,23 +564,17 @@ class DefaultAgent(AbstractConversationAgent): ) return None - # Load intents for all domains supported by this language variant - for domain in domains_langs: - domain_intents = get_intents( - domain, language_variant, json_load=json_load - ) - - if not domain_intents: - continue + # Load intents for this language variant + lang_variant_intents = get_intents(language_variant, json_load=json_load) + if lang_variant_intents: # Merge sentences into existing dictionary - merge_dict(intents_dict, domain_intents) + merge_dict(intents_dict, lang_variant_intents) # Will need to recreate graph intents_changed = True _LOGGER.debug( - "Loaded intents domain=%s, language=%s (%s)", - domain, + "Loaded intents language=%s (%s)", language, language_variant, ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 96fd7aaf67f..89dd880f69e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.1.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 330917437b8..f4205091d97 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ hass-nabucasa==0.75.1 hassil==1.6.0 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240112.0 -home-assistant-intents==2024.1.2 +home-assistant-intents==2024.1.29 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 925d838427e..77e89cae6fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1056,7 +1056,7 @@ holidays==0.41 home-assistant-frontend==20240112.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.2 +home-assistant-intents==2024.1.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcfc3271f2f..327e61e98a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -849,7 +849,7 @@ holidays==0.41 home-assistant-frontend==20240112.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.2 +home-assistant-intents==2024.1.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 128f5479077..e822759d208 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -48,14 +48,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -67,7 +67,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }), 'type': , @@ -75,9 +75,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }), 'type': , @@ -137,14 +137,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -156,7 +156,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -164,9 +164,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -226,14 +226,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -245,7 +245,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -253,9 +253,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -338,14 +338,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -357,7 +357,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }), 'type': , @@ -365,9 +365,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 31b1c44e67e..a050b009a8d 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -46,14 +46,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -64,16 +64,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }) # --- @@ -127,14 +127,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -145,16 +145,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }) # --- @@ -220,14 +220,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -238,16 +238,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }) # --- @@ -421,14 +421,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test transcript', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -439,16 +439,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': 'No device or entity named test transcript', + 'tts_input': "Sorry, I couldn't understand that", 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), }) # --- @@ -778,7 +778,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No area named are', + 'speech': 'Sorry, I am not aware of any area called are', }), }), }), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index e5a732eab8d..23dab0902a9 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -352,14 +352,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named do something', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -519,7 +519,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named late added alias', + 'speech': 'Sorry, I am not aware of any device or entity called late added alias', }), }), }), @@ -539,7 +539,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named kitchen light', + 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', }), }), }), @@ -679,7 +679,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named late added light', + 'speech': 'Sorry, I am not aware of any device or entity called late added light', }), }), }), @@ -759,7 +759,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named kitchen light', + 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', }), }), }), @@ -779,7 +779,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named my cool light', + 'speech': 'Sorry, I am not aware of any device or entity called my cool light', }), }), }), @@ -919,7 +919,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named kitchen light', + 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', }), }), }), @@ -969,7 +969,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named renamed light', + 'speech': 'Sorry, I am not aware of any device or entity called renamed light', }), }), }), @@ -1252,14 +1252,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test text', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -1292,14 +1292,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test text', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -1312,14 +1312,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test text', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -1352,14 +1352,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_valid_targets', + 'code': 'no_intent_match', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'No device or entity named test text', + 'speech': "Sorry, I couldn't understand that", }), }), }), @@ -1510,29 +1510,7 @@ 'unmatched_slots': dict({ }), }), - dict({ - 'details': dict({ - 'domain': dict({ - 'name': 'domain', - 'text': '', - 'value': 'scene', - }), - }), - 'intent': dict({ - 'name': 'HassTurnOn', - }), - 'match': False, - 'sentence_template': '[activate|] [scene] [on]', - 'slots': dict({ - 'domain': 'scene', - }), - 'source': 'builtin', - 'targets': dict({ - }), - 'unmatched_slots': dict({ - 'name': 'this will not match anything', - }), - }), + None, ]), }) # --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 1bd8b5263e5..b992b0086d7 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -147,8 +147,8 @@ async def test_conversation_agent( conversation.HOME_ASSISTANT_AGENT ) with patch( - "homeassistant.components.conversation.default_agent.get_domains_and_languages", - return_value={"homeassistant": ["dwarvish", "elvish", "entish"]}, + "homeassistant.components.conversation.default_agent.get_languages", + return_value=["dwarvish", "elvish", "entish"], ): assert agent.supported_languages == ["dwarvish", "elvish", "entish"] @@ -440,7 +440,7 @@ async def test_error_missing_entity(hass: HomeAssistant, init_components) -> Non assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( result.response.speech["plain"]["speech"] - == "No device or entity named missing entity" + == "Sorry, I am not aware of any device or entity called missing entity" ) @@ -452,7 +452,10 @@ async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS - assert result.response.speech["plain"]["speech"] == "No area named missing area" + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any area called missing area" + ) async def test_error_no_exposed_for_domain( @@ -467,7 +470,8 @@ async def test_error_no_exposed_for_domain( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( - result.response.speech["plain"]["speech"] == "kitchen does not contain a light" + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any light in the kitchen area" ) @@ -483,7 +487,8 @@ async def test_error_no_exposed_for_device_class( assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( - result.response.speech["plain"]["speech"] == "bedroom does not contain a window" + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any window in the bedroom area" ) @@ -596,5 +601,5 @@ async def test_all_domains_loaded( assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( result.response.speech["plain"]["speech"] - == "No device or entity named test light" + == "Sorry, I am not aware of any device or entity called test light" ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index b654f50f8fe..58e94d27aac 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -581,6 +581,7 @@ async def test_http_api_no_match( assert data == snapshot assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "no_intent_match" async def test_http_api_handle_failure( @@ -738,6 +739,7 @@ async def test_ws_api( assert msg["success"] assert msg["result"] == snapshot + assert msg["result"]["response"]["data"]["code"] == "no_intent_match" @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -1180,7 +1182,7 @@ async def test_ws_hass_agent_debug( "turn my cool light off", "turn on all lights in the kitchen", "how many lights are on in the kitchen?", - "this will not match anything", # unmatched in results + "this will not match anything", # None in results ], } ) @@ -1190,6 +1192,9 @@ async def test_ws_hass_agent_debug( assert msg["success"] assert msg["result"] == snapshot + # Last sentence should be a failed match + assert msg["result"]["results"][-1] is None + # Light state should not have been changed assert len(on_calls) == 0 assert len(off_calls) == 0 From a8915b85a4a114a02496741c55579077c403340b Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Tue, 30 Jan 2024 06:40:35 -0500 Subject: [PATCH 1150/1544] Bump pytechnove to 1.2.1 (#109098) * Bump TechnoVE library to 1.2.0 * Bump TechnoVE library to 1.2.1 * Exclude unknown status from the options * Regenerate test_sensor.ambr for TechnoVE snapshot test --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/technove/manifest.json | 2 +- homeassistant/components/technove/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 50b1c1394e7..33739bbd867 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.1.1"], + "requirements": ["python-technove==1.2.1"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index 38d0eeabe49..e4d3822ee1b 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -28,7 +28,7 @@ from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity -STATUS_TYPE = [s.value for s in Status] +STATUS_TYPE = [s.value for s in Status if s != Status.UNKNOWN] @dataclass(frozen=True, kw_only=True) diff --git a/requirements_all.txt b/requirements_all.txt index 77e89cae6fb..f1d634a8b89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2281,7 +2281,7 @@ python-songpal==0.16.1 python-tado==0.17.4 # homeassistant.components.technove -python-technove==1.1.1 +python-technove==1.2.1 # homeassistant.components.telegram_bot python-telegram-bot==13.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 327e61e98a7..1f6be5ea4fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ python-songpal==0.16.1 python-tado==0.17.4 # homeassistant.components.technove -python-technove==1.1.1 +python-technove==1.2.1 # homeassistant.components.telegram_bot python-telegram-bot==13.1 From f7909ee34a1514e7dc423a19f543f9fd6443681f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 13:19:40 +0100 Subject: [PATCH 1151/1544] Clean up Fritz options flow (#109111) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- homeassistant/components/fritz/config_flow.py | 19 +++++----- tests/components/fritz/test_config_flow.py | 36 +++++++++---------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 5474a171798..03bcc3b77f7 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,12 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -289,12 +294,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): - """Handle a option flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle an options flow.""" async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -308,13 +309,13 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_CONSIDER_HOME, - default=self.config_entry.options.get( + default=self.options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional( CONF_OLD_DISCOVERY, - default=self.config_entry.options.get( + default=self.options.get( CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY ), ): bool, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index ded7cda0dea..5b87d897dd9 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -9,11 +9,13 @@ from fritzconnection.core.exceptions import ( ) import pytest +from homeassistant import data_entry_flow from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) from homeassistant.components.fritz.const import ( + CONF_OLD_DISCOVERY, DOMAIN, ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, @@ -453,28 +455,24 @@ async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip) -> None: assert result["step_id"] == "confirm" -async def test_options_flow( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip -) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options flow.""" mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) mock_config.add_to_hass(hass) - with patch( - "homeassistant.components.fritz.config_flow.FritzConnection", - side_effect=fc_class_mock, - ), patch("homeassistant.components.fritz.common.FritzBoxTools"): - result = await hass.config_entries.options.async_init(mock_config.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_config.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_config.entry_id) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CONSIDER_HOME: 37, - }, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert mock_config.options[CONF_CONSIDER_HOME] == 37 + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_OLD_DISCOVERY: False, + CONF_CONSIDER_HOME: 37, + } From 4576fea51111af0054e3aa3347332a4d4b324e89 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 30 Jan 2024 13:19:51 +0100 Subject: [PATCH 1152/1544] Bump python-matter-server to 5.3.1 (#109118) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 32ede276357..b8b384060d6 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.3.0"] + "requirements": ["python-matter-server==5.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f1d634a8b89..cf4a88de366 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ python-kasa[speedups]==0.6.2 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.3.0 +python-matter-server==5.3.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f6be5ea4fb..bbc566c616f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1705,7 +1705,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2 # homeassistant.components.matter -python-matter-server==5.3.0 +python-matter-server==5.3.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 72a28d68d3c3fb58df339bc55501f2a6ea7b4eae Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 30 Jan 2024 13:22:27 +0100 Subject: [PATCH 1153/1544] Add script icon translations (#109107) Co-authored-by: Franck Nijhof --- homeassistant/components/script/icons.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 homeassistant/components/script/icons.json diff --git a/homeassistant/components/script/icons.json b/homeassistant/components/script/icons.json new file mode 100644 index 00000000000..d253d0fd829 --- /dev/null +++ b/homeassistant/components/script/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:script-text", + "state": { + "on": "mdi:script-text-play" + } + } + }, + "services": { + "reload": "mdi:reload", + "turn_on": "mdi:script-text-play", + "turn_off": "mdi:script-text", + "toggle": "mdi:script-text" + } +} From 694059837d7a35c03d7669230908797422a531e8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 30 Jan 2024 13:22:55 +0100 Subject: [PATCH 1154/1544] Add input boolean icon translations (#109108) Co-authored-by: Franck Nijhof --- .../components/input_boolean/icons.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 homeassistant/components/input_boolean/icons.json diff --git a/homeassistant/components/input_boolean/icons.json b/homeassistant/components/input_boolean/icons.json new file mode 100644 index 00000000000..dc595a60fba --- /dev/null +++ b/homeassistant/components/input_boolean/icons.json @@ -0,0 +1,16 @@ +{ + "entity_component": { + "_": { + "default": "mdi:check-circle-outline", + "state": { + "off": "mdi:close-circle-outline" + } + } + }, + "services": { + "toggle": "mdi:toggle-switch", + "turn_off": "mdi:toggle-switch-off", + "turn_on": "mdi:toggle-switch", + "reload": "mdi:reload" + } +} From 6d097886732f4e225db8da77ec892a6a1cd0f830 Mon Sep 17 00:00:00 2001 From: cbrherms Date: Tue, 30 Jan 2024 12:23:33 +0000 Subject: [PATCH 1155/1544] Add missing status's to Nut (#109085) --- homeassistant/components/nut/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index c897542e666..13951a44d90 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -39,6 +39,8 @@ STATE_TYPES = { "BOOST": "Boosting Voltage", "FSD": "Forced Shutdown", "ALARM": "Alarm", + "HE": "ECO Mode", + "TEST": "Battery Testing", } COMMAND_BEEPER_DISABLE = "beeper.disable" From 14766b6992f7049452f980dd26ecdc6bd1caa51b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:30:22 +0100 Subject: [PATCH 1156/1544] Update coverage to 7.4.1 (#109116) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 893860834d4..1f9dda7cc44 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.4.0 +coverage==7.4.1 freezegun==1.3.1 mock-open==1.4.0 mypy==1.8.0 From 92795fecf5d8b980c0bf2f5e7b41ad398e7fd971 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 30 Jan 2024 07:59:00 -0500 Subject: [PATCH 1157/1544] Clean up ZHA cover entity and add support for inverting cover entities derived from the window covering cluster (#108238) --- .../zha/core/cluster_handlers/__init__.py | 10 +- .../zha/core/cluster_handlers/closures.py | 173 ++++++++---- homeassistant/components/zha/cover.py | 234 ++++++++++++---- homeassistant/components/zha/strings.json | 3 + homeassistant/components/zha/switch.py | 61 ++++ tests/components/zha/test_cover.py | 261 +++++++++++++++--- tests/components/zha/test_switch.py | 185 ++++++++++++- 7 files changed, 781 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 65137d683de..6c65a993e95 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -424,10 +424,18 @@ class ClusterHandler(LogMixin): @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]", + self.name, + self.cluster.name, + attr_name, + value, + ) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self._get_attribute_name(attrid), + attr_name, value, ) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index a7056fe9a9f..13ca6f92aaf 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -1,10 +1,10 @@ """Closures cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -import zigpy.zcl -from zigpy.zcl.clusters.closures import DoorLock, Shade, WindowCovering +import zigpy.types as t +from zigpy.zcl.clusters.closures import ConfigStatus, DoorLock, Shade, WindowCovering from homeassistant.core import callback @@ -12,9 +12,6 @@ from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -if TYPE_CHECKING: - from ..endpoint import Endpoint - @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): @@ -53,7 +50,7 @@ class DoorLockClusterHandler(ClusterHandler): command_name = self._cluster.client_commands[command_id].name - if command_name == "operation_event_notification": + if command_name == DoorLock.ClientCommandDefs.operation_event_notification.name: self.zha_send_event( command_name, { @@ -138,62 +135,140 @@ class WindowCoveringClientClusterHandler(ClientClusterHandler): class WindowCoveringClusterHandler(ClusterHandler): """Window cluster handler.""" - _value_attribute_lift = ( - WindowCovering.AttributeDefs.current_position_lift_percentage.id - ) - _value_attribute_tilt = ( - WindowCovering.AttributeDefs.current_position_tilt_percentage.id - ) REPORT_CONFIG = ( AttrReportConfig( - attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE + attr=WindowCovering.AttributeDefs.current_position_lift_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, ), AttrReportConfig( - attr="current_position_tilt_percentage", config=REPORT_CONFIG_IMMEDIATE + attr=WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, ), ) - def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: - """Initialize WindowCovering cluster handler.""" - super().__init__(cluster, endpoint) - - if self.cluster.endpoint.model == "lumi.curtain.agl001": - self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() - self.ZCL_INIT_ATTRS["window_covering_mode"] = True + ZCL_INIT_ATTRS = { + WindowCovering.AttributeDefs.window_covering_type.name: True, + WindowCovering.AttributeDefs.window_covering_mode.name: True, + WindowCovering.AttributeDefs.config_status.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name: True, + WindowCovering.AttributeDefs.installed_open_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_open_limit_tilt.name: True, + } async def async_update(self): """Retrieve latest state.""" - result = await self.get_attribute_value( - "current_position_lift_percentage", from_cache=False + results = await self.get_attributes( + [ + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + ], + from_cache=False, + only_cache=False, ) - self.debug("read current position: %s", result) - if result is not None: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self._value_attribute_lift, - "current_position_lift_percentage", - result, + self.debug( + "read current_position_lift_percentage and current_position_tilt_percentage - results: %s", + results, + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name ) - result = await self.get_attribute_value( - "current_position_tilt_percentage", from_cache=False - ) - self.debug("read current tilt position: %s", result) - if result is not None: + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self._value_attribute_tilt, - "current_position_tilt_percentage", - result, + WindowCovering.AttributeDefs.current_position_lift_percentage.id, + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ), + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + WindowCovering.AttributeDefs.current_position_tilt_percentage.id, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ), ) - @callback - def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: - """Handle attribute update from window_covering cluster.""" - attr_name = self._get_attribute_name(attrid) - self.debug( - "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + @property + def inverted(self): + """Return true if the window covering is inverted.""" + config_status = self.cluster.get( + WindowCovering.AttributeDefs.config_status.name + ) + return ( + config_status is not None + and ConfigStatus.Open_up_commands_reversed in ConfigStatus(config_status) + ) + + @property + def current_position_lift_percentage(self) -> t.uint16_t | None: + """Return the current lift percentage of the window covering.""" + lift_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ) + if lift_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + lift_percentage = 100 - lift_percentage + return lift_percentage + + @property + def current_position_tilt_percentage(self) -> t.uint16_t | None: + """Return the current tilt percentage of the window covering.""" + tilt_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + if tilt_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + tilt_percentage = 100 - tilt_percentage + return tilt_percentage + + @property + def installed_open_limit_lift(self) -> t.uint16_t | None: + """Return the installed open lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_lift.name + ) + + @property + def installed_closed_limit_lift(self) -> t.uint16_t | None: + """Return the installed closed lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_lift.name + ) + + @property + def installed_open_limit_tilt(self) -> t.uint16_t | None: + """Return the installed open tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_tilt.name + ) + + @property + def installed_closed_limit_tilt(self) -> t.uint16_t | None: + """Return the installed closed tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name + ) + + @property + def window_covering_type(self) -> WindowCovering.WindowCoveringType: + """Return the window covering type.""" + return WindowCovering.WindowCoveringType( + self.cluster.get(WindowCovering.AttributeDefs.window_covering_type.name) ) - if attrid in (self._value_attribute_lift, self._value_attribute_tilt): - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value - ) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f36cbc13533..d94a2f907d1 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -4,8 +4,9 @@ from __future__ import annotations import asyncio import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster from zigpy.zcl.foundation import Status from homeassistant.components.cover import ( @@ -14,6 +15,7 @@ from homeassistant.components.cover import ( ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +31,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery +from .core.cluster_handlers.closures import WindowCoveringClusterHandler from .core.const import ( CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_LEVEL, @@ -70,40 +73,145 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) +WCAttrs = WindowCoveringCluster.AttributeDefs +WCT = WindowCoveringCluster.WindowCoveringType +WCCS = WindowCoveringCluster.ConfigStatus + +ZCL_TO_COVER_DEVICE_CLASS = { + WCT.Awning: CoverDeviceClass.AWNING, + WCT.Drapery: CoverDeviceClass.CURTAIN, + WCT.Projector_screen: CoverDeviceClass.SHADE, + WCT.Rollershade: CoverDeviceClass.SHADE, + WCT.Rollershade_two_motors: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior_two_motors: CoverDeviceClass.SHADE, + WCT.Shutter: CoverDeviceClass.SHUTTER, + WCT.Tilt_blind_tilt_only: CoverDeviceClass.BLIND, + WCT.Tilt_blind_tilt_and_lift: CoverDeviceClass.BLIND, +} + + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" _attr_translation_key: str = "cover" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Init this sensor.""" + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this cover.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) - self._current_position = None - self._tilt_position = None + cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) + assert cluster_handler + self._cover_cluster_handler: WindowCoveringClusterHandler = cast( + WindowCoveringClusterHandler, cluster_handler + ) + if self._cover_cluster_handler.window_covering_type: + self._attr_device_class: CoverDeviceClass | None = ( + ZCL_TO_COVER_DEVICE_CLASS.get( + self._cover_cluster_handler.window_covering_type + ) + ) + self._attr_supported_features: CoverEntityFeature = ( + self._determine_supported_features() + ) + self._target_lift_position: int | None = None + self._target_tilt_position: int | None = None + self._determine_initial_state() + + def _determine_supported_features(self) -> CoverEntityFeature: + """Determine the supported cover features.""" + supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + supported_features |= CoverEntityFeature.SET_TILT_POSITION + supported_features |= CoverEntityFeature.OPEN_TILT + supported_features |= CoverEntityFeature.CLOSE_TILT + supported_features |= CoverEntityFeature.STOP_TILT + return supported_features + + def _determine_initial_state(self) -> None: + """Determine the initial state of the cover.""" + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + self._determine_state( + self.current_cover_tilt_position, is_lift_update=False + ) + if ( + self._cover_cluster_handler.window_covering_type + == WCT.Tilt_blind_tilt_and_lift + ): + state = self._state + self._determine_state(self.current_cover_position) + if state == STATE_OPEN and self._state == STATE_CLOSED: + # let the tilt state override the lift state + self._state = STATE_OPEN + else: + self._determine_state(self.current_cover_position) + + def _determine_state(self, position_or_tilt, is_lift_update=True) -> None: + """Determine the state of the cover. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ + if is_lift_update: + target = self._target_lift_position + current = self.current_cover_position + else: + target = self._target_tilt_position + current = self.current_cover_tilt_position + + if position_or_tilt == 100: + self._state = STATE_CLOSED + return + if target is not None and target != current: + # we are mid transition and shouldn't update the state + return + self._state = STATE_OPEN async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" + """Run when the cover entity is about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( - self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_position + self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.zcl_attribute_updated ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - self._state = last_state.state - if "current_position" in last_state.attributes: - self._current_position = last_state.attributes["current_position"] - if "current_tilt_position" in last_state.attributes: - self._tilt_position = last_state.attributes[ - "current_tilt_position" - ] # first allocation activate tilt - @property def is_closed(self) -> bool | None: - """Return if the cover is closed.""" + """Return True if the cover is closed. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ if self.current_cover_position is None: return None return self.current_cover_position == 0 @@ -122,39 +230,45 @@ class ZhaCover(ZhaEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return the current position of ZHA cover. - None is unknown, 0 is closed, 100 is fully open. + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler """ - return self._current_position + return self._cover_cluster_handler.current_position_lift_percentage @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" - return self._tilt_position + return self._cover_cluster_handler.current_position_tilt_percentage @callback - def async_set_position(self, attr_id, attr_name, value): + def zcl_attribute_updated(self, attr_id, attr_name, value): """Handle position update from cluster handler.""" - _LOGGER.debug("setting position: %s %s %s", attr_id, attr_name, value) - if attr_name == "current_position_lift_percentage": - self._current_position = 100 - value - elif attr_name == "current_position_tilt_percentage": - self._tilt_position = 100 - value - - if self._current_position == 0: - self._state = STATE_CLOSED - elif self._current_position == 100: - self._state = STATE_OPEN + if attr_id in ( + WCAttrs.current_position_lift_percentage.id, + WCAttrs.current_position_tilt_percentage.id, + ): + value = ( + self.current_cover_position + if attr_id == WCAttrs.current_position_lift_percentage.id + else self.current_cover_tilt_position + ) + self._determine_state( + value, + is_lift_update=attr_id == WCAttrs.current_position_lift_percentage.id, + ) self.async_write_ha_state() @callback def async_update_state(self, state): - """Handle state update from cluster handler.""" - _LOGGER.debug("state=%s", state) + """Handle state update from HA operations below.""" + _LOGGER.debug("async_update_state=%s", state) self._state = state self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: - """Open the window cover.""" + """Open the cover.""" res = await self._cover_cluster_handler.up_open() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to open cover: {res[1]}") @@ -162,13 +276,14 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" + # 0 is open in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(0) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs: Any) -> None: - """Close the window cover.""" + """Close the cover.""" res = await self._cover_cluster_handler.down_close() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to close cover: {res[1]}") @@ -176,42 +291,63 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" + # 100 is closed in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(100) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the roller shutter to a specific position.""" - new_pos = kwargs[ATTR_POSITION] - res = await self._cover_cluster_handler.go_to_lift_percentage(100 - new_pos) + """Move the cover to a specific position.""" + self._target_lift_position = kwargs[ATTR_POSITION] + assert self._target_lift_position is not None + assert self.current_cover_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_lift_percentage( + 100 - self._target_lift_position + ) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to set cover position: {res[1]}") self.async_update_state( - STATE_CLOSING if new_pos < self._current_position else STATE_OPENING + STATE_CLOSING + if self._target_lift_position < self.current_cover_position + else STATE_OPENING ) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover til to a specific position.""" - new_pos = kwargs[ATTR_TILT_POSITION] - res = await self._cover_cluster_handler.go_to_tilt_percentage(100 - new_pos) + """Move the cover tilt to a specific position.""" + self._target_tilt_position = kwargs[ATTR_TILT_POSITION] + assert self._target_tilt_position is not None + assert self.current_cover_tilt_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_tilt_percentage( + 100 - self._target_tilt_position + ) if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") self.async_update_state( - STATE_CLOSING if new_pos < self._tilt_position else STATE_OPENING + STATE_CLOSING + if self._target_tilt_position < self.current_cover_tilt_position + else STATE_OPENING ) async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop the window cover.""" + """Stop the cover.""" res = await self._cover_cluster_handler.stop() if res[1] is not Status.SUCCESS: raise HomeAssistantError(f"Failed to stop cover: {res[1]}") - self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED + self._target_lift_position = self.current_cover_position + self._determine_state(self.current_cover_position) self.async_write_ha_state() async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" - await self.async_stop_cover() + res = await self._cover_cluster_handler.stop() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + self._target_tilt_position = self.current_cover_tilt_position + self._determine_state(self.current_cover_tilt_position, is_lift_update=False) + self.async_write_ha_state() @MULTI_MATCH( diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index e2875550398..08c485f01b3 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -905,6 +905,9 @@ "invert_switch": { "name": "Invert switch" }, + "inverted": { + "name": "Inverted" + }, "smart_bulb_mode": { "name": "Smart bulb mode" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index d4e835751f5..57b84bd1aa1 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, SIGNAL_ADD_ENTITIES, @@ -588,3 +590,62 @@ class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): _attribute_name = "buzzer_manual_alarm" _attr_translation_key = "buzzer_manual_alarm" _attr_icon: str = "mdi:bullhorn" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls inversion for window covering devices. + + This is necessary because this cluster uses 2 attributes to control inversion. + """ + + _unique_id_suffix = "inverted" + _attribute_name = WindowCovering.AttributeDefs.config_status.name + _attr_translation_key = "inverted" + _attr_icon: str = "mdi:arrow-up-down" + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + config_status = ConfigStatus( + self._cluster_handler.cluster.get(self._attribute_name) + ) + return ConfigStatus.Open_up_commands_reversed in config_status + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_on_off(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_on_off(False) + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + self.debug("Polling current state") + await self._cluster_handler.get_attributes( + [ + self._attribute_name, + WindowCovering.AttributeDefs.window_covering_mode.name, + ], + from_cache=False, + only_cache=False, + ) + self.async_write_ha_state() + + async def _async_on_off(self, invert: bool) -> None: + """Turn the entity on or off.""" + name: str = WindowCovering.AttributeDefs.window_covering_mode.name + current_mode: WindowCoveringMode = WindowCoveringMode( + self._cluster_handler.cluster.get(name) + ) + send_command: bool = False + if invert and WindowCoveringMode.Motor_direction_reversed not in current_mode: + current_mode |= WindowCoveringMode.Motor_direction_reversed + send_command = True + elif not invert and WindowCoveringMode.Motor_direction_reversed in current_mode: + current_mode &= ~WindowCoveringMode.Motor_direction_reversed + send_command = True + if send_command: + await self._cluster_handler.write_attributes_safe({name: current_mode}) + await self.async_update() diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 5a1f2a862ac..55a4cbebfe7 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -28,7 +28,9 @@ from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( ATTR_COMMAND, STATE_CLOSED, + STATE_CLOSING, STATE_OPEN, + STATE_OPENING, STATE_UNAVAILABLE, Platform, ) @@ -42,6 +44,7 @@ from .common import ( find_entity_id, make_zcl_header, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -131,21 +134,40 @@ def zigpy_keen_vent(zigpy_device_mock): ) -async def test_cover( +WCAttrs = closures.WindowCovering.AttributeDefs +WCCmds = closures.WindowCovering.ServerCommandDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus + + +async def test_cover_non_tilt_initial_state( hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device ) -> None: """Test ZHA cover platform.""" # load up cover domain - cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster = zigpy_cover_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - "current_position_lift_percentage": 65, - "current_position_tilt_percentage": 42, + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } + update_attribute_cache(cluster) zha_device = await zha_device_joined_restored(zigpy_cover_device) - assert cluster.read_attributes.call_count == 1 - assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] - assert "current_position_tilt_percentage" in cluster.read_attributes.call_args[0][0] + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -161,27 +183,86 @@ async def test_cover( # test update prev_call_count = cluster.read_attributes.call_count await async_update_entity(hass, entity_id) - assert cluster.read_attributes.call_count == prev_call_count + 2 + assert cluster.read_attributes.call_count == prev_call_count + 1 state = hass.states.get(entity_id) assert state assert state.state == STATE_OPEN - assert state.attributes[ATTR_CURRENT_POSITION] == 35 + assert state.attributes[ATTR_CURRENT_POSITION] == 100 + + +async def test_cover( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.COVER, zha_device, hass) + assert entity_id is not None + + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the cover was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 1 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 100 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 # test that the state has changed from unavailable to off - await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) assert hass.states.get(entity_id).state == STATE_CLOSED # test to see if it opens - await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) assert hass.states.get(entity_id).state == STATE_OPEN # test that the state remains after tilting to 100% - await send_attributes_report(hass, cluster, {0: 0, 9: 100, 1: 1}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) assert hass.states.get(entity_id).state == STATE_OPEN # test to see the state remains after tilting to 0% - await send_attributes_report(hass, cluster, {0: 1, 9: 0, 1: 100}) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) assert hass.states.get(entity_id).state == STATE_OPEN # close from UI @@ -192,9 +273,17 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x01 - assert cluster.request.call_args[0][2].command.name == "down_close" + assert cluster.request.call_args[0][2].command.name == WCCmds.down_close.name assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSED + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -205,10 +294,21 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 100 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSED + # open from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -217,9 +317,17 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x00 - assert cluster.request.call_args[0][2].command.name == "up_open" + assert cluster.request.call_args[0][2].command.name == WCCmds.up_open.name assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_OPENING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -230,10 +338,21 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 0 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_OPENING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + # set position UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -245,10 +364,27 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x05 - assert cluster.request.call_args[0][2].command.name == "go_to_lift_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_lift_percentage.name + ) assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -259,10 +395,27 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert hass.states.get(entity_id).state == STATE_CLOSING + + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert hass.states.get(entity_id).state == STATE_OPEN + # stop from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -271,7 +424,7 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x02 - assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name assert cluster.request.call_args[1]["expect_reply"] is True with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): @@ -284,11 +437,11 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x02 - assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name assert cluster.request.call_args[1]["expect_reply"] is True # test rejoin - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 0} + cluster.PLUGGED_ATTR_READS = {WCAttrs.current_position_lift_percentage.name: 0} await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,)) assert hass.states.get(entity_id).state == STATE_OPEN @@ -303,7 +456,10 @@ async def test_cover( assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0x08 - assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) assert cluster.request.call_args[0][3] == 100 assert cluster.request.call_args[1]["expect_reply"] is True @@ -314,11 +470,12 @@ async def test_cover_failures( """Test ZHA cover platform failure cases.""" # load up cover domain - cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster = zigpy_cover_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - "current_position_lift_percentage": None, - "current_position_tilt_percentage": 42, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, } + update_attribute_cache(cluster) zha_device = await zha_device_joined_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -331,7 +488,7 @@ async def test_cover_failures( # test update returned None prev_call_count = cluster.read_attributes.call_count await async_update_entity(hass, entity_id) - assert cluster.read_attributes.call_count == prev_call_count + 2 + assert cluster.read_attributes.call_count == prev_call_count + 1 assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # allow traffic to flow through the gateway and device @@ -493,6 +650,27 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.stop.id ) + # stop from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to stop cover"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.stop.id + ) + async def test_shade( hass: HomeAssistant, zha_device_joined_restored, zigpy_shade_device @@ -502,8 +680,8 @@ async def test_shade( # load up cover domain zha_device = await zha_device_joined_restored(zigpy_shade_device) - cluster_on_off = zigpy_shade_device.endpoints.get(1).on_off - cluster_level = zigpy_shade_device.endpoints.get(1).level + cluster_on_off = zigpy_shade_device.endpoints[1].on_off + cluster_level = zigpy_shade_device.endpoints[1].level entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -700,16 +878,13 @@ async def test_cover_restore_state( hass: HomeAssistant, zha_device_restored, zigpy_cover_device ) -> None: """Ensure states are restored on startup.""" - mock_restore_cache( - hass, - ( - State( - "cover.fakemanufacturer_fakemodel_cover", - STATE_OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 42}, - ), - ), - ) + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 50, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + } + update_attribute_cache(cluster) hass.set_state(CoreState.starting) @@ -719,8 +894,8 @@ async def test_cover_restore_state( # test that the cover was created and that it is available assert hass.states.get(entity_id).state == STATE_OPEN - assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 - assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 42 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 100 - 50 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 100 - 42 async def test_keen_vent( @@ -731,8 +906,8 @@ async def test_keen_vent( # load up cover domain zha_device = await zha_device_joined_restored(zigpy_keen_vent) - cluster_on_off = zigpy_keen_vent.endpoints.get(1).on_off - cluster_level = zigpy_keen_vent.endpoints.get(1).level + cluster_on_off = zigpy_keen_vent.endpoints[1].on_off + cluster_level = zigpy_keen_vent.endpoints[1].level entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 0db9b7dd18e..cd25d17f84f 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -1,5 +1,5 @@ """Test ZHA switch.""" -from unittest.mock import call, patch +from unittest.mock import AsyncMock, call, patch import pytest from zhaquirks.const import ( @@ -13,6 +13,7 @@ from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha from zigpy.quirks import CustomCluster, CustomDevice import zigpy.types as t +import zigpy.zcl.clusters.closures as closures import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster import zigpy.zcl.foundation as zcl_f @@ -23,6 +24,7 @@ from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component from .common import ( @@ -32,8 +34,9 @@ from .common import ( async_wait_for_updates, find_entity_id, send_attributes_report, + update_attribute_cache, ) -from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE ON = 1 OFF = 0 @@ -69,6 +72,24 @@ def zigpy_device(zigpy_device_mock): return zigpy_device_mock(endpoints) +@pytest.fixture +def zigpy_cover_device(zigpy_device_mock): + """Zigpy cover device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + closures.WindowCovering.cluster_id, + ], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock(endpoints) + + @pytest.fixture async def coordinator(hass, zigpy_device_mock, zha_device_joined): """Test ZHA light platform.""" @@ -136,7 +157,7 @@ async def test_switch( """Test ZHA switch platform.""" zha_device = await zha_device_joined_restored(zigpy_device) - cluster = zigpy_device.endpoints.get(1).on_off + cluster = zigpy_device.endpoints[1].on_off entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None @@ -177,6 +198,9 @@ async def test_switch( manufacturer=None, tsn=None, ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # turn off from HA with patch( @@ -196,6 +220,9 @@ async def test_switch( manufacturer=None, tsn=None, ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF await async_setup_component(hass, "homeassistant", {}) @@ -338,6 +365,20 @@ async def test_zha_group_switch_entity( ) assert hass.states.get(entity_id).state == STATE_ON + # test turn off failure case + hold_off = group_cluster_on_off.off + group_cluster_on_off.off = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE]) + # turn off via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert len(group_cluster_on_off.off.mock_calls) == 1 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + group_cluster_on_off.off = hold_off + # turn off from HA with patch( "zigpy.zcl.Cluster.request", @@ -358,6 +399,20 @@ async def test_zha_group_switch_entity( ) assert hass.states.get(entity_id).state == STATE_OFF + # test turn on failure case + hold_on = group_cluster_on_off.on + group_cluster_on_off.on = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE]) + # turn on via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert len(group_cluster_on_off.on.mock_calls) == 1 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + group_cluster_on_off.on = hold_on + # test some of the group logic to make sure we key off states correctly await send_attributes_report(hass, dev1_cluster_on_off, {0: 1}) await send_attributes_report(hass, dev2_cluster_on_off, {0: 1}) @@ -391,7 +446,7 @@ async def test_switch_configurable( """Test ZHA configurable switch platform.""" zha_device = await zha_device_joined_restored(zigpy_device_tuya) - cluster = zigpy_device_tuya.endpoints.get(1).tuya_manufacturer + cluster = zigpy_device_tuya.endpoints[1].tuya_manufacturer entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None @@ -507,3 +562,125 @@ async def test_switch_configurable( # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device_tuya, [cluster], (0,)) + + +WCAttrs = closures.WindowCovering.AttributeDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus +WCM = closures.WindowCovering.WindowCoveringMode + + +async def test_cover_inversion_switch( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + WCAttrs.window_covering_mode.name: WCM(WCM.LEDs_display_feedback), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is not None + + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the cover was created and that it is unavailable + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 1 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # test to see the state remains after tilting to 0% + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + with patch( + "zigpy.zcl.Cluster.write_attributes", return_value=[0x1, zcl_f.Status.SUCCESS] + ): + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational + | WCCS.Open_up_commands_reversed, + } + # turn on from UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + { + WCAttrs.window_covering_mode.name: WCM.Motor_direction_reversed + | WCM.LEDs_display_feedback + }, + manufacturer=None, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + cluster.write_attributes.reset_mock() + + # turn off from UI + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational, + } + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + {WCAttrs.window_covering_mode.name: WCM.LEDs_display_feedback}, + manufacturer=None, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + cluster.write_attributes.reset_mock() + + # test that sending the command again does not result in a write + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.call_count == 0 + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF From a8e3df7e502b6e43065357a839fe613dfba74d46 Mon Sep 17 00:00:00 2001 From: Mohamed <10786768+xMohamd@users.noreply.github.com> Date: Tue, 30 Jan 2024 16:00:57 +0200 Subject: [PATCH 1158/1544] Fix readme images (#108767) --- .github/assets/screenshot-integrations.png | Bin 0 -> 66219 bytes .github/assets/screenshot-states.png | Bin 0 -> 117735 bytes README.rst | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 .github/assets/screenshot-integrations.png create mode 100644 .github/assets/screenshot-states.png diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png new file mode 100644 index 0000000000000000000000000000000000000000..8d71bf538d6c775dc4c09447b265497d8cf83a47 GIT binary patch literal 66219 zcmeAS@N?(olHy`uVBq!ia0y~yVDe{RVEn|v#=yYvsah+7fq{XsILO_JVcj{ImkbOH zEa{HEjtmSN`?>!lvNA9*a29w(7BevL9R^{>lFzsfc?smvx4W>#t*t zyC1)M(DJx%_kYQ}_TAnM59Bx|v2aXiQBiT>C@e28zqw=g?%nB+->sW{wdQ|a_Osb* z->sB;JvZg)F)74#i~QW!Z5Sl zaH%+py4qWVWnY>e99beO8!YEokaVe!GyL-toey_*7B>kTSjm5Jf`G1HZAV*6M&YZ& zOB5gYq-%Mf*lqDO@zR9J3ZI|oxY!@KxjCKn;4;qTaW2kIDlcXgymB>BacNq|xjb%( zy|9w&^_F1$HOj(fpO>T;alJV+)7Z>!zMbw9#W`Q!i_BXmeQ`p6cwZ9e0{%6 zQGJoJ^{ywrNE^|Pb@Tr1TliE|zmbNT%bH&K}(z{?rOxm;29;=~7T zDpr?lC;1()*~$6#$H&K8>i^f>+FNb@_SRN!fhl4OI5V8Z=e&%+I3dBwM5W@Wud|wq zdz19S5|L95nxBtw-muwtp-;wg5!bQjSFVKYDtS3cK||L;_yU^=)7lq{lK*zJv6#C! zH--C19ZJ6@@S3BekH!2>3CrIX_cWGNUXs?`VX$Mm5Nr= zBex1?sb8EZz`3jM&@A=47K|xQi(3Vz?I=-{xxlf7=_2Ec_WxqMoD&tKg!UYga=O2C zcEKNADw;en?>mvQAm>@@>b`^JPa{i-#aDXIx|00!g!g zo);b+Y#FS(8pXLpYmH{93(gkceeuI@o{i*-4-9tQNNSf?3dwR_WLUf4ui$5uBeE}l zu&nQBV_EN@eW7QD`a$jsT({=0I4gXltw+gBF=)r*%hvXp8(K0zvCWw^3tX?;dxf(-OH^FAwj7cY`mAz96k+xnc|(7vH4fTq z6b=2IVkY+af9T5a_`tR0&@Yf9289OwDk-xpzxiT1PV;O3ndW-k$?=&y9haC%y+#I$x$~F7?x}{gH zgv_$9w|jeIWAmg*lQK?C(bVF^iq*&tdXzQql9ArUHshBj3I1H6 z@$+5oueb1boj!kgQTC)2XRoEqs@CZ}wadV48G&A&o`%!-h1oufa-Q2`v3;?7|D}1h)h}*rOm_U;xKgHJ&%|Z@Ds!VN zEd?`LzY6zOp4#whgX!+eE8m^y{*t(w`-|J;&mn7$vr26=Y1LDi{h4E@ct)$Ki23Id z70YHRwYVku)8_J@EI+M(;nCyFQyDW)2_`Y~{@%B@y7#x9deS^_Xs)?-VVUpj7p>xP z3$#L4X``u!9`PwP_e!sI8 zm3(ZHnVC67KYrhWP4nviRc_rIv$N>cFAH0x6xS}1jZ1Z7c5E=JnA2AIxYs=A<*t&K zLQ~Y1gQ7~w)Zf5w^7UmGSNEQi*1Tujc~0$Q|NlKKTMkKebywc1(`)uxvm?RS(^Xe% zjqjpY(t1ILhxe_sp46Mlxv`ONx!>BTW3|&KuX*v~gSJGIO~C_&wJ|$qP1A|=lQz#= z^!RxHYQ?D$^@@w{e15KfVWD&T+P-XA-MBqhwCZ`^-miDaZD%wsHOgDEbm{8PmUVwx zt~TZ_JL)~{;-{zDnZNsFua&>BQB_xWFIkuOjI&Zd&S#l%`ngrF-G6F_uMPap`knpy z;_1SgFDB^6cq}^X-ghbM4*TjmTeGiU7JO}d>S$f?zJdo;{`2jeZ*PCUVCm_w3zwE^ zXWZWQHY~cx?``*&d&gEOPO;CrqH(+S;QjS`ze$Czi3oI^ob>c|{{E$Xveviyj=$Mj zx8+XF=d)jS-~ZdUZr`u0U+?SxdvCv2mE91Xx3jhG>+1N6_y7MZpDOp!;2<^g;2`BE5GzpUeHaegB_Zcx>rZt>)8z&)5HR z-v9sa`>TfRyi!ZPG`$d2%B<1z;Gdkb>pQ>g7lFSI`RfB-2Go6icDDJ_laK|X*Zh|} z%l!JTq-yQPn-+$=?(rtiM2plfaAj+pK6`m$w`53G>N3k!+x{u#+D!Hnzhrb-;Z(_m zqBqXpmtNR@+L$-w{oSzirBhXp^cne^7H_L;s=T?oT%K3naMr2t6^nRU{T?da5B|&M zoB8ig>oxs1laCqs{5W;jU)jBPX?)#E$NBXid)MA+nqTXhdg`Cg-(UPo&d%Ph<(#6` z?xeq$Lb?cS$|NZT4{|%o*Z)EL?TGV`Jd0ky~opTrekDj-a)%=66i)Zf4 zuqn8|tL&|(s@IgQQ#rfpq9<9EzVmy1EjQqLs+O~Q81F$1#i*9wXKx35H}LP^tNfXN zZjR-;iIR8JUAp6{UaF?H8MMFZD4j2PW^Pkw;%Ao4tj;Bmd(ESyUM9@^{`b|s1wFU6 zW@|Hly%wE+@yg2J&?=tSTXS!R%~W?exFbelP2SyInx8*^ILyELcU$qny;UENioe{x z|L>Yb5_jIDX+h-fY;jB(I3;0@rKxpwA)MKU^0lUaH)Cvhr5#nLn|9+xIn*>qPj3uD%+k#x(cc_4`v6 zY_bnsRP_jtF|3}vnBifO{;6`HeBR!$xSFKF>WjB68%5XYnqn((XPJ| z7ILTlZuD`L%AF}R!?@+++^32{TWWr4tqR^=xWHycU;cjWrKhw%I!9`S{MdcKF6aHl z#r$vY?LB_A$KveelarTc-1rcQMBFCPB<epO00V!@h{Z7-*->to1^_oAoH-~{W)p#7}`!3%z3?j|2?BEzjJ;U#Qpzq+@9%K z=9?QEzdX19AIZDda&gTLKc0sN1wvPc>2BB?zCP}hP2#+1^W=WH-!yRe{Ol}uPW{)b z;ZrN643k=P_X?kR>U3{k?Qf~?&!O4tf`Ac)v{8`o+nG^DhNEb6=V^ z*}6BAK`v-T+}>SMYHiCB79}*T4qNNBHR~$ZCI3AK_WpP}JwD^{vEE~UA4-_#%`ti| zZI)y3uD`93nf+1=r*O~j8P4r|k@jxk65n09+s*{e%bd@*<&e~=pwC<0xI`VWFuhS? zxTNsX%qk7j3G1&NThg(}KjY6d-B>r}w*CceTlwVPPcBWHBmU;@=5{gN7>~nkJWCa)NyqO>S-DGd zPyOjVs`sa zvEBy$@A><#YCN9R7WF-CX(@kU|5qEUjU_b(Dx1^KFW!_|A2wHq$7%L9+00W@UTPIJ z`_&&jbkwBcL_w<9glFCQ`xmPDMqko%w|%wX+{MWH@3op!pPzQRAJp7-Hsi&HiLWZU z#dHJz^GTa|xo){(Q}fri@YTi5>zC^P)-RM0kT8_sm5`XxBW3#PVnfS8ljLJLr;q3? zto$r~=|-XO%GllK8Z~$>J=|dsP*vHJH*ebdh?PnS7VLA%Ka1st9N~FxS$>acmPMvk z>HB-L>o)nQUQ(7{6cfDc+=eY_W>zcQdFmhC`&en7cj;2rAGK8vP4C=S^`3Td#l^=u z!8bG}EZbF@``SFG$h7u&pZxNBdw;Vlzdg$tFt4`cp6Ind9vZc^zR{nl% zPv^G24Y#JBdB09r{~4$7KK|(Ys(GgwdRk^zI@x7N6)(8Zxq#=6{`2~{Gs64cPge6? z)G4gK>im!IZPIxQj>}d1#7>_l_Fd|FKc8p*9p{DH~2b>fbHQY%5F38xJgU?T%Tz zXNB_&AD?UNKW^tb?d8m9{n~8S|9ew_6_Wk* z-SYelV^!jh=~L>zY~S*J&CcXw8xtdDnPe_9S^563ssFZLOtsNA2lsYdwRvbAv^_7@ zbjjR=ukOoCE*j+CGKtFgx_?z^(^I@8tJ#DGx zWVOpT43l51*5?kK8MJh&qN9{a&;0tR3A4=doZ_P@6PwZ^djr?LGJUc5`@QWm9c4>y zG%7U<_UFko^di|-&>+t`}5}WS5?o>%=5jv_Vd3l zi~E^*!!Zp;(wtC91sDe+cCNpQJ1 zP7rI$Idg+E?0A{Ni3^M33}P%!8EG!{njZdaVd-qXSJn%bHy%9jLGF}u%hLZ1j$4wC z>n)z?Qj+@bk88-{G~20isn^zCw^+a`Yv%9K{7_e7d6#|oW~nJN9S^7Ph`(_1*H`Hk znVYXMGPiPEdUW(OQ|Zdh$9HXu+A6)`lVGW|Nri>1MTLfjZr|qW^Kq|kt(~qVe*MO_ zT=hdS8n&_xYK8WRZ3Q(h?9+AQGp`ujno{{$EbZFHQ1xyx{jIUn{nk9=yx+=i|0lrK zmp%By*X!}Yi`{y!q!doBbehL{t>3_<%0QiS@-oBioOAbmw%31llJi?~pYSu)hc?so zVzbtm%TBIzlAF<$y>4flT>YQI=;~#&)t3}2PpNE5JhVsa&2bs+GyP33fB*S>KKG*S zo#Gho14}Ag{u|8azGytd%BRJhz1#Dr`Q4JqR`D_1N@)fuzYG}{ai6#O%=7o9fBmA{ zdAogQI^PNRnQ(li#nDwcUVom#Q$db;(Qxt_xS3d!Z&lpH4g7m ze<}HLO5w?v={xSf*diEs<@PiWhq&$UxjRzr-?g5!Y1L{!_;*dr&0puDk4Xxe1kdjJ zbbWpN`nn&*yW2lE+&p0Tk%7(q%t!s{jV3P^3keHn{{Hs%ResI(^ypfNGs1R@&mJ`l z(DPBR%Sn)!efqJ{ME;kBavI$83+F8JoxN(QcYxoD1O`Y=6q2?z|Hs-VtGK7kk=tW0 z63mzGHvgII=KB@GIYM8~$BXOv6t8vToqw_7Z@?OfxuqpbIRE|%eko+Xg8NDCiaA|R zwJ#iCWX`$$BfmlVUuUXGf&G`aXC@u>zVdPBonNJ|g67BV&3*bnW_8G~)?eS>F1K5} zDKSpS{AT^x#YXm9eNM(rUtZbp`In>R=@kck9_BBvlrl@{__e0|n#D7HQSD1b-XDJS z{A$g-{epk@xmkvjyXwE!IyCGMyIiaJ;b8l=T&s&^H3E8zd|uoz>}gRn-n-66W{XuF z59dNfd6u?E0-B%a`WKeIpY!YX4#U8+yAPQjbWl~x z+jv)B{lddFH;b7A58Nu1+I6jX%`?uZqXtf|udTgW#Qb7~LExX8>GOGw{5|@Q{cL2H z3y|H(XT|#F`v2}LHU~2fs2n)JIK%MIhN5N1|A_J2J?L>D%I3Nk3;zTy^j*QW#gnEy_Ffl((2EAgzxib znkOhFgjIcck$5McY1Xkj<{6R~wPpnDl|5WHgUjdJ@qcxHf3a?rj=0?OSy}g)Wg$zT zx&MWN-{0O|mC-rVc=+e5r!NlFB+TGF-oDT=q~rU-wGLYZtPcD<(2{H+^t@MIO=6zQ z-{g7XZTf{VGW=hA|7dJ?>y^^V-Cg!}%FQGFY)g**Oo~&8*q%4{RQuZ>;@3O&TQYUO z7e26YBflDdF7u*=Kl&a^#E8w`Eu7J6I%VlI&dK`{>t5w@-PN2KQD;WR}L5cul#;=OUhuF9qb ziA@GR%a^m8t_ll2Jo!)M>uGDh{8XR6YRi4`zthe|USG7_yfI^1rgg`*$IegMo*S9w zUh|Sv4z^O?yp~V4@Pn~OUFs(iqCT9kKM*72^Q3H+dWpvO`}Oq)3|=a{+L(LWtlsN>{lr=7GZZC^ zB&0ulx$H0fJnTW8;;XNp&)cs)dAH-m0hfb55B7Z35K8#m!XdoKn!#IZLGr;5)8~mD zuu+`VR;-owcSXtLce~&78TqfswlSUO(q5kPl>gEp>S7kBx_ZCtfJ%yVLg2NQ26RmO1_EN`-~u*WMg3J0tk(-Rwo%N_ezBPUg1{5|uk;b9GIa?UG8X+E-ho%wA{IavJos zy*^et^GtA8Y2W;sFM?Y$uSVTfKNC4C*VJpSZevo#rJEnWzq&X%uJ&c>RKNRug@4yZ zZ@X}Sk@H$@rhDxgHkpf>Q*ynR3+5ibzW%?$)tQevH+4>4c4ME!LW`0g98>>oTy!dW zuUqWv)6;$);h3hhB53QjTkVRA=33uXuA10z{Jr2+$I6Z*#aSs{&)KX>c{VEAACS?k z-Jds4D`bt-Mg45q8P4bWH73jD#%wGqJ>ZfgRJ9{Rta+i8jb}$qhuF){TQ`I-DY@VH zUb|5~G{o<`bXVr(^o!=M^#PTJ;-{vlg?(oEuv&P3!odxRheMtxblEJ_nZdujkf~#p zg4Ck_;wvkg0uKl<*%?n9l&p0QtZ;`*#v3q8Y+&1Z? zt(hx|%?-A^H1Y57O5kL%`Iz3-q&TUe*oFP{?He|U?`F&syWYNV*YxPQlZ7Y7fJG<=p z%KtTgR(x9OeS3R*-@dOk2P|ZNTi7p^=e5lj-PItMsQ2ZqL8yT=k5$DVZsx`>#%X6b zmK-f-o+y3C*yY;>dF=9zxHOQSa z-LYzhLf-NMIfL9kk{4ZT+IF^CCiOnD=16XNeSGUn`;4153*RO&w`VsVv+O--^Kf_T zbKzBsuP?IxFIp41x9wi-XI&}7njO3JCGun}lUi;q zTYkB7+hb=F7TL_7pQc_tk{T=}Jfq;w%3$-U+bcdAU0dJzHp^OSJFm15b8+J=^ZaBP zvre`%?S~4aP92U>Im2zVl>6udMP(*IB_7LHzrR@e-`zc%*Ht4y%(CkG#^CbTb?JO} zH@^J+YITy9R%%t#1(!)zfAC1#MLlJekV<`SZAw0Zz(k2<0H}j@Q}f6_iOzs?*vK(QX5`6Ug@_D=8KD^ssJ)O}I7GZyJhI-n!VRDo>6X0@>lZw{VW1*$sc73_o^6L+l6P<&&i3g4eC_f9k%Sq1_96*xCB=Pz z4CHV0X~=zY{#{i!<*kXo#IB}?P9mI@iAy#}?azyoFsqPJZ7xeVA>jGc#K7psyMred z*v!#B$D79XSzw}k(9=30nFE?*g|54z(gUzSny7a%I)KiyUT|M}@ldE=K>EW_JOT?zB94HXI zR?yWlbMvzRBPnr_**0dL`>s|g&yPJj8_o2z^yRJKT793FP8(~F z`+YT$I(6VlUakCWbqTvOS1)~cWqN0z!&j`?maJ6gbZEkY-!ItI zD#~A9(-zozs75t!N#XjRCtX~w1c)y)^68N>^}A~NDJf#Y`C#?1&n=&SKWvu|X=h*# zSsA2yHDIx_*-Ui_AqhEk1EzET_smfLTD#?z(3(HI#*N41xD&UN7w9sdq6V-Xl4_Q2_e$8Ufv-2|#>y|^mI*;18 zvZ%Go%Q#53vqjgti|HOS5aMaPealNCsHrY6KjMRE{p-VX8D#CBz1uNUJwyJ$83lWu zo%7_E9m<@S5cSdg%*koG(LKu~6K3$dOb98cVVkHe6KpU3Lhftp^2oxoHTNg7SskxD z_~O8mm(M)?n-6#%ZsWcBb5rFblN0>H%f2tSa9i@s)4V!wfz7pmX|t9`OxKDiu`!N6 z%lmM;)`3fmp=)Qa&p&+j@3+_MHy^PP{r}B+mf6wm3bM^!$1j)U%u72z%XjJ8Hc8P{ z%ad0{@P4&8o%{N>!jomwnvWGo+C83=Z0O%%^fyD&=6RWqoB2%j26euVB}(;X8*6Wu ziHbU>aL+fKDC4hMq#VA=h4ZWHrOyw38NQ6Yzf3sy`0=^M2ku{NsxgqQFp>K%{k`JX zm&nFXAC5?Eo7pF;`TBZ3&z#2>7AjvAzdS`xL_#a|@`JQ_2JJ7dtbEZc`RRj8j?bfe z+jKW?)Cs+nexu>@Jy)jZb8RYT?dsogJff*Vxq6w+!v{uzZFRCfYo2jFXG?DKJjzg9 zbz$c0wzrG^o(E<1Bd={1IyYt>_#mfNZ@qZYj<_`(e8GiFmHb~Yf0!cMcEW&V@$%!P zpXR9Ru${WS`IJp!2bcfXhg&Vr@yUGWdvW^?s8sl9;4is+df=@&`vY(F#Q&c0T|Ybe znT&yX0iVPrC-#2z-51X%Sa0DLZsHJl$Gz=w+~E?1Rfm2iDs15W@04y}WH9HBexe80 z?SwrS{0?47*e87Ic%9F?<8{j`4;h%BYOQp(s<|%svq(yRhMI(Vt#rdl$H*VzhbMpF z|5Z0buBLrM{Cz&*<_z)K>Q{uHxxLIxuvn-+rMvh^^|Lw7t=UJO{PH*>EVlgU*O$M2 z9JJRg)G_t<%nW1Sch0w35B=m`u~v1{q@&&}YIl!j%`1zX%lS+taYwE;9;pZP#SG$vVeGggMT);rsaY z#kE*l%Nf&VxdkuxS*R2*w#ZXhZB=5>neB&!cnu=v9QWKJEAMZ0Y(r2hSLlkOsZoK! zTeEV%ZV^8xf4y((j2+3xvu@?>U!=r3QS99Z!Mz7p$N0Q-YOJ1^IZx}xx0la2h4*}| zow(r4<#M~vEx#K*f9&D+)=#ija+T04xwqRRaz{bo0h`2%sp{v&^#k43%BP+Z?$Zi2 zKD!SzLL;mFj4SbwsZs#L{xko-@Be@Fs?EZ+E>k#!FC<1>h+J5ll;C3!-!6XgNICyu ze*@i$a=A+j9v^6l-}9Pp;`HtJ>#Pg*Up#S`A&uXe=Q^fWJj8z5%ob*BGdVWF=9(~B=kKkb~g z*UZ;3_kZa*E%KNDvcgZ3_kDQRp%n09b%Csb^otjj65R6*RaWtDPW-|BopGY#48|n~ zRf;k=zDtxg=l-hG`F}u7xOn;m`DHxI*uP7CznD4iLj8jq9^EA#{F9&0xx@Fl@u}gL zT!zy2?3qblB|B6O>Ky!#@Pc2^>x}E60_{^R+cwA^Zm~$OJ!J8Y_tT?)3cQ~ZIrzV` zsIfX%{-{f|J|BC?KqxV? zzRSDYECZGY8LgOfKj)$6-g(xO54+8kkmzvxDjo1v%5-XH(4m=Dy=Ct|^_o8BPOaL- z6aQ;xA7>TA=f*FIYg4aH-(GijSC$4(8~4+*n`6vk^jbI`H!bCzbLNWL=ap_(gjjlb zuXRXGF)(;AiSbDy1UEAaEX-dHiqG;h(hmP1F%rZ7G}%pKLl`uSP#e!JSM zg&!o3AFDhZa!5l_s%LALGmm)eO9zt0qJnq`uiS)DZT`ILt@f|s8)xVPKGB7G}&jM}}&$Ne9b zzPsZ*(=>RV#{;9%BvXHe{c{8b6EAoi>PYzLbEfrBl%K@0$NN97|7ZS<@yz5)|Bo5W zzODSsLSKT{B`~4j!*<~rEN1=;pXUn59x8iteDTjYe9uq&Pg(f(;N$-W!Wz=W6Af58 z%4VIubdcZl%D9R#W=>I=?^MF~h&<^P$W69WD?51j;RP%6M&| zd#&=(=Vry0pN`gyhnvGMJeXBrx5I8mK#AENEvxy;(;OVWusrW{zwmz7YrU=VKMP|d z3}%>>yve&*FMEaawfgC^!I$1CJYF)RmO0j|`;BHyg9q0lEdybL8wSidRV4y{+c&mJ zHcB4ad-eO{-%UjVb6s4#?z0=Xa0qw*>A04_$v8uO%S;2SJwG(wY4;eE@D;C~ZNc{Q z?ip>RL>bd2zCt|>{`;QSFw$Z`6Xmr6bt=#dDZ6^A<%quPo-9?^O=_cPNO)0DT5@}IcczQ`Hwz?7VIK zxiXu4uJQjfG4PxF^yq<|lQOLB;`})Tg>*h;cnRxDYV?zB=Npg3vzap( z{exdVRgg+}mBMHx#nb%qci4AC%~ZRwn{n-ix=nX%md;zSW#)CZ*~@qS z4+zvuo~O;ycJs@x_MHw}IG=g=tIq%}jw&?tXVB+Zw_meZ=!gGmW9bhc*@IuQ*OzeV zv&B`|`0mw-IIsVNZ_(Qy2GiMO?HB1Wv-572@_oNd>NV%bl%hT2eA@Bu$sThuKR!M1 zXp#p1^RDa1cKrQi)VTa&g3$!|HId2hmy~>NS#jl|hElXecw7*}_kt@Kzoh~rmNm{l z^7`iH^;Y7x^*RY(%hw#}Gb*eX>@-^1J#~$l|BEU6e?F5=Z7|?rTFhkJn#kl5D4==p zf<%wdwTeRpOg&6$KFLKUZ3YHwM1ALShl^#jex3aE*-543dh_^VXHRc-zj1v{Z2oW7S8SIKsLb7Ps%(~QNzn|GzO9P` z^unb2O@6g}zp1#XC4*~vcYWf4uDaLJ^EoDO-kx()Y|3>ODa{#9eJzRiCL8zW%@a_} zy1Hxc*Sp;E|BhECT1-%m+Tpk6&)Ve&OCQLV-st~4e}BTp>?=;}smXd*KR!PATI8Ye zynTneX9wne7RwEty>NLUv-8P{r#xYH=5N^}bLTPKGO}UneeJ};ys-99uaV)J#_U6L zc+Tu>s57|Jo_*+N(hLXrI`K{RE5fh&+nl+)w8!L>g~^ml*K5;0s;0_F=K7VlI@b5e zbN*mtxZI+6$RoZ`ImYd?3dhB=ninfJM48?TZePLoOyuF;(ib;;UNTx)`8>+IyG*(C z&6VD7Z|w!9$bGK6u(JN&(d(=3q@9gk{ZMUt(MH+i{}HzsoP*Bo3^Gux$mLejHQ<@y z-4eRPSyJ$cdkU|72L4X>n4%K@9yw~mS;>^qYO3ly}9_x{mpTY6h`DD?SjKB}xX&)v1TSM2y z1ed<^=q^9wJI|=M?Aj-9CB|8#-=nc`MSP_t4W|&*R?H&bM-JZ(H3r zU;eUw_&f2`KMCK?MctQNa*)Vo9+uL*RGGba9(&rrPI#lcAjwO za)T&?_RniSHwhlmPEKek@Hfx%3R&H%EW3`EjqSDWPS@)Re+u+uIuAejvoTp+!+528 zgTZ+g?N0xhR4P~xy5?Js2aQTyhdoy<2JEbKq zsMhfCkd*1`l_$h6B|P+%kelHheA6JrdzwzTzR|PK?o)WbvkM-Sv;DQIzVKXxTI2J$ zhjkB6dTn1ZcZNE6X{h^|$pYFJxUxOYOcn@Fdix{6=fXjLc{{JY`n+>>8X6k{6O|7o zpT_BG=4Ub$;I7Qw_C|FQTI*{StJn%HR3j-u|A$$dAW|~LA;a(@7`qAvpeLmUUD}N^CeHZVj#9ZHb zH2ZkFpJ;LVvA)?JrLWC`UOOx-)(?78eRbjX_ZOy4(~nQ(G&^Z#BErX0ohUm&(b+BF z>#NdG7A{#Et1t0d*O&bJ%f7OFTVhgy#^wVF7J`S1AFuhmCVu})EB`6&JENbT`tLM% z_m>5stFLQZ7I|lretw>NGaGOC^JAaeCa_=UXzq9+p=1>w{XA+-g`{Ywlx@|VSwCb> zwajRkd8C@r@H@xn({&n=VZIja8;=-9ZOQR2es(7K^W-~9&T4v5-+4bT0nH1GojoF> zbBF&<`kp0{#f!G(++Ej`xZ}6>Gta@b8rOgDEf5|OPC9k-S!O#1tZRz(6S5VK+HfY#YdUHAOw(oWuQL;1EPTh?*zos5jbM5l4GhPqE8M-4vS8R*D|NMlofz>L-<_8t=91k@M zqn@&I9#5S2C0=t|@#Rl9tfHsC-r4j-^z)Lsl|SF_zANzC%JIJ4+Iu-t-0RM_I8^Sk zbCNyoI(=uuo`iWJd#$V(Z2JxOUlW^gx_nFT#SB01?8`G(`krER&|WiNJ9%EsTZa$d zjSg8ct@|GL``z!~3Z*i>@(VxTZk!hI+$)&R_to3}edl-FEvu>i{p9kLg}GPGUsTHZ zziN5;oNSwhun1?%>oyOU{md5&H+%m%bjg0Bh@O92s+3dnH{3b$*8BL=)iyddp@rYP zzC8WTeRQeO*Uzq%Gmn3HdRoM;ex=M))^)PCjrYq;j645SMSq5R^zo#K ziE#6Gvx?q)zP|Ezp-k!e*Fy7)Eh-P%6z<)<)bQ!`?K4kbw|@3v)@9S|_h-IryRy{x z)HASulIDH+{lcrm%4m!Bu`)qzAnB;kDtcsG>E649;{g!>dytvN2T6l{*OL@wx2t||WE48cl732n$ zu|*~RjWPVZn9htIdeB|y8Ed`%iDh6O8b(y z&3$os52|W^fB6)1x{n#OjJJK3dVG3fj>?+9+w|)-J}=Qa7uR=h1}mrDipiJyo(cqH zY`oDwQ?twG;p#u@Uha@-e{m@0{8WYYGt~di(+>FFr(e^s;O#6oS*ufPo(mi|tdoD^ zrma3%UCE#A!nOiwA-(;T`<=rDv-Fl!a$TC~c>ktAM(fw&qh}_6jLS?@cadein!g~f z=KkL^Wp*uk?H?O^HdUvDm*WVLHw<-hw_vGmUSHb3{DKlkhM zttl6N-u;%ni(|Q^?)tTVuY79Fo4m>V0@v&3T{D&|Og@}(|Fy=e6MJI~ri(}3oq6u! z+T6nIey13J2Ju+7btmWh?N2-Y_4V~)%Tms@8`_E%RDKHeTju#;@!cOY&P+b_etk*0 zYL>?5#o;TucbpZ?`p2|#i`M$!ikZ*X%8S4Jxo6|u@VJiG2cK)ZyfgQ&`ulM6`o7w) z3G3gVDXEg0yv*qHirOmWM4LX_x&GGS``&Ht+9OcOb?^OeyV+4JB!tIy+{qYgBx#OwL=O+S=YBLXU zuUm2E%Qoq^;Ym(zz?J{rHu7n?JY_TJy6hAyeCpHGT+zm#WzJ6vmzYkSc(ySt!an?zjb=`1!jAU5QkE~(`KzY%v zQqkl6{Q3oXIVXZ5=Fr@Emu))xS~42K z7R+98MmXW~mg?_$UcSDjVtO1rCQNG!(vMH{R*-`4R%_#wdbH1{M8&0Ro$`zJ+KCr! z9@g(_eDvb-zrVk)-e1Pd!NlC_pk38`w0ByDM+eKr2@gcC31qX+XRH^K<(#kHCy##I9OFu6M+6yG-BCvorgC)wav}Tc_q6{cG>}rd!esO$Pddn4~zBsO%8*a?d-)$#O=xz&L}ey8K@h zs`^l-hdW4hXJ1MGl4KKq9gF2RN(urL-#=JWzx+nY-HtXE;d8&lc{wjWT2pV%Ybhwp zX?5-w|5D|<$}ex65x#KrSX1&vOMZ9HFgfGtyBwhLrg!m4VBWj!n3 zIluJy?qMb9FY%mtwpuJ_CHu9;;2a%`zp$N(KszBPz5uCX{QN}Cq;gAe!J}^( z%kl4)+>ju`bu2wH#3!L5&@iJ}T=cT*@n3?xoOvB>EbC{Ow47g~cui11mNRe1OO9U` z_Xc`=USb|qkXVqqY0lmUY+D$69khS#Fg%ccae@FJC{@HduaW(dd}li2#fbuZ%lqcA zX(fGbF%Ub( za$#Q}|1TP}`4p6MvN>v}=N{@+TI8#m2VFMD;O>qBL?aPMXJ}@1UJgzsSsAK-#G5MvKS8KL7WB5IGfX)8FRDkN3MeJ1qePpo8`r zc{9H?suw2;XksfoX;;z}Ej77P{U&YK%dK@KS0CSPv1tC?^E-D{`x=HQkb8HzcxIlQ zQjw?cYrCoG*SnccXSee#JSDyBGXLsZ0T-v9UHPI*EM&v?tY3@HOZ_T4V71C3%h+`0 zw&q`YH~o#x`{geE zSG_!N=GM0_Us~E<;(cttz`n1x|DVCPSmk<|&y2C5X-ctQAFC*bPoL>?J$#Ov{+2kW zsc!kp5?AwGoTe<^bNsw7Y^$o^XOVe5&Dt6}Z~vWg>6x0mZ2r0ReLJuJ?5%OkB_DUJ1e z4?SIQ=gHFj%iae6Th@2|sFhyd|A*!Ft3DsBeE7%bxh%iW*9oGaCi$Jbc}9Av7v6EZ zXIwe>^UKvPwJ*~@%D0(#sU4cBE*u{HC;scrkg^|Yvv$3dm}@cr_dRhASiO2CSi?vo zH@4m@?|1oWlfPHpin3qd-?{ZgP}M5M_N;!PFTokTlR2OK-0)j# z)}D_Vb9YU!zGaxvYFZL`Cb+7~N>x>LrO}2pmZx@Cd{jzJd2+|_@|7zgrMxFko;>!q ziQmv)Z^@j+QwrmQyFKe0S+j^6Qtqt)XX@c`!eFF-^1e#l}e0m%iGyOTWghJU>r!%ZDRu zRuf9*Tr#>8dPU2DZBfL&$8+}1aO-S8cd>={_=>A%?tVGjQ2M3h^wE;=y*o=D>l^=h zb}DDlf%T{SmK7-rWuBh+c~#8w(kCJdM6U(JoE7dl+9zwB@$Ae@lY|2duWoEq-kN#2 zZCA-lC+Bv)rLNs#7Z*CW7cJ?G+FNDHD{0i?)+ghssku|%W@czdC70UcBW7OjirS6* z3_QiH|4hB)e$UV3nct#~5919Gxw5Qs%9zTcrJ0Io`YK`|M37`5mFtwa-3N-ro57$k#`^3jgll zU-ny=FW45;_Is^vK4bakO!3SkjXO&|Ptosn<4oA`ZeHg73+>)n7bh@p4xLx=b*uXM zuydl3VhJ{tUKfFzs*zTG2hmO?CWj$ve>P6(Xn3X%liBO zBrVChy6TkAL6@f{%tm>4EJ}Gzv#*5&ng-8W!xE@>=lj>!*DoLImCpSC@2}RcRIk}{ z1wJoHG!@H?J|21OIa_Mo6w6m1`S)k;nyvRR{{Q_S=3D>o54k0>Wa~fQ*lS_DkC*mn zF4?AO_2;yf`Q4g5F;0F4{@3lVH{R0-nkS_GZ~n!YC#D7R1@`Oew>5vP+nESU`YE4V zUR=1MeERBzStkwdTKrSqRsXE~O6m0qul*%YZ?W2RUaz?2_p7}3<~p}m=c+zu9^c5_ z_#(`YAu5sg=uw-w7KKf-%yP4gy+FIAe&U z!R94hw)wnHWQ5h%8ZPgU3k{1F7 zu0N!mW6te#)X2WICG%9u*H>4)J0tGTzuURJ(n%%n?X9g>Rs<@S%FH+)wl*s8_qVsA zXJ?y-A3tU{@F+0RLjjx;i}i_AElcI&;%nairDJoY-Qtd--Hm^bBH{lcEW?NQG- zoJ+R{|JqTld-;pi#XaVt)1U2gd?_rqiDznl{jnFfHI{C^uVd-)n&I=5G@HCUMeAA1 z<83cqHP_j0Q+_6~%Y29bj`$t<%YL7P<$uewHi;Fle_vhO<9{ZR@A9U}vo|lzENXiF z!X{roHrVWjOKfnOQR$4`*JtQkgSs)BuGkz@=#{k&o2lmF{_Feu^=GDN>K^#<8noH{ z#6;y|e{asLn5E8hTsLBagX?pKXWeTfH@n?cU&k#W!Bd`eL-@I2;iDs`jmp>L-rja) zS84WA?#p%Rm3M7Y-$Z+8%Ko35S+VQB%XA%M5d*(qx$v~k-pPK)zKG{!2LJkVIBkxD z*YWy}$}Zt$=l)8q{`~%mmi$t^zf&)&T<|)rv1ZpT(e$b_qDD)(7o45x6<^kS?CO+6 zNui@$+H;eYex3Yz{bDEU^^2a}!QA!!%XQD`{F?h{&7+$7TAtr~N;f!8OA% zcHW}-D{k`W$AtM&-x{AgXi{S9=!GOKi`ttAB&^1l(uy;-JiN?YW3yrwaw@3 zWy0<6UA-z2w(4J}VSgvHah}1ng~ch?=D(PK?Qh95)2q&53~M*k6?Ij<+LOIz)|a)j z)pQ&U65f}1on!T$A!9w=Y5tcO;gkjI)*DP!%d<#3Q@j*3ry7|xZ<*iRRaP|nO%-E z#e$NC!XYwc~tf9~@0mD(p7(#Cn`y!e{mtDeJx z`hr<;e3@m5h_2=|!(T;}%Cj?>{LlWJ$fuI(A1tW-QgV96#dE<~x|(+#5+&B3nxT6$ z&&U$83+u|dSnI1Vm8}F%-EiTudRzPZ+e%f&grFNU8l$#mU3_q`IrII!2Zcd1)Mn^B zUsCF`=~xlxjOM2`LA=*3^Y86hU^AoGq~=G#SMxUI@U>A(FYTVGKE=59*B6H;9S2+T zpEPnl-lA48zc5!JCd$0v{s$AeMPW0F+cXkRPt(1cQhR3cp~=-%Q6+v7c}HWkmK+UG z;E_>ExY1vA+%!>o9kX+1L>AM0as4=%OCrxqKTj!4&|La;$J`*@!&8oZ`IEiuWKfUHowv<*cjq&{owMuyriefO7jJ*Db({ablVNHn zygpyq|JeB0m%Kl3mtHF|JoJ0&o+swl{;rifkqxabQ_??|Xg!tLYqNgOS(C(d!Jl)T zbr(;pmgljovulmZ*?c8!!2$8wYiX;?UpPY60&dN|9_GdJ3-b4#v)T8GQWCFv5jGPCD1xX7J( zn|X0j>#W^JQ(KMwI}RNPD6q7M_h~zBS!`(_yQ{KrQ|arlsoS%|S1T4v?D05Qd-&($ z8CO?@27mXMU(uH#vHb3qOkvaPYd%X|XPujCtdY3Xdc zN!9&@w?ZZveGA`yCY;yn{-4F?9U_;k*L^fwIQ6OHJ@3cwHl5lPI#Yeh*#|Q+->zT$ zW$)(R=hG(dob-&d+g@(g*YxD$`ATM=Q~bEie13`leK)i3SS`cqy4Oz)7oNFneSg|Q z*TZ@INe?er_Xt0@|L>Q2vgN{thegvKA9H;D?~~H(WmPA*K&MxfnfY7m%Pfj?55K%7 zd8Urt#)~t|wl^L%PS3S;ylgK2ed3G9FK)gu)@6(WuVYKP+|c=G+24+&0}%(0>}d3x zKVL@LFv(@XWI3~(8C@ERry4&uRwf2do-je6qa=}64wUc_1r?>XKAY|H+2op$e^}&8&i?_a%gcT!r_Q;zx^HIT zKaX`W+a7q$TE1!OK7+C!c_~{fe^u|BcI@qgm;S47ow~%CI`7}NBJHYG4yB?WD<3_6 z_2tKLQ;qxSQ+uDv%>K=8`u$YMIaB``ixVH*Yzpmpx-;mG@&pm%m_-OUq@mc-7b^c`T`hdzaVpHu7tT?;nr$-9EdMqEP zvoUR!x|u>p+>CFJpB;!ec)%j@aI*%dRhA6DtnId$&;Bi#{QmCl?YGX)wcJ+z{$6aZ zb6m-rD!GQ`H*P#QGuJwND)-`s^9K16t1ewWEU_VwD|Mbg>8qQYm)~N$+;;M_hV=KG z)6;ZKGA=AQP`GhRLmkgt&ERD#OeEBbj@6!d^l;*RYnQzfj)(9^GHyBa>*Tp-FCVLx z<~WxA80I7p_IV*Y9m9OI~Eif1~_CK)}3n zdxe{Rt?+F>w=#UAMf?2?uYRn1{B7q!D~Vavi*7DjP~*Go?U}I8^Pu5BL#^brrNV_kX`gYm(MDiTr>Y;||! zpC`;>=q+yh$eB8?Ab!I)p>2v$n`?iUiMlq;be76FZXof@e{J-3y%!QZ?kfaryG%c^ zB*=*C#jH5xSi*UHu3>W9ubyhI%PpVOeP=CM$17;g6Z>eM(x>*{DO%xYf}bq;zOVVx zl*(NP^XKWL?RPTy9eOH)?Q_T(@meDZ^$8o-?&-3Njr!etin-)-%cjdmZI1S?mV52z z{`}v;8#i|sTAmelxZ3&M$lt(E|NQC<@AjR6TZMnKExlT3?7tv*J!8qQGm**r znA`c-mzvL1Hz@j=xVW)u$4Mruxgs{Atlb%prs|&ia&}p|{$mzs2)h_x_{i*Y!)&Qx zW9P*V3q^S~)5VXE2?*OA=6QHyS&e|5Jiq_3MCtmr!h&V#76163i5_(LkUm2$xatn` z9CwY+Ev7RJZc5!w&`X$NF^es@t-9I6Z^chP1~w|9Wt&Ew<&4o;o zw@>}NwC1nYWt+sOh5IXmQp@-Foe7@Pz!+g|R=l#o<3!eHlWpe@ugg|4)Sa~_QCqmh z$bbG+opsM74cpWH&kXC^Wx1j!H2L4^X^sX{A09S}JNDMD?t6)PYS)~nvm&gY*=zM9 zh3$_M!9i6Bexe8w!F$|KxXX3nhq zaOmOkS^9il*K3Zg%SoRD-I(5T{NvBqvKI-4C8=J^n`iScZtjzlwOAy7qpwf;>4AnF zx{Eqr@Lgi)v)PznKx;@Qi|9A|wm99_63Uvy<91F@YqUMGgI&gKaCog67C6~6Fl7cqiFV} zmj_ZlHZAGMQPh9OdEc8Y^|7XNjQVW#Io)+UHOGwDBjlK_cUxUlu1}h$q`vn2+9~(` znNPWQ*vMZwf0?)Pf9aHYMrL*SCVmRWpT2CpYZIBel>f)-^>6z2i+>hTzcBN^d42HK ztZgd~n+tBOoF#k9=IzW%<)u2mMgPA1dEvEA;-~F#d0UF-&FJo~ea88I$?w&vn|v}8 zR%wO)HgQ-n{r{tXOZ9diE6ejcr~7BESNj*F7K(hU#5Qj~|Aot+|H-(`{y0SaOu$?} zm1iq+x7(Hc=&0TI;oxES=1Z-{{x`mFIVjVpmAw7JhUu(ji+Wd9CVP9P@K|O+EH){s$#jbj~h*D63K(!tf z9+6j^$B$bs*8B3sU`fPj&Zm#=EwEGfpSR|mXm7_nF>jFrDi7setMNQdn!P#4{L`jN zulK*>Pe1t6b*cK_#r%2yl4mZjImMEy^Ls{yZJ5aR0B;eC4cnHgi!Jx|*u1MCVO_|w z%3rhB=NS4IHuE|(CaEgdeasJYpI7GheXYm6y=S|R{k1Y?yC<#jJ>PKFa+Up+MZ5d< z@4I4^;doBJP9x9f^q0K#{cipAX+~I3#k=Mp6Ha@~4L}7D??^hxZuexL3_kzoKoQdwr?* zbcZE;Y5dpQi;r#$oL`eCbJKYX-^+#V0r3lLWGi3&mfv6bt2wpe-|KFhvo!~eob)d( zU26B)<@LU8Jd?Pu*)7?*W!VIM{sT#~!z^ENns-%pZEh=J{@~ntNb&y7V?D1vYS{iO zEUkL;SnA7*$iDvXLPbxODHqGRxsf)1wmICi5L@><@c%~n z&F@&xOf}{|@b}L`C&AmbX0VZlS(RElw%FO)?M}JyZPx56>C+soCvA8o%x2vx*_zMn zd^}~J{?`u|lO2C68~Q(Z@G@oB!b>HKGnuYWX)XN3YP883v>rz98Rvq@$1jMsJ*k+q zNmzo*#n(W;qqj#`hWi*p8nic(>(e3hROR_m>L8R&5`rDS0-gD z!6w0E%iqU*`RL(hzt1hj2e)plWHQjMXyRSwY;VTHyC_z|tax{ry3NPgz0*1Gh)XES zZDZc?epjTk)Suq$!!89gT;#Sjb2`gDj4JHgVV`p%At=y1M$h3(-}4?u8()sv7nvn0 zpGtaD=dDwg`l`Q+C2g;V$#2zD5uX$VpBWugXjM#p`aDd|%zyjJO_$jJ91Sm9e!sJ_ zOSoz|U)G8WUX%FMWO%SZ zPOSfkj2h43h01LezLlj3eg=v%#f<@cXE?1&=gii$ZGOQ!@7Ljt31u6O1@G{@aiZD% z*YO_7#cf**TG|&HO0dq*k4t0tpVVY}&G*p@zUGBiCO>{{+}IbihS^H%%U`5S5u3i3}>pD<^ZU%yFYf{B0qTASve z%@?&JoIpdWkFw^K2;@3OYOuGuOVwUCYH`6rKmrt_?0PF1p96j5bz zmgU=Gy^r}TUZ1}FY~_F5DHjEwz5H(X+2qswQzi#FjxsF@n||!hnp3AFKB&jvoO{IP zD0lGlb9a81)mrU+Q0~^OFyr9gUEklb9YYKl?>uVL2%7x5Huq+x&gqtuHfv*cJHBo9 zY>F&;_x@JCfvknd-^*n@al1bSJpSqS_{g20^m$(Cr{>QEm6FrX+N7p`u&_Iy>ty0% z?zbb(C20fy|6{*4-P_|e&&;1WFO*Gv?ewcBf1M5fm2o;=Y&PJRda8-FxkC<&)-HiPu*%!IE^DnZLZ=%2X521z&cR zozk(~{{Q|G|Et`szE3R;XD+|`+Spy9?A<--E%H4Q_LdpZD$h87Z?sr;(!lcPj^D3x zcDpb_*78IqXz&-WPncj3&fy#=G3(GD6$!O7?1yX=d7i&K=Ec!JLw$yN{1Z*>lP}(z z`^Tz$64CdWyPI=HQ~i@{&)iv0mw$P>Q2z9J?FV)b>K^QKNtz`A>jSILT0X0@cFIPH zJ^9lL|2G?V2UfAX-)b^*xv8{~xO3ahnMTEN0aC&fXDn}*W|v<6>~i^~zr1JLR^65P zzO;7}Xz8BEnaQj?g4bq0y1tshhwb@(!-kD^K5{M9`D(j!TKEAhQ(o4y)h}zW$87568eYdL9Sc|WsVQzrHoACK4bCBg3**FEFBEuXo`;Dk(~CDV)p z$>aIO5KW-HTXMEjfyv=AcH*B%Or;;a`#Z0lU z{%G&I^77S=iH6&!l&<+O*{s;e@~VyH!QJ=X)o!=j`}L~-l_d{vZ^=2bHY;}7n}1K_ z&hbC*{Cu?X@t;E39degTs}91t+b^GSsw`E#Y}|MC7^B#^oGqyd0aeE`<_Xp2KQGvn z`#kS4OVP2Ec^fpAyO>0n@0XlnGRe!en zgo!n?^kQxNrcXLxXn*efuWiD&?&m3-G*`k%#G+28V3`(H%u+H%S0D}^3;#hLpzm9Lx9 zmRGfQ{>JA0bN>ANS@x=RufNC&zn|RU0qc!^E<3Y5U+?!`JNwyky|r=6 zk zQhfKv1Dja7r>);q_@&~-({jJLcETRJ3nJCL!`B7=77Tr5k*_cMX6rFFf4=f*=}c^^ zo=oTsS#sY>>q@z9?ag%E+Kbh^qS9L$zs=*>`|QC}7T8(bpj|1THE5vIVy$PZD^^1a zIM4y!Lw|*#tJMJe;gpwHQ1j|_i&nqVHFuipIIHz99&F^?a$$@8 zbf&e4v($^`Rz^jf{}tLAALe6IpQxMV5H9Mopm|xXqNN8X>(|dY57sK)p6$T8XPTh; zCjaBle;>V@FtPUc!A4fS%j*j+fBYNBkm0fZu)x=q=gb#K+bf=vzu(Tp%+L0|xoq>w zv^go}pHnXPtXj==WbGGFS$Ed*{7t15 zZ+T9guay09+abB`cHsp3A3LTqgC;)1&rZIycJcm4-&mwR{e8Pee(kv_Y_h-q?ke84 zdZ#Gp472VtllOnz*m-#S*&CH9tcEkyot0m1RSsr4H&1rq4E1A`HTTmOn>k-eFJR8t zw{Am6WtZgaw2;_L*{;pTh40FlzXq;v44wY)waZMA zw$O!J)2#G&$4#_vsupBlAbPEThWgvh%gpbmr_0`YeJ0Lr&hP6v9H1#J+h-@&8W%{( zOg$&Wv7yxITfASH;}0p``5P`A_<8h+#%_lUARcX5BW=?00FK zbI#%uOEWX;<2$&9`R|W5H}!-WPS=<3{rG8Nw)@}Q(!QFU+3WtuT7NU$ zP?D2lu{PWB_^SKDpTEY~74=mbW#~A4ZmI66+4if*@oM|J$OlDp7JQn(_bc$_+h1Jq z>wa1v+421OV}<%3)q?yGU%cMDQ2x_PslT)1j-RtSowM5~H!u&hxBjWr=afH>tODax z#M=IbPhJ1{L-)yP=i;{M&yDw)Rk?}tYV-A>B_I4xAt?~Y?Vo! z=k&t)>TGBEZMFaQ-z`0GeEW212T=0J)Q(TgzjI}0#_x|em#g0Y7#V)>`r*_cmsui?cOBx^>g9Zu?pe2;;n?FJ=RDWz&)L0>v3CB(nE&4otV^t|S6lX{ zY_C%Nj%q=9&_v5TbN^Eotk<0#u1D=pTVq^rouH+>of)+DeQM<;X+2ez(1Qo=8{Bkx z`2WrQ=V#{M6tFnDxR!6qa|4^UPls~L)jwA-7Fu7{Fr00DTidtH*nj4%3lFNL+~j9| zzqhXQOcm!+=HnmdeqC`rvE$d4*SFd~S5)0>d~ALCik)?-=*_P`PVeMNssEg^j{EXI zhN{2L>5b=iJYQ(7nl7sy)0b>7?|yUJ<6To;UvJ2p_vZMCD^pI%C++`SU2F+jL0);w zhV_-|4!JY;tApqM{C4}+DH|qG68bw)Jzafactyf|J+b@y8YYBD#oEl8A@l6yy^I;> zkAJl(4OzZ+`qavry^UOdn69k580KfDpzb>F&ew;#-_QKD!Cd2<+wr(^v8?&;fBd?? ztv`kV!M{TdA_ptTF-)8&v%%e<5$`(S8qAJaZbX#E4JVM=U$$1>)D%{H!FUx zzaOu6eu9|AYww3PeVg|&XU;i($|qUp`nSD?_3aYX{~nlK{keDN9MH14J-PF0zTf}8 zJy!nUu4DfmSOvf2GF!mr_;vPg(1iNVV>Xh~+yVlP_PzH$W>kJSdcAc5JovLU?nsGB z>pkam{MGk9XTcpQncUrVy*GR2q|8&g-oDq*=rjj7%m;bw2Zo9|-=|-K-R z{#g5e(S*fkCb#a|UUQ!3cd5_U%u8K;arYT}j$b@&IDMtO{f?OQXwkowtgny%jXNTo z(fU>SS?BhjuO{z5Zx);Vdv5LIe}Bzin|}x0h*31L^3y`C;LsiN>B@_Hy6WdIzEmsQ z#b3>SQ0Y*aP2p4V$!YWE{A}*aK73!JFv`7oQM_|wBX?-jUdAW2GkYcS&)2bOxzAL8 zu!#Rk?lQH2u(Y|Brwv{oD5<&D7@BW(_(|M+$6svgp1=IDrtjhD$CnG!e$ISqwxFeB z-SbZqFP{ls@Gn6BmhJXtn=^Z*w!J-nC2ND)TsG@+a2}pp*=3#A zJ0U@3cWI52#U1M}+O41^$)Ne8)z3UXfKC+``Ya;W$7c}!Yxrsk?UAUUoXO7$U-xzUONOQc4$2q0?j;m1Rz+;7xOFi7zlCl?{W5%ay5WTfM|-e);7s-9 zjaEfZI<$D^t3J^*xWHt>nCqZ?$6@1*2>V5f2YEru?I*W=m}z>%ifNJZL0)rNNBdpo zMzhox%vRs~v5DJRg8Om93=dU~3rr>tOI|FicXe*!1Zgtooe5ge-*Rc<>_R5S&n$O% zL>xF|h2MX;v3x>#BiomUqRTIQfvKKaAazMlmNU;~5oo)E#93kPbDCzdG9f3dEneT( zG>bJ~qtA`)4uXuq_~a+*s{)*?_8ao zOlG{g2eynUdDZU#@SRadgpd6^u*@^u>0V>$0}*L%e0NLv%Doktv%~gQ<}cX&Q-zl& zaDwii(vWR63OHp`$YkcPW1=I`+Vn9}OjKARE`;aS1J|P^7dO_qI5$aydJ<~94~?@s zN?AbrF7m9J>PqrOF4?^76^oIGnW>YmzXKFo7rN~7cs>xiLTaPKpt?fp%W*r#VS0a5 z39IF_E!|N%H8+nd{ok)vF@N5%nQ{KYo>kpPl8t+xPo0X`W0m-~!pvV%a!TR9IhFG# ztOhkDlIE>baQpnk#PY`*m;3Tsb2)YUXQ(TxgYNJG6B79L)fp~N1x>d62{0CI zyVP}2TjOWVCa?E@-oIG4*ks*73#p*G4&D1Nq!z!tFwJ?&ylHvXai3aKtZILl%)OI8 zcm4e0UcbGJ|RleJp&AQ}r_~YHj zTzz)=w4YHyH(#H=^~uQo-#_=PFDc5iE|#?!Syuh)`N9)rRgih(w^(2Nzn|P^Us-eq zO$xeFYq+$tE$I5RAh!tn>j9{J+i-#8C!XdOem=jy)L8wpdw#zT6t#_OE(PpQ`n%%a z4vU2vej;J%BvYOwYG;ZY-JW(<OMF ziiP&YX=XC&-{vfM7hkemDE#*6B4d9|@t};C50+keaFOlH;j8vHKJ7@F8vA+OER)@j zb3}8UER*KF>8Lz)8ydh9I_7Wdp2l%n=(EZbmQ7yHzv@?>T)CzCYu>DjnQWy$4j4Jr zZ|IqDc!tKBTv_gtC5LbGtyWb3$Ny|e_{F%8Z~Xf*{7jy4_N=pu;@$c3rL*Dk(=w~- zqQ5RE@_F|1&Be_o%brPS)jR)O@MU7axBk?X^&kJ0yn3K%=+Ae==4tK%(Q6ZCEZ6Qh zYvbyq5q#wLhxPxq7w>oTGw^@0!7TTVN4L0M(0uE%7Y^J1KHqOr@gU)A<%KCArNlHSYaZF91ZB=q@jZ@SwfsIt7$s{Yd+BR~7U zAD1uK!t?3D9Pj_zwq%E_I#Zgu;(qzxDT2bEr*zI(e#bE?N-EaF%jg|9D>Tz~rXI3b zo}GC^{KoqKZ*Q!Bev0eF+Zn>XTFxw>S zN$20+-!3j_VTr!Ea7E-^tI)qoxk}$&alPue>uSV|bK(mWBX=Y))_zS@-1Rj6f7ths z=j)>L7f(|4y727m?9iz6x;x+FY^&Jjetvazb@QcHIT8EguKFJS^>t<4-CbAqWEv;$ zI-NN0$Nnu>OlDoLmJ8jxSL<&<@7;(gKb<9`OcEyMO1!^wviQogxn?FS-Yi&gM%dwb zzvulbIpNJ#XBMs69%4Fe;cRu6&SzzFy+i*6t7^oy?hjjF=lc7?Gt1eUFW0QU?C>S$ z`lr||rkbVoVO*y^f4*P8tg!U>%J)oaJFO$_Gi^h6Y;4l?@3qd-*ep8b-mdi)_A=sC zh40!m@9s{^pWwN=BG}bxMumZf?SoHBjNsjALPBe|<$QdkdaQOKWYm0}LLmQbNuGcU zcDB9$*6+LPUuNe2`}_OlezI1p+Qei(uKoXyKlj#_&TDI9ms@?g8}=_Nbk&8q+wXW; z>joEpc@g+)-;3=fZ*CYyTZ-%ay;psFu9>kx$^`u^Q7)HgfrpnqpPg@hen!~y(@pbl zehT@qQf6($zB}7=#R86PkFQU@BhD9d{Pz0&j@4!F`K<2$tNneE`@BuIiLctH=hdyp z`lZvgnM=Ps%l_GX$?oTi5qkYZV8x3WdE3f-U z`uEPyk(sjnrLk}3={vf|YNVIv@)-Gdtk9L-bY_N~h=J4ZYj3%Y{O#|5F_yhobz_FQ z6le$|=IrFJmbGtnyxs+O>TT0-IJwq-;gp4;n#rgCyy(3))8*8S?rmCT&1HsPP2Sp> z&cAdixaYUB;>RzXS-Z+CYYmf)_L^oY)mmjrnMQA%_ibM9A5ZsZH$FbzayM)B@2~O> zbJxh1rk_jM#(O$^&4RMG)~o72O0uQQ|Jq!5Wrg9XJ?G;$OqgYs?KpY4Pw?-H&wOTB zbV`|LroD|8&`3Yr#(S;*ai~ec0SBv^Cmpx8B_HR@y1B{JH1n2C!1bhgMHYrL)i0MF z_+2u2$#U~2XV*RBjGuOBe!b`R|KHQ3W=wDp*N)0HHS&Kjb$VRb*R}VRpB2qs-sW$z#dWel;-BTx^^4}+{}SS+WovpjzrE)F!7FE8 z8n$cwTfAtkv;NoLL0eKz2yGQ%{6D?OP*p=n=$s9sPJhG62UT`+U*;~8R`%qXtuCRw zWbF_0-#f#8xvS2)S5=h|ah~^0yjh84=+E_kBkcdaR4?sYW|K7U&9j$DtBp#wz4%-5 zZ1(M!IfmVnwqM?9wEg1FPunm4jL%!Vr0RR{s~e}beTuQ%+IPwQ|LjS}|IF4qSABVo z8p}(`l~?L7uD()vu=`4SsbI2{`MKq4m(O>rU)r2pec|rm-Iw1sS6|ZQ-23u=?e7cs ztG+C@FPC$;zSV!h{&Fct(e(8Tw{ZAA+?IP=r};|bq)C&e9L&g?DY@k)xWJsTzo-zz>1PuMm=>?gym)m)<5D<5}Po_cu1C@1|6U&-MVuk;(!WX}p)B{8hs zJJo)_kw}MdZtt4T zDbMG7cU^jR^4L|?6{-KGZOOg3O+mPIr(bB^R-aa}zbij{=bB=nesS8hN}I`*hYUhy z-k;7{(-s+Now#;a`?){u=JLz@PEl%D)zaWCG2yx%lKngd{&Fi z_+b{iqGEpPyu9#dFP(V1t+odhT@Qa56!jt_;Ml~SQ|w;FpR$RaQL%oju2J(LmF4r6 z*?pcL{%qynpc(5=eCofmujiiB^~QN}H|NPoU7Yz&?%LT!{Ch1Ec|>O#Hs6!5o|yN> zEMVsH`0Y|I;ij@a|Ff*8U6bSc|AG0;skg*(*Z*skn$~>p z)%BIDc@CZu)z&BsUi3?;amtgFTXSZ3?5+N`=(t=}glpP|rCArZNba4cFF)7f;cwIV zvS))UPPuz)>$WWnw?D)dHyO=&wzB^IzEe{yJh#4Me)lnF^}$BPOP@1F8)Dr&Vt1xqS1``HL_4-z`{T_H^yv+i#9(*!CLry<0LRaVpa@&6O9| zPO*AboxOke!jn;-zf9YzTk}uvwfg1T$+H9Z1+JZ1skOI&Dc7oWj-J&sOSNdL$nrNT zd#!4w8Tp5sN;uVL8qG3mi`>ogY-RfSd$01hNa{vy33{9J{~t5cTT|xGCdqAk>z#hv z<=o)#+IH*Tt+4$K^&4f`a%OyW=6kks@wIaYY!rDH`R-Ypb@f%!ydvgfCno0=%$f1+ z<zI+(s}%ai(uiZz_qiCe4N?%mtDRleq@SV@!V;8v0jJU_?AweR~=V+ z<%jt3qyOF{%=3B4ASG<8dm}H`cqyyjXOji-^~qLjitp0r*PNQBzOcT2Rgh-I4!*4=n4jAjXt4U>8Q~rO-&JS46sk@x-Ff!%QDgC!H{K<0Dg5R(>rRr<){3>PQ-9@Z zMyK-{%(<=;Z)~;cZ->;b$NvpN!e8G1)_GNVb#DlF+#~zvAAO#2x-Gx|{cp~)#h>Xg=FFSM8|U^_ZdJN>|B7pO@``hxi}3j{ zmFpep+&VvD)ttCMSRL)ZtmJUNKHJ;7 z3l2JGY;b+97n}L_7jKre8`rNdFPFDH+Ew~~rOk2A)pVRxA=Q^`+djb{qoD%-~FAxRz_k`sGv zf1{IRhu75I3u6>~U%vLbylm#|o{O@RD`#CQ;Zi;4v*ek+$@W}-jhPPa+tc}aeY0#l z&xSwx+u@-5ZvPCiudyj6{-3MP1pDv$SQhYo->y?3FQ1F_=@xGbxST%w)~TOQsPvj0b8XJKM@6%iZ~0>KE31h4 zS3yo~>O8e~ap^wK-4` zhDs^ZB9*lf+hW%0x5Tr~sBT`U5gf(l#xCXBAy&L_ng49Jz13fXeiv=7{Ppm6(v_{n z(-m%Q%kxe3o*vdV>2lJ}Mptg}Rq8QwE-IhtZCYt0d-lbKNH5+BYkF$aqc{GfB(CV>G!Hlj2=HxuRXq}q0;MJ z%FIP3_b*JId^&Sp)VdemtjDhhJzM$ilAzY26tCyU7H;+|$=&<#l+E4yNnM7#2R2B* z+npKj2j0cR{kdhMmu#?<`Z=401*aEJe|}_U82`%Mx#rJ{pTA)Jej@lYtA22^$R~*6=?6`;4&vieH8^)$i1wi7cMA^;PJqRh#s-S4P<- z?Ubszy*aJFOT_ZOx3}NoefD2Mxm{k#R`lmrU6^@z-$Khj+tWTtt*Xj?w#(c8&og$@ zm3MZEIB0w>DY)#I_0>!Jw#=%3M}KKAHrRKsB2}uwy6wJz%cMzu>}M_?HQRn+sd07hOTFop zr#5V>H($E!yiISmL#Mit^5KggE-qj_WfL2mX)$+?!0k&Mudl7`|KTokV~gg*KG|-Y z+As#tI_uWTsx<}K(yPte*By8&{r%G8KAVL%cK)}RyL*Dqd9mE|+y9)E?_YNEyR=sS z)8_lzwl7_Kt#HvT^MprSx4%vAD(87(UGZg0ZoEaD-pvDPRkO~YcW;fVclurz*tD*8 z6;tfr7w5b7Kl$A`@9#%xE$aud(N$)St+(pD%4-6Q4rq(+3wq}5f4StJcFFbQGcymg zR9^h6)!Uk3y)Vso)~cs$V){`o?Q8qwE-o*4#<@R3VBsNge&KZ+`2w$>+t=BgG_NM% zo$$W>GwdJi(VL;J^Ebh2*<|r~b;qWC?+uSHHuayg{6hNt+us@;IhixBe3c7qUdfjKTgo|M-|6(& zocG(LBh)sDGjFau^slp5{gztGB+Sz$ETh|!M7Jp3^eYtXy z>&laN!kK;^u;%4rUHW&?)#ckve4p)g+Fy5SUfPK(7qlJHSvQu|%=jL%ILox^@U$&9Q2eQ?o!gyu_T_`dGnPRcXQ|gj&h?+37+M@0CA++GRmj1A9&7u;KZoq~ z5{=qfvr|yV@WTsc{+pNlXIq6%{rux_|BY?RMlInFEc&0Tn5u^I{(o6nuMwuRrqU{T ztMm%@+W)p;b4~JZUD?L_;&}3=TQ)D}8egkC)b;3B(#(jxyYBveo8v$C>W<}XvNy|) z%x}v0dgN->MDKg@FJDKNd~V@<-NSc?UK&EEN#*eu( zpS}DLmAvb8>bzq)bA=52w~Fx_EbDA}r(MQ&=Cb(TGhg=~$Xfofj6LJ$^i!c%_qyAb zyt)#Z`}*2i_X9Fpv+jo3&fI?X75j(BJHpr7ZOOS=G|Md4>g)_l@1+;d3X2_jbKk!4 z+S+*ky;a`>Q`J>tm#F#9UdeHF&CTK~hJJG_R?fR|IQVYEd0kMg9x%_y|MBx&o?GFo zy+62|*uOXI^t5Nk!n1;A{r8^q^jYS^(_c2H&Z+ww!kR24{J-Y6dzO`cyuzv!Bme7m zv5i{&HQE!{+O*YYsM`q5|Npc`OKQ^lR`HA%6BcHj;IuMXYM)eFxh%?!QFz|zDOziJ z_?5K1|N3Vg$(qLy$G!OZ>+8$=4P~44pC5l;pK$u}mcr!Zs0UYVN^`E?o6&dB`jtV= z$(MzBH~LbUzZ5xmpDwZb{5s)Y}?dxZ49)495miFW6lDfKMGn?+4%?1D7&ycK)-L z2TPP?`*@y}3Jf|f7I?NiC}m0Dt)8|iW~&*Op3QZTOpldov)tWmbnWWWFT7thOgFCF zs=Mp)l4a@_554ux_|~_~q&PhL_P%6szRQ#Te$IUO&ov~|?`}h7)~*@}-Mh1Wiv8fY$1h}wHL3cdQZPJ zLGkbu$6Nbq*-bNUc;p_h3dor^%QS!CDW_SLpPyZRq8WTuCSr~J*0Q@+R?pwt3!Hpq zuh6(H;yP1sTkge%;&k4pnx9)9?ufphpK<&91@V2M9y33CYJOR*UcYVWOB0=7p2*yJ zv(B7Mmp#AX|HiH_(We9c{^H!ca@YETj}xA8{(0UUw5ld`UXjO>%(t^lzuai%U%7(C z#iIV5%~p>mS55|p+z^X*X!xLa>D&xEXItBAYrVxyk8Hp6%SrU-rhpTr^9)j2{w#EO zax$}JpGHJLE&H73JOzHM!`Fs!m%pm~^+SE#?2he~PLCVI*B4Z|%-F#D{bHp*gIdMo zU2~r@X zp|jI>+q1HMTXXAiHE)lye%qU{o)3GC4tvI#_*>5BS!KOnW$OH}IbQynzW4pL@@FnT z)yVm{;#S}F%15q7Uqz4QKN3B5J5_H|XDO(9nz7t`*RI*6I)Mg7S2CWeZ`dci$Th=$ z!}-j4e)GQgsz&T6n3y`RZB;G9>B|Yb^XAPm$#jd}mczT`VS7-3nVaD&^9?+!_vaqo z=6bFF^|{zxtbMYeqslbGXDq*O7ae)l+^Xo&oQWoH4IiC)d;IpNf3H@0ZTau1UT8?-JX-l+k=o z&@``aza~WKRCUfech-IV|Dcw-&s*-g#Qt@0U1hDh|Mdph&mkxCi>FjJ%~)vu=l1UK zd{NJ=)W54Dc9-eee*Pg`a_!gC59g%wSA}m%xIf9YJ7dS!qfW>F{ZyYHG)Xn)?@Q3Y z%JcROLggHBb?;bT-Q3%*D}Ue;=Ul6@ki#(+>}O6NDv+C3Q>!j~0+j%ged7d)R|HEUPIb@Sq5JhRgNRyB9lKI>G^o&3@Atw1J(9KOjn=1e*U(3 ztL{I%Ex)>wX|wg&Wv{m1oIl0+NOsE9i6y@#++EXdb6 z{!i!URX&%i-x|E<{=C*;$;#)6pO#d5`7Zu)_gepsy<)%57FRnZ|NUXTbGBY%u$Z3z z?O3nPw|a}ORnEQdDk;9m-tXAb6071u`?+B#*H)CJetl(nWp{b}*TVwqD;gUe?IhOh{rzpFqQf7J zW3S`P^6#yRW4ye${WpKKc|py!fOQ7``>VHcEp@nZ&G(AoQ`@3PYf_c8ZhoqdS~~yM z^jWj=W(rkb(Twjr^Yosh#^)zX3VKsN4K?|6?k%3?5%~24J~G<8D)+pBAGbN^(wA2>Pl&iODK*ZR((lIo)Fo>kL*3u0S|6Kr*Y;_ie`@5v zpnBrFQvo09nZ5Wn@V54@vhH*7vc2mbYr35q4lXd9rcwv!{>tuP{3>?fbGn`}oRl z-&?%xZNfvT-&?nN&B(p|{-{mv{r%^+$=BR}+pO^T$e;VS?QdQB_v&KTm7KbL8=k$~ zetz2WTf*_@EuWoCdVOte$chKgy^5o^he7cCm+proTd}G=-;!%9k)!g z*lb_DI%=b6ujGDJE1WMX;u5cfg-zo&g(mgSdhM07Y_qqWRe1PS=iA@SSANcVCU5B| zwdXls>4NEvZJI%!Sp>??fB(b1?Msj5-=`-2OV{iBx^Me+?^xgeXWwRJNB471-sJz* ze(AS|yEdAM^t6e8X8EusX0!M#&CAWMn(5{9e8v6P%M~7;5w@68(NR@@H~Q`6ep$^O za(m9_oj+x}E!kS|vxxoYl9w--&f45fl~vzdr~I(yQ{pSx#>!iDMd1pczFal=eMdu4 z0u+MTXC`x+e>TZHs1|L-)2O?p@8>E0xw*I3SMTeZrM~p}*;6MjADi2r^SjaR$D-CkK+x6)0YpZ~nJ zzVge#_7_vdykOLb_WFRe!nlWl=rnc z%du;R3lIF7`o(vmlGzt^*122qZeE&J_UFdNVo1n&%5y5RF+{cXL{W&tN3 zslK@OpT&LA2CvGir$S%c*qFRcF_7(R$m*)n+dFw*$X9(WwwV|6=5~H=<*6xpxjxmF zXNA?;&cC#557Jwr(OjbNvEl8?T~Xh!2hFwoe|>#~{Z^HR@Xss>3LCD8s4brTt=Z#@ zutn{MEmiC1?(?c#^5AZDgt?dg`IZ^WEoLpByqf9R%Rf72zYMA>Suc2V!}|GY^X|m+ z%;~y%R6UgUOr_P_oH_$_Z3pQtRi+Q-w*+mSp0MPes+zw1;RgTz*3&xgZv^#hK!xe% zeJlRg?VhLm_Acuz7T%k@vxNSf>RMysfBU=K=C|ykZTsf#Ji;KY{{Q~^kRP|EeUm!< z<>5-}<(0esb#hI4|9pMUZJ(6#&nzEQ4|_LO-d$FfdrM2%H06W9SIPBhH+LTXD;xNc z;XbQ`d`pkvvA(Mp1b-SWJ)8N!>cbSdb=mss&KmhloOQ5bV(2r@^)7{F=hWP;N{Cjh z;`@JXhxX^K>Z$oU-oMS?g#Z3<|AtlaRqkp-^A}I6vuozd}bQhed{^9VJD#p(y zmF0havF!2hi$C8~epxsE;Xf~pEzT964o8WTP(cGtN!5Xq;$OqI07rb!p`xm(p7Miv9Pd&wS>|toHKP z^HaAk#r=+O-72x{q>bs!+5LIi0hhMj_4G6FZ<-J)b!%I}Y$KtHtDYLu_@Y+D9d7!+ zVVh{&zuVt`@5_pJddXt7^Z$}t?ft4o#raw(F8mZ{o91->*gWn0uP?4nDLM9c zVYdfpWwhlXo2BtfzHI!@Ib->iwG;VQ&b68C(frI-d}HOU@^1#U&&>Vb_hkE3pF0*- z|M0@u%QrSY&YVzxQsPYH+WQLxOW$9dtQEd;*`iAZ{+yi5S6x2N+WP9kvEF@0U2d1Z za1qrGTg3Oz^ZD6X+m~$3u6_UL@ag8|ezV=azA|63E%!FNQi$}LJ#TN7FWB`}D&TV3 z+1`tvL5_0XdC2DPyPaB7vVJ@j?P{K8SmZXVWZwUE&4%$|v($Zx{=eNEnv-eJ@+0~G zccaCAb@Oy%uT10H6TNrCt#l**v(NYYY*pXux4-sS-i{K`N)^V70X2;qXI;wix*!$U z!L+r0yRGc4H+Gpe%Nan2m$g)GI^mHcXW+kF{f?ic=R;+km6^{ouKW?tJkc~=$;^Mj zsit*}OS$akTsdkK@Xr6t(XPCC0$bl-+q?W%_54!*%FLNrSF=}bS2$p^HS>1ghb1;` zzpctX_1Y+Wy0SSu|Eu|p&FSl-bcE}_zr5W)ZT*M8y{#r6P2~RNUAnTpO;+i}WAz{R zM1(-M$Y?~*S{`2i!e@TiJLw~*T<&{a_WRs&^5IcwzQsblZPGWZPw2EPDrLWY>f+5! zx6L!Jc!teVH>f$56)pAQwYv6&|NAr?S!ata*{;+4<-7S?;iI9mmTR+!ohmW**A?6A zbHDs=z*?Vm@6$^@Z~6Uz^Q+3Dy1dB7lA5M)7w3=cflCZcj|aTWHVzMEU%F1$HI}QS z({k1n(2hQP%cC~2UUqILlm9P1b9v>*?H4*<_6MKh{Jt~exZjz@C&PBzC!M=o^5YBF z+sxe`o@uJuPOY19({RJN%i5231>NTDR<4RYy)O9q$~Q}{hGbrfI%FN~o7r&J<}K$8 z^(%~vTx)-STmDQpYUPOub~)`FLi+gvmu$YibpQRAJ1@!fQ)_pPzgWMNt($HrgYeeu z@Mo7RH-ZQ1mR1%eKVpi1p?AsBzvuSuPj^o3eD`yemR8EC$yc9oeqNvVf5SxGSPO%| zJprrzYXUV-D@dN5{4HpX(?N6G{PS&?N{*3dblu3?H_O*3eZ%vGCtbx=6TPm9z ze?6aF+I;Ez><9l2-C1Mi{~*ar>u&IZPTjTA27d3a{h1VX$l@obXyu`Q$2P|={J$^! z)-<*QHnLWAZDQ+hyMC?qS^WML??OFgrRu59OCU=JOMV{svq-mi+luhLJZlZ6v`zbN zxa(MkY5nOb>gu2@Wixa6WAztT=9X)JE_t=py*JZo(mXHm*>~n;&g)a1bvbj1{7lbu z_50rLQ!95(F7w|vdz1Muv<=4)vv)7&bQQ_?DpB!iB{N{OUZFBC_WXF}6&sUyr z5}#7}YR8-0=*opBHhl8CKilKI|2t=X=`e}^(fxHL&eI==9;*+pXSq6e(8Xda}aEGQx1q=TA?Hc=r0vvFJ=YKXbL+ z;prb*KBk_Yep&Lh@xNn-W>kNF7kvDX*CP%2_b0P2Zknpj+*%ajeDjda-6s{(=SWB9 z&T}|_(K{z|edVWgFD}D{#@g2_FCEJ?{1BbXn;F!Pi~Ek2b#C_IBcgIrH4O`QI;9%ND=fe)-s?T4&)G_y7L7V)`{NStZvXV2Gc&l?#w@bb#KA?8&?ClKi+qEIjUY*O6v8d|W)f?q<|Ej#x z`)RK|<-^{%-8awk?`=0*_VBg#^Yvv>D$OtWv21G_N#Xq+8b9IzU>Bg2z1X(o=`op53#A>B=0lN9Y&Cah)yxc zGJ>B~j&On3)u|j2effiBJ?wyV%#DH`qZd;Nea0I~`qCTC8@Xaa8-%_|eM?OSEt`jI zq*Dw7oruL}=+C|KU}$(?v+J_ji$xQJxQZe*%(<)C6`jmPtI3cYMuuzSWsv8(Qf|_DHoa zCAofWoF_V?RQZ{v`b_nIu51wsej8b=IOi|S+~Dy)^jL4#-|0~gD*o-^)tSilmYdP# zt)7w{|Nrzk>!)7(=6axz`TB!=4axeQ=k)u0cf8tTd4B3kh2kZZTp36ITP{%D%eUsQ zmMqWuviiI4c4{5I6foi0oE;f=m!+#!N-+G=u-7#-LuGqR?duyI}el{^U zxQgA^epQ@QK-0IsWtZ0%?v7ubG_R<$^3tspK6@?pZH|)K`C=?k}qC-NqkfsW99TJt$2TjY=EIxY4}qoWfdGk0ZfTplQTQnc6W zdF?BSnNzd9pSPV_Z(eus!JHju4%|QSTzvZHIn0kPt-q<}_k3&Wz0DoxEcu@3eSUJ| z`E}Q2dbQv0tPozx?zj5&l+V999n4qVafn+xuX)RXTYL*yZ*Oh8F!NS@>WSIUG8cY} z-R5vNs&w8>wF0a9*q1Y2>88Zq`Z`e>)H>dI(B|dsE8lNkQG0nmcWo}mi(~VreO)qr z`C?X&${ zyzIo5w&RK&3UglG-7r^s`FRm7mprk_W%qt||tN-9Z+uQ`a0b9&Iu;I(U3Tx6kVYr?P^opHi!M?Ck%}(767~JV5SK zvi<+p=hs^~{FSPVfA%q_iQjAe^PM$M>OMd5SYCha>(dK8YuA-Doia$cJc<9>?0>Z! zCN2xNBsJycmgY4kTda?_e}AUBA({PB zi^cKNo_PlT+e&`fE!XRr|M%;YTOB_xvlU#lIu-QOG{(g zO=VE&i+uS%Uzfi=w(@>6vv7#5RO#C6!29b8F2?AAZVTglbkPjQA!Ti^8NT;zI_-5NQSfiL&?iz~i2?z9Y1f6CO- zrk;^=J#A|6f;i?Zfgesc*2i}Si*L5Fk6B*Ucg*J3zthg=O#N%#|B-rBvP*2vt`GN% z^G-B_n(ICO8UQ|vjnPN4F-%}>)MnWy58 z@B8)WJp0AG2b))(3$fjOb?M%F#mX1JI|J?a-gDq!yf&c_iR|-^~=}qO?$Wb z-Ry6t?Ss3+=3139=6t^sYrLu^y*AV0{MluZ%3$Hen*hPfc1&o^pC7lw@{g6ZapvV0Ny9u-U;ixlNmX>(`{J{=3|=2w87R|# z`ucpat?xH;T$D=_ht?rZeyZzBYn-kr!|r;TRoNxIYp0MBD0zX-t>}Vu3!1_qm5+VW zyhX4hEzqRE_0ohHD7P3T{_Us~N&+>Hu$VQxG+0>Mfi^Ef_9sTz=gez(;n4wVL5WPQ zv?_kqb4nvgSgF<8?%kILlL#jhNYm&-Pu`p(Hk=ok)-L$lD2~=5xX?30{UG-Ru3PhG zoDpVGPZCyQm7Ra@rQsq)#UM~~V8`X+S?U)j3h-XwDiNMq$&@p%?EO98l`B^sJH*=9 z=(a%g-5ZM{P8Vk<6_8~c+HM#)I^UVz+1KK6v6SWS27RMh>VN#r17vD%?XTbe>vjo) zW5NQ#4A=Y(J1z?Ga!v&8c{(JO^!9=AgY%1&6@^MF0{Oerl4pf4oSl5H;$W*{ zPivur&k`Y)48~anRwbABgSI1rG|g`F>F8rIcY1l^tVLr(B~u<7mqJTlXL-e&;1jzY zn16lf3eNZiQ|)EJ`x2yD>Egt96H9uYb+SGena#(i^+a~g*Y_f`*GXTTkZ>sS{~|@j zs5xKXFH=-sr0jS+^8dn;CDkgjvn!c)-H0gAv3kzfx`(s7r*&DkuZfCFQ-;R}FAH8! zh<3QRs~xf73>5YQ-3=uJ+C2EsM8!%-Nl{34W=G`>z8QxSo!%V|`M!o*Y!1k>2S-A_ zFH%&L@+pN_)@XRORzvosoTvID&c`(gmzQWOzCU19(ACSj{J*x8kPj#dTP{!Nd*M-_ zECg1UFz-%_T7Mar-yONPCVydSc4VGt0jZhN=On3-I?sY}wz`;+l zO6btTD-$m}xaQ0gICdyv!ApfE@J88{kA;y^kj2@_FFZO}V%-zZPR^UdV6^a}6Nh)q zE&;v_mZ-)T3||h{cek~8Kz8CTe1FN;;-45|C$3R|+2 z^r#O}vSqlPnf!n?gY~Wdm1mqu&d|%+Ue2A5$f;}OkvlPkWi@}cXLxjoK)bAmk9BSZ z#WuZ4x}wD3BqiS&7AKD_@toY7$y&ZC`*^vc>Rhq+cFupN%P%{2`FlXCq4kT^jk90= zohSX`uAH6StGyHJWOmMtI&;rG*nECSrB5p7N7plxABcjt`+IhPPB&3`cCvNa+NjHW zzuRU!Zfut`T_$?xWS;E(jm4T*s?OP+x?d{0_fF>8{=T%hq7PBiOHHco7M?m`FkSZu zyP-dn@*W}G;;p|cCM8XGefrV5_u0$M|2kKl z5v@LdY0Fvl<{MQenlFBYHz_|4$~g1uv6)+R&+URcGwQDX+iY@c7r$Rxu5G4I{Z4_; zDiy4dtw+%MsL1h5@U;oCR{IXF?%OqMdFnLIqGk3aYDY8XI5gVLevy=AwKwT$?Z(Ga z^0GC1QvS}JGfOb!-qZ!J7d3zJ*?rY<&-LPF!@tuxXTNy4ZMBGhCQo$nl^Oqv6rXv5 zySAN2Z5BGm?60#m&Am0{)6>)L|NlIl2IgJ-o_FlTYcz$!| z>)4G+tV@?JmHghWq^a5Y?frfK>ThoX-`w3jebVH~y_ts_f498{|6KB5%U;G48!`;D<0oV$B^(i0 z*%VkbYdKS`*{xH%Ce@_O?LKLvdrR_8fQ;2_r}JCBORC?sd3Y=6^7=KBBGA}yNuFn9 zWR&svSZ~O_n#y0t?f*H3$CggjzVZJszx{&*XoHowmK{l4FNyb=ZuvCS)e%*}FcIGnfpt>Zhx;NT8>k39!cXXwZE?K|ChSQj_rEqW7E7l5p&JoOQ8L{ zzK;Lz<=QQ_G;(v=#iQNgy{U%aw%_dKPo7LUAnw@y{qdgX`Vk(c+1HmU#fvX>a^1H~ zYHQZzIhMtTtt$usA_=yq~lamN4Y%JTVj*S;}l?br2p(@I@3 z&!|ZA?%k`0Kh9n!%Od-jYm%&$ozI7By^}B7oV`DDrR0Y5M!`RICa26||N3=-?kS%T zsdmM^37Eh7UVJkb%Uk#7XeN!=4;qKn=_q=O= zed$~w>lTuFV?*MXACLRpYj~a=lg?kFzyHsqKDk~Sqn;Nxzg~}DucU90XVRAP>B&j0 zti#9mf4h}EHPB?KY2)v})nU53o=%HCB;_Kzr1sUyVBIFK@h9-DF|ra-f4-7>kTLpJ_e#l&~5+sQhx* z>vhJ{1UW6X&(7Z$$@_igsq#b5I4kS_zK#!me{ZjH-=s=Ie*=;H{eR0smYw8$clp9X z=iaBU6*dGc^O5{@b$wl`j=ISH-*?|%30Sl;1~dmyw6s#|qw~uJZ}SxXo}7HJH+9Ri zl_$bon=iFLI^1v1yW_=`$)|gtajLw1BUG~H;WTFRshrFFX3I_a=QSzz%;gPN-xbfh z_j2F88OM9Sedd!md%4_sUCorX%Du0x7FXUX`{EREOZVEgm=Le8c(g=4U=XymWQO)t4upIda^T z&uINBys~nZMd6}XK3~s$-M;_t+AbYMsjm;)BoXe~hr& zRmoKU|M&fs!OQ(t9X4Njz4NM#taVw>zQ5n17Z)DdnILz`TYs;MeC-#(y`Rrne>r1( ze#HxsGjok?`z))zyqM6&f4F-__p;~B6P>oaJt`g_a{R#Gst*U*U!Jf3SNxzZDNpj< z=ViXLd!8~~(ahGkK680Q1Nh92wQoGm3h!f)uZd95$W__%@tO6MHA?Z4C3(J?iz}_n zo?V|(R#yC=;`X-O@Qd2Pm7%Mzc4+ZbTb|H+_VU9_k?_wg4|hmLE7dPr^JBwWx6fB{ zKRPD=I(>GJ?DF1YoEKGtf6aHxH=nh9{_)oQ9)0#WlaenrMpNf)m?Zb!v{~-G`D8g+ zlhgSN(k?qEKfkc~ve+f%%eseC30Ek7UbsnxM1_!J3BYO`VwF9kX6c}AmK));^}F+n!d_N8&@9Kv!+1pMCLpO9+79Ft+U??cUL+!EO2h;J2m6Qah4tN z5^9HAa_!9438!VBn`4=EX~$)j#@TthI-i}JD_x?)5Zvnd;Md)xMp z=IoiU>XXIU%d6*J52`XVTo;jaFlFAUe#6-dukTuCJZpJx`zCkw7p~58#qt&}xAI-A zzT5Y_NdR;D#pJ`!0@``4U-W#P8(;j%#MUmk@uGvRRkdQSrT8zq1qY8`_MXUBALD7T zWa0k?IM}p`Z*Jv`vzA}CTEFxk&ZEl7h*V zMRuVa&vNYFn){V~{^EJRH%vXjzj|p$sPk|C*ahLY&Tm+koi(BHlzovM@BMxA<>XDL zuUhM|q$T$E%f^+@6!&biKEuzrVB_4R+Va;|S328I)$}&azOrCpbNdC&!zK;dyjGnv z!`#0lue`L)Z+YF`s*rVW{MV`E-E!#`(+R2Ho`3yZ*s{rNUteo~E;+t-d#bfE=h_W* z1%K~MZ@qN!|KI!nZ(2Rvey>VfVCRQJTQV;U+Hf!D7tQ^DK6O_q`RcPj z@7QJblrira=K-F-UoQKXJUYUed@tjyuusa}U8T9l8w#5~I+mQAa-2n}dWU_L<&5Aq zRoOXiqV{|SOI9sEY9K3j?b79=pm~y0Tc54W*~xY6f~M3S$1vvfQfQ+J5z)$GzV6lP`+5&)@V(W}zQ9AIpn9*1ZgveC{a&0|UPKwNVq!x$Ve1^E7Gq*VDfqvdb+= zpI_&8*ZJPRPu%*q&fPz`XXodP4+|V$z1O$ zsq+rJum4~D>*I0xUVd5E?0E(G`8yu6MgN>pz9r!xQ}VBXgorDdBCA7JFFP}79^ZHI z;v-*5W;C}YdOp80CjvANvh$KnBHyIj!6giCi)-8cj_v7?Q1rF<+~Q^A+8`u+cCN_g z;^*rY)M$j8SE(<%mt+#4o^H2d-oC>dxy64*J*|CoGCias*xs#7Y4w@QFPDU5S(n?_ znigbTx}^3JgGYp?O&wsBju%|nH2 zQ?Dmq43L^OSz^bWdHEJI=Ve>Xy!Xy#b{BuNSN^wo&i-X{{rscYRvmdVb<498no=A8 zGT2%b_03}b`r%0Bmy?U8e)+lS>X#2|SWns5+NB3xn4lx|)?Kc~V`f*H@2n`bWWCb& zw|sL~hc923A@Fr$VHRDmEC2-?5anD(O)3owI_6Yg_UScYD4A z->VP)`Kh(awCD(j>GQPdrT)LZzfONTbH$|tG7mq`xw$#>{XAQfv@a4;b~zHcw|7lV zJ3q^HZPb<})$jIn%T+yKe06QBwy%W!yr4~{2PBs=_PY|IdQcl^=O?yFxP%A}v2v+|c(dZiV#>fUL5 zZlS4*&4yz_^MVdD^)0Jec);}0O5?pg5(_-ks%ICtl{u^qTicWAXz`gPq02z>US<3u zQ~!u3LHgneOf@S`%#e;Z^=Dcz_$-}t*-=9-0i8J)BB{09^ z1T}BeB_=KwY+)&sJ-3J9wNqlf#pf*r50xJOdu5u{`t+f@9RDfnx;^U!Szd~lUH({j zU6S|8&eEqLQYK0Kc7JAsir-9*RW#pp$VT^8soP9Bm)?f67L4MbThz~4ov}}deo}aA z+T?Z5Uhb89#(C9e=f&H-&sOf+w(atfUiB-JSPgu6W-tE}d+p{a=HusfHt?)P>a6AO+0yM69C#3Fk~3${vzIgK zIoTVop74G4^5nMFS698MnmJLZyX{1( zD0V2|PUY>ljq&?4CR{x6IsHuh&iC`GU+zqw7y7y3-QL%EyZ%nQJ*BhqqSe2??_55ABkz3~n}P{;RsWf*9@Kwdxk9Y(`w3_Mz=QsFfya;AEon|X z`2735y7qaMpEP&9{$?F=>z-|OVCVbpm9G>!f?1t^6wUy-!zWW5$aoH&e83 zrRnXBC|0^EnHYO{k*pTe)uLl+Sr*C6wRh_6dhcxM50kU;)aIYNq{>_9<X$1|ewrlRKX^dk-loalPOw>3yJRKs8Qjl2`RHhtT>HQ7X=jBm?fG=+^pzcf z%31feNbdbrWViKgefj;xJl>1hcvn_#PW$pJ`{&hf-|yG@n;yt0-mewol3V?7=ku$3 z7Qec>>f&-%J5C;PtFyuthqacwY)s1fQBa(u)GJ{&r*36P=etdx4$Qf-GIVxT>WihB5QZ%=+>fO)jO1n58{N=ajJ7r&VMPsY}oXTJC zem`>jz4t@vLZ!w{qLP^u8ScxcnIx`NU9<9=P`T-Xvgd;nH$^~pTxQBG{np!l{`WTN zGm~%E_O?Wrl>9m4`fJ%ynW~+qShVhx@#WsA?~?M3a9{m>-h#r{-!DE>PG9l!j_;K9 z-*bLdyl}EAFzeJh7wbN`Om@+|+1~?x`&zHOJS}#gWx(a7J4`%GhwL zZ)eDjXKC}~>OLsWUisqo^8Q=%{!b0RYMS_iSM8;MHBt@%hT4 z$l|X*H~ZW1mK@n3zgt(grArAkqwaB5*ujNkz0tu5{l({cg=aAHyiDA2YAyevy;nak zV9rmQ=@op-qi~-Y+xiN}{kdM*M*fF&-zIc?Y38@%=yEz(laQ-2V@bvt;a5!AnlDom zU!0j@7IbTC_VQ~7E}cFiuuW6fVG8&4Co+eMnVj1^MAa7TJ{z6}8r527?9XreslsKV zdX3}!%5SX;{yaYRTg`gb8R2<7kN9;Ve_}uFIv76&Et*71$bNSIxXAu)0#~R*Biu-<5le0$i=n zEY`bh9(+-b`^8SR$(hflIe&S4;%S!9&&#pPFCVHszVgkNt6#1hxT(c;HRWnpRob7L zs)9ZFLO+){KWpbKdw**!k8oSB+^+wOS2xy2EojKnziIQd{;TQjl&e#o&+ohqN!|I(b$HZ!}F=f-mr|NC{pCK|dYxITZ`m&)_yt@>J< z8+^&l=kuPuJRY<(=cj<~fwDQ=x7PQ)wO(<)H2XSd)_DUR*5pE^YRu(;w|4JzU#m-fW-0KK5PEZe|Ng2scTLVLZT1iqD>9jy+GUf-v{N&8 ziG_NqyL4q!<)M9dcbBjJI$_R5*@FLnzh6FY|39ai$#6=gQ>;SP(&>$&hYru29Iz$l zrqPK5kLETk+j8dN;r6S$qC0qA{q#xjWL?6$T=`nSI%9tWi|V!db2RtzsbyJxZmRtJ zZ1rEY&n!Q#pS!j7buFu%pZY+maIN;5wi`3;tsh>s z`D<|IuFYI;zWhmZ=j@x2_VjAc&Szz zs}oY_hp;R zj?*;vlUy-fhxuz#Z|9e!xt(8Xnmt3(-$>1xRdK=SDEG>PXLz^#zEW9boG52|FP+WS zEJ4lIszS`xrpCs2-&Cbm=4tGsVU=c7)`rM+ZU7Msmd-?RW@m~zs z_sGt@zS-Y)?XzDw5&Io^KQGkbd0i;QHc{hE@vP-Z_iN>+h~?bhc6W2-uTPuL=ia`k zp748b`MsrfyJs$+uN|`C>FtEl2RHs*dF-!|d}*7n^w*t*habFd;dChBe|~iCtzV_D zuL>kI^4kX;rfAq@>)S6 zTvxLH{`#8xymf!#GT)cJ33<1|<7<~HmF9hI`+mDsOBl48$Lcf7r~MzpJPehVM0|W6 z-&46!PWaM}naj7mt$D^eQQKwboZlZ$JK2YANm;V|!beVXiJr@$B~=%1Yw_Os9i6{_ zd0+e1i?($m7LiSm@=3^F<&n-oJUFJIINLiIDPW3XK z>%IP;#kI5rpRUjJd2c%3QGWYuSKH_L!P$Zh2Vz3eqlHt>Ia^>R+ft|!$WRZI8I;XD5N!n(ay zeoLNle&;&ga&gkuVj%j?~-|YQ;++z^*o`+jlHqXlvM=DU=Fx zJ2Z2-Ve&DSrw6T{&-tz*lwcKK|F<+c^81})f1me-u6q;b3BNkHlR^CeOI@%XbTpso zer^1r9dhjJ7CRn3cFe{gHfCeqvzHT(*DOq1^7GZ}2j6e*{r#`~mE6nYDQtXFYvL9@ zT2lUg-#-({lB|EH-5!_JJc`$}z4@JcU-|rf+mio9JX60OFBLAe{#oYXh1_4Nu77p= z+_L|=yiI;h%6-m7?#VjAOpjBH{5uL}o^oV4AyfKk=JM^k)-R9z_qAK>y;ZJ>zx+48 zc{lnK&E6Q#T;4L(o#}9UHvgQ({7ArvVhY!J56$5D@tvv)BkHyYHEA$wtSE*NHpD2`r|`l)zhcb zryBlPSiw2VtW-;P3s3ayYq>8v-Q|OqDFuCQS;|>(pkohoxjg*IyF`Md8UNGtmCf} z=WtI;TC`Ks7CfYM&4$tE!@BVOin@`TrcBd~_glNh?RntoKQk^dEqT1@?zKjynb})p z!Y0Z;|F?X8<-D}>Go9IWw%0s45qK-n^j`&Ux3O%+i;2fWtfow_D%p0?B5}vNNB0=D z&i?u;-Sf0Z%2uu5-A&H*&p3M`iq9As%w+32D;!*ZEACdIj_29^8%vACo(EsL+MVys zb6?EB&+l_f#yQK}cYS<4oM)zkRh57-c|6gqc9 zH_}fzqW1Yq-1@Iyo;LV)>Hpbt zp>XR(n}=tj;}ZA4(R~t-sq5>ikzdFYH+-Z^&Fb?TiO+-e&QI zY!1v`8FZ+qYTrKvp5OT;YkQRtrR_eTaH!EwMo_rq z9Zyg2%uDvG{EWjsw`^T>ZB69jZN3ubalF1Sj~~2m5b?u0=rc>*rn;}imK$|KAEeDQ zSSdSyv8n%#b3FA|__711Fl`dMjsfYcY=D%x~86_;%EMF?`&JIc$ zTPvA5t|jd7e&4c@Z{o`0`Ig1c{%@uF1n+^CLnkuw#;(6?WelGSA4#Pd=EK) zb@#h3Z$2OAPusP6b@sCPE33Y(-M)IVTx3&9R_$41#$=;2&Go1FI4`aIH{skB8^$?{ zSFF%5cxW}ZvdQq9enjWDM}`I$`rb-N?+E+-cKiJ0a!>ppzH^g3aYFP=K8ta9wYXOe4m*-f#Z(5f3L)K@e(bXtEg@e)4dApKQz2>{ihksRAwdLE~T-{?U z8@=SNzvZghCOgB@_-k?RtEO27{tVs%FSgG6^UQPo`w3(LNKeYLIn@`{9qUM`dJc7F{sWnH>`ecawCtI`)5 z=65fgGwyA+5qx5MP(tyM(T4vFmGy^y7oEMTdv$SQK-www`76Hr+sVE5&siW}pLW1u z=hJ83v@E5AUf5ac|vQ-gyV}zpaVPy2)H~PI~V9gclPOKfS%Q^puUFWz6@T@n`1g z?w#Yf_wCo*Dc9&&N#P8U*Ge;+4djG! zyyXM$-!{EovB*DR{oZdu<*!&(tFu2Gx0aK8<3G=BibkwopxHA{gKx07Gbd9ZMQgzcTQ zIdxK-cZYrB zh}?5>!Ol-l)U_|`-Rg*u_1}ALr$OtFGd(vR$NjnU>6-b*V(Vw8T%L!pG8(r{J=$F8 zGlO~3p39Ba@0uKBpYzRBFYo=me$JaM!LpBY{2TVHvV3*UZicjvcijJ5LZYgNs&mdC zvsrkDvrp5wk<+I9#GNHF8$wzxUNFu-TYmPkq+5%fHK=o86@F8?UK zomi4)oyI39rW2aL_aSz78SfrB1Iyp<_wP@?z&69_P_p|3|8}!ipMLDgP!BtA_y5o5 z$wIpdx$T9{UzS{-b5{63x`a(d!KLO&#)kd|{ogIw?#u5z^hHUj)Zj*)aLf6w_Yym! zXFU5n+h@!DZ!y-wE`K&0Vfi#`gHh%Fy1!E|y5*fRMC-;~$WM_r_#pZHhOTz} zW-0XzrC)!~``qB#Eyh0Y^_I)$x&GLcI{MC17xp=+C{=pC`t*}Kj-Q`=VJyCSxHdVs z+Hf+<2dT^96*-(jhd$O=N7t&@&iHpFP;&$WZ!W-EomNu*aDX&C$8l^ z={$JnfpOXEYra;cua=xq+?VV#f9BJ#hPA)Hm7ZPAJHt?=c6M;>3^CWa>2ph`#ZvFgJ5TziLg7p)h&E&sdv>155| zNqx1C&)#sFRp;e>ddc11x06rJj;X%;b>8k+t?T+cbJpGd5GuaB=WeWi#0G3*e zjE_~1{<&+>wlkACdqnc49={?vX-4dv?;UcfyF1i8*~*2IqR#i&=tl02yes*_(C>hu z-*VnBdfNrsHb`mRj?g=NOlMNDb?;V*6&H(6CfmVvkSFDmc)y%&6z`jD zRYr_yj}|oZ1)aRP?bqKYS@TSU)qDbex>=Y7e`ZO@;IsSjK!tfKuYuVNr!80WjWRE( zoUaPG$xz&?v0`l;!`cj9(KVHa>Lir^SlG=s^}hSk>`$wB>Z>c6o38y_tv34w&%WlH z#m`P0>6{!=yUj@L$EUFMaf{Ejw(6++|8v;)zi#R(^__c?`&P*^rz;<@$h`1UC-T$Q zo^lr6a(NM@b>4M+pIx%#*L+#mD=cBGFBZD%;aAnL&n+t*>t%Na1xrP3Et|aO=fR+t zU+?6I_P+Y?LL~G55s{rc--ql>eNgi9-yxlszwa^q{3886IqbxF=l^wom%L;ZUln@k zBZs)kt@78mLc@-RoANV*ij9?(ho%V2G4!06l)GSSW6O(Uz0#{Q)uZRuT}bbjGWBwe z{}WvJ(m^j!`*_Kt;v28F34)rano3>GnWuct3OiUQYE8VITG!`u;FtG~#TV_`st%U_ z`gB_V^uu=fc~c_~n;uB_{(8GTY)c#0Et`qE+Mlg7I1m-KtWxW4jOJ6(tM_)ld881Y zb@|#X<4>k*6-)YNpVm(OyKJqV^!AX7dBPrbbp^2nvD5XVm&y5WuKJp_a@9h6j)jnX zbwK{+{F+alG4+4H&O566H(|oDDPlz#26xj}Yjf1(sknWAcQ<-1UtaXX+bqx6Pb(xy z@y>Wk1{^qp4E)*t(T{xo4Z5KRI#otw!ef#Dd?u z?^m6&GwS{dS5er1u%Fj}tqz$ri$ywn^ zKbM`KvG84n^P#zGc~(YSd=TZU2=U#odEa|;;^(Q2R{1Y%URoCJ^!b~=^0&^uTMbf5 ziH%`f>N4hetaH$ga-L}Buk!!l0rx{ryc0J#Xh+$b_^X635M8S{OFd|H!2{()G%E$p z3a<>c_%Lw;nm0P0bzaeu`QZ_nbv0TUbLAQ)ygc-$qfzN3pNvD8pF186=~i{0l`S$_DrHRqM0dhMD?g1U!ZYM*lPb+&(> zFInqvEV=PerR;_FUAZ0$WxsT`UV46`A#6+CtKLeltNat^G=yzw`*g_=%3|JKEzH1(>r((5|R^gA}J{8C;VeHM52ui9`o+^tkWOnHgV zr4xdF%OAgg(v{NkUgCA7$i{6eYi+wi*k|7HTg<~Du+FM|{oH@&&fn{c_e`w0b+IE~ zUR&lj3aIif{jHl^zvg{seERa7GX)R#-u%z{MYd4aH2C+f)3te$`S)j@vbniMJbU`$ z?K{?W@yO0%-f;8UBDr(feR$-&pp#v@{^X#mo8QYw0i1Vf&qJUmRJ# z@vXVkjq)GA6#wVPJ-#?et@T^MzLrWap1M`Ca=x#MkCv|MJ72Hsq#b1s@sZBoZue@t z-eaeZzS@#`SSCB^Y0a^<4$x%ra8c#QKMK8$hfB7- zTgdZ|rDD&jx6iGbztrcFoxtt)b!FT8zP$B#V|;)1^X^W&4STn0eEF3=FG)l8Wp-v+ z!oK&`*?T2_9oo2S&$ab-+a`3(YfHMW{d2p_ZohkuY72Qc+kZ-msqb%Oyk}*yO0V8t zWQDDB_P<}XRmQicSLAQe-=}{coOs(SyB4ks46sjmb@)PC_~N>^lF6+LMfaO=fy>eT z%KmEaj(#ikk@|e-XY0emmjhQ6b@H9KuXyA9Iii+0TFM zC|eMp9H4EoXz{`yUpfBh{$^Ku|Nhc?*6jO%?axl?ytlvC{NCm#!TFyPs88;?h-hNnM+y99BJFh&yB0Kljy1#FIr>ymS75n7mqU6%Q zCCeZG-gk9h&brNSe=x*~as^(R@Lh`Oef~{rgG%<9En-Q>BYyM!XLz>SdGZ~;c9C1f zx1X-RZ*y$%+ZlGr$A8~yNPhJ78TW;*YyPzc{+YiOx5j^$UTKo`#de;Zrrok5TPLo` z4qgn-dm^5nTfV`5+KAATX>$%L8r zdS1WTAIB)Aud-;_!jS!``SK4;W#x2PzRG%DkN&?-mwD;`^0`jcjbCr~1-|`scV^Bu zbN;U!Ycu{{;r`4L^ws&mp0s18-?FZlAFFs}^16HWON)PoD+54@;L~xNiA_u5W-XsD zc)lTeL4fm5kDLHso=rb_LGhG-MtCJCQlM4vYTKm1ReyeK?_ajC?GuZC$w@`0WwKEh ze~r)@6JLe@gWawyLM0m71aY6O?jSoc2Nd9&jQmqmQ(rlB^`>;m{rIwg{YK)%oCV;< zRK_tAKNEkI=mnx*>_0f6ntp-jtS~3@mZpE|>YrH-2Ii;)8+@4AJg-ekX(p(-^uqCc z{=5~+lRz6&Cr_o={!T=Ai~^EJZorGH`UR(plW+ zv22D_Q{5K*O|#VhBui2b>^L@0J3Iw+lRPpkJlUl z?kXJ$vX1u4%s0(aPne~?`7`+P&Cd-UOO#lm8r1&q-Th{7>XHMp$5CpNqwGY5pqCma zEEuobG%7H&3OKnxlFj?A@>M+Jz?`^`+-LvHmr^o2l;!cd<4XJ%z9y3tC)tS#Mi(c( zoanRWnJ4!-HM8Eeic1PU#@Fw^;d3{@`AFOUU+Jq9*D42lOV!U^vf*QV{aWs|!fU1N zuLv4`jQ<~EWn??4^SDi8NYZB0rYrVAoxM^@GbgNUGuhR%QaxzDq3Ofx-Ssw?XB@xr zO64nmuyEbp4B49tDwQIgKf4^7t8Ej2{q@TXJ`%3(uUmG${_@BF6 zc0g>FdTaaJTiLNM4xYclYwGgn#=6=GZ4LqZT|5MqrRCh{Q#{h|Yi(xaCvko50$s)_ zSLCd$=fw}oN0o6*5A@s;~;m`vikZu8RXPqSS9Ta~(| zytiBa`tu4(@4P4H!=d=I!N`Bt=1b2!`KuxfxaP{-D-JYVbg{e3QMxM7aDV5e+ba~4 z4lh$&E5F(PQ+Y&-z$E^PGm|YpJk)pk(|Y3R8&81?Y$i-=XISbmykDs}i7(jMjQQ#N z4IGLp@*F1q94nRe4_Sf=-K^m@iu}+I~=+bFPEYlih@AZ2|+! zh5kBU-ibaJq%N_=e0MboXqixRG;#=Y$*!(^bijQRGr~^K zP5f1)w^SPOikuZb$(B3s!v704ATwsE3rX#-ytIC$;v`KI|CR|Fj-OqeK(XcY*@dH1 z$i!d8#Kd1khmcg@hG&`Cu1kIXeu}Pq^OXN-*YCI~i=%9{e$T1i{-)y8+E0IO%)FND zk$1sn;s%h#i(DLME?>1OYv#AB+lzwdUw;~&epm6f$r}}3zZWx(Y}hzIs$YF);NoX1 zcSWr-&zsow@^i}Lxy?)8TsgPtUAegT$8YIgAI+9~pPVP|@pAvI6K!VwUO#8cdw*^7 z{bc`qSM=*&k}FrOnsn-vm+RKgk~yg!24D{a3OwUn_VVvc?x#1d^+%tb#FoZo@F`^T zm5$1yb%~{gGpFqTHgSsCF4wO5hclM4Enm7cWq#VGl`mLVMs1J#7ap0qe)qJVGv_dr8u_q?;EOwNII`EOYkd zr4_f0msD8G-)O6C>x|m|f9a;uFD5JRh2+_~=LQA_D*D^~T=Hh)ak)zi9GQ>W|NF>4 z)2cM9_a#r@+U4`>qGp-pYKg~HEL@a*eO*vYOpdO+E5u2go^di?zF;{26rcM2SB5^f zO<(E!-|;F@(x{5f_tJS6jn5&0n$gS8PTmx3<$3bn*_6Bc#82dH|8Boi zeft7WN!?xaEtzZmnv?}oYc$*f%Dx-EGVYjETB*z&o59*OTSINzutelAxV=FCaY zI8Qz}`(Eamq3?`7>pO6sGE`nS$EtqP}w)a>)n+8?PI zYX3(ibanUAtf`;>f6rc;l`6C6m|1%K*S51kTXVMN$o`+1v*)E|21O?pwF8MDQ9LFY9*yut3_wbdox#DvE_>BL}Pygzxw@; z6#ENrech7x>CcVxOsCh!Zg<@G=U27lk8dSA*WTBdY4<%kJ93h=<;>-;vW~NedM_>3 z)mnCD>L+viiMoI9PdoeFUSHb!>tx=waudJmR(agCEcdvXx!vog=iDVF&K0odL|9{_$4luHN ze1CU0^=Q{D`l-AAtFaOFtE^*i__bK^gmA|FW^{G>GCw=SR zo4NOKU-|8;Iy+}9f3DEVoOtqbabM)EPk$WaDcu@H7#eY5ykt(^5BmVrK|NoDBT*X6HNz<$;HQ(=+ud@4Z$uMc} z#00+L)}j;9mjtz*_`keA-;RCRzo41wOG}^cj@wzZR40C4OxW6}rFF;ueP(NXTqq~A z{Pvc#vr;!VrFL)K!+WLi9Lw>Ad>;<;+pj1~k*Zx>snp`qd1A(Lt>w*jD`VsPIjx>9 zTRwH6(do6Z+XMSD|5iLOVr_V~GVSCoo6u12pW5=rChgaKrXgzJH~XFZx_4*1S6#p8 z@w9$l=hV9obt3B@-p#t%yrf7h!G(=qZq2({r(4qJRl4o_@rb+h%^b_(CHeRFz1t_K z?56QK;ab1^?K?Y*pB@#DpW-*y>g3e$I8D&F`IlE$z3qNJ5sutlw)U6Y*{U`kNu_Qv zT`m*<&(F_Kmo!d0;mmKVvNme#q|^HQW1hzI%2)*K`}67a6g93k_B8h6%94MV&CZ*o zy>5q7)V3VW?RU#&@A-1cdllao-ffRf4dOJ~&$m8ax9?Zh&R45eKbaPt7x;FDS?;Vq zKOXmI`ih(tR#fqFIcD>D zu4ac(>MmI+m(2-#-tBxY2O4jjd~1oLY=*E>>OZRsYA!41)qac2ys=^7n+=EgJdZDq zt^aj-{*!mR-%n%Z7F%Kx04~M_IX`bHxS3TdKG)2d-R!;Q?9;;KMpN_GPMMtl#ck;U zz2*0oy*m*d8?&rv{uwLJvlG`=u9{&L?zQc7j;y-x$^6-;kG*RSxwK=gpGtpz<*!`L zn;+jCDYQ15#v5**q5FRMwll)Zz9cx*ecydwb*9YZz2EOe`&qqIxmo#q?%mv#XD;jQ zej}7oWnvKa_t)3p{rP__6BE+T&CwK(DQHwbY0EJ~X@*XWe=Pf=upQevOcX8emCfO_ z`@VE~oL0BEe%SF>RX&iC`2AU7pY!+i>MxkO7zEm{yZzwZ{Vl1hWiD>&KW?Jf_~&Wo zX4&S@hbz0U@ZFrX+;_X}V?(!>4o5N{uRXuQ`gP7%&Bs^%%WB>{Y2cSI`*P~v$h%9V zxHF>qj1OM9ZTWo8;%ga3mVZ7RemZ^spVUc;KR(a@zedU0!fa|~ljiT+_y0XJv@&qK zQFbeH`N{qN|K@*{a${3FY!b2Z%w#sTcT0=tXnRXpnabo>6lYj@js2`oG`xBgeL z64RAMuG}~CcE7z-bUT0l-d{GoQl>$r4<&Lg|1Q6sYyR^7{{30(uN+LcFZZTR;rV({ zL9Y0W;VM3twRK-t$M2la{(pP+^>fKj;3051d-C%g%Qz~1Cfmn9dUyELBi`F~FAe>U zZ?AL;`?up*^C(i!+{r-JZ&V=i+<)J@aXZ$*}(eW84=Y!na+d>1C z_VTN}+VlC`VEu}_T;p4s!~(`hZCEtmbQcRqEn`uyek{(qsrzPwb9ul+jp)XiD8 z)!XjW|NomM6)^pv;q=(DnZJ4$+n!k3Bzfug#XOx|mL)F&W}0SCTlISF_Ip7)y{(K` z9~YlcY+un*;ancG$+AMca9%(VEzj>6=bMyXw^ z7b|=QEg!XfwPNv|7u-L7-~YdkXL6<7jK%);e?xq4zbsOiTG=Fcu<~#D(TC=L4lwgq zoa_9SaI`q4KYQKIV|V!s=C*yCv7fW%-S_+T_l1xC`ntY;EsxkQb_oOHqSg%Io@F=1 zzNpWy|F?5a>9xptH7m?=Z|z8h#`XHMmv6k}j!ABD`S8?j(Fga<`@gBQ&o6$xS?l{r zn>gmfrg_JlGXEYpUG(bg-zO`=`d4*?ezu+;-1*|Q@gC*w8z*nsq<*-wYf^l?hV*Sa zwW*)qmOjwirF*_7?EJRPvg+)0shqJRxC$HcCFUs}Y zmok@!0de2uKR-Jg{QTwJ-P^k=9)6zv*6ziE=1I4BAQ^uB*~_hW+jl*CaU%Oy#kapt zA1Rf~FABDwrt)cl$yL7m`ae@;sv_S{@fLqm=vKb7_JD-jk|LGp&n=}ha@^d5H$7+F z^l+E^l25CbUVHq1`$HmH+ye)6Iu4zY}@gN@ zv!5KFaeB)u@O$L5M+98931e>l@@#f~)tM-*&n=mDI;IJ$xwGvQ-+N4N{g`=w-`*&z z19PNY?8K+JIk>F!p020m)$;9^@I}E?r|1nlOxHI5dT#&!<-6PY`~O~vt^X1CZufh; z%zJxw&hq=BzWA`nyWQ{i6+N4o9(CIX+;^zVo_9@Tefj)o_Q%HmF1xCRqcXS42TyuX0Kg~M&UlWV`-JZ}6!*-$#oL%%0eC}rM+ z6yC!Nwlk|t_}$Om@h_qAtk3&x&7KE;d2ZV^;eX3Y*H|$@<28?4{^)MzlUS*wTapIqA``+(J-Slw#^Ez31LNKiPJ=+VklfRmNj0W476t`>mCm_mXM$YrY*Z+jwNBhlC4s{7c9> zJ;(0w7tv)`K3H#*6;7GA;gQ8O?hCgM7N56GU(2lBe|XMJHMOagR=-{>W|lwSw9?c# zQ>J|H!=Kd_=5epz)(I?hD&Z}E{PoSvVAZY4Kc!_`*GYSR`cbp(G2^Wt$L;?G+OA{c zd~EUj%gf85a-pkE=z*ISPu5nxx>w(moiSnllI_Vao96m;x-L2J+gMmo^K7-Tn2PV^ z(uzmAJF725XqVd=hkdhnT(N+r27cm&LR7r-Spg)y_vL z3EM|MnPyt~WhwvN$FI6_&IaGCELWR3XHx9>!1J3{&v{yN-0bm_n)%Uf{nIbiZ<+Rp zr)ZhUj?L?f;?kmy$tHQ!nep=Qu=cRmiEm4Gm{937<>VPdrWsP3XByPBN6fsfGy9ay z!3(|e_I8 zro_qRQmn7--WI>J&66yMd95B|a!M!N=;P+2o@w5*7cE|2edX@8FW(ks-CjLe=(Kvw z-Q*>=%s#AL`EJ|ZbNf#B#%-;C!&`3cFlqNZsXxAxci(yYJHBH7?xbH20*&vywVrdw z^=a|lPe*3IGn+iu@8zn#mEN07{b$dwIh(5GFo-Ah_nLVeB4mQ>~vO9^S8y^5cj-B*^pzIIkP^lb>t#W;HA#I|)+H|%ymA-X zJu~HfJ9wPp(>0sFDhU(Kc*K5!xX#_xCA>|+Y%YkgmzEmyq#YfavsTVeaQ9umHINx@R~ z_ifuxyLR(FdprBbtDC;NY+mQtKiPCIYj))elyH8U{^xSF!qiHpihlQf3*N4H z{p(ERH1WxI3FZ+=%0|Guos+_WP{bNvL_$DSV(W(3zA zUZ%V0^#1(f?Y4ibbPirK@wc9T%eU+Foa|>aoLp{wNnfeG$<+Vqx##x^-X1n=-u%0A z)5mL#uM!V#D|(t=BNH7`-2SFV_}w9G+gEKp%ikExO`FBY+v6c%z}=z1I#c~h_fr;w zxlj8u9>zpyF&tR3z)q!XcKP9@M*bDO$seRTZ>=~UBPw%X$DNwbXXm9~erVqJ{Nm5$ z!h!5QvePU+xbN8DpmuG|rBa1oUmmv0uhMk*Y<;*egZ-7`quqJ(7cTdJGO&|E{@KZK zhdH0ItFhf)HK(uqd9d)YLp>guQg=U0*?pg%XXef&2cL6?tN31@xh5iZk=5^J{Z7>t z`Hv0iU-I{D^jf3OI=R(m&0PPtQu>!YCH1b<*Ijqav-GijS5dU5r}$iGcJfY@9f$p= zXWsptTKTy7|Bm+?Tbkgqxfd@Q%6&W^9G;RrPatvCoShC{-!k=7 zEB@U%`hE587dEMLviFr<3DL~0=H}idwjPMX%J$ZIH$E^c%D>@D&R$W?ixCNF!zt-e6eJObH z_mIl>^`U?6XaD$psrUxV(Js?_$2T15ImawFIq{R?yGLEqUF$AB3|H&v?p4)J+Nn4F zzV)5EWuF)4YY1=s>$&|@oNv<3&2luGug~vkzSe)cQta}P zB9__m)uG#X<|{-WKRc!C<>w`@tbeclV-~eNZ*ACrw@)R@4$M(F&dzyh$8X>K<07K( zf-aXTUuXBc!EgU(Lsgy3wSK1%wlt3Zy2ebWbwWj*XI@(CC@&Cyvgh@>-B)(`{F0gR z&6&w!(jG%zcIIXdv1J?^52#+tGg|CgS1`B0=33gJSpg9q!V|R{G%X}HFP~TC1saHW z{fUR6oFggFN9Sb29<4hM+fPW^iQl@oA<_A2p3BMw?0>DdIl{7e(bFBT=Wm$R<7QqV z?zJcXM0JztB9m>&)BXmmIdyK%{xf&CUB9{U<<9z;uM=i59eMq&U%lt-3jb%TlHb*o zt=awdz%$!WrmE%Y_lveaob&78(P}e4pO=c0L~q|eX;|I4qA_!xz|UQe{s|U^o}YLs z=9N*YtKKuti_5i+&&k{KZeqle7yi>vw{7e9{<+(DN!CxPm0Eg}SN;E|F!fw{yyf@k ztqEyQr}M>Iu7CbFTuWSM`YF}>o==^g|G(Lt`g+RxB{g@J2mQO0qFJB5>hBy)hZZ(B zMd=B@n|I$8IQjF_Y5luzE7D}^g8vmiux`A0U~#NLYwI6nmlX*`0spRs$A9&?o#^PP zvgvTigPlbc6&ox0R8kk8+gbd4m6f{htR*}bA06$UtmJBzaB9OQi=MkZlExvQEPMFm zM4PQ9B;;A2W65>4tf}wV@bG47g`2`VlOyRz%NGmZ3WkI%Yx+DZ`>A^j)D}6}Ppx7% zwqfSCbY{4mv)|_O>>Ir?+PQfK4sSlakKmj3>`WhX>a|0n^KuhTc13J?JU@JSUsmJI z?xg-7ETf{fnj3Ob*1Bq%JR+bSXJ+$|dF79xoHG zpWNjfwJO&j`r7BKf*ltU3O$P>870I&zuPQd_rq~l+1pi0K?Z(5_Q&onJ5}?-=%vBy z)2uJfUWw6r62%T~=ZVazJoGriN0@2Dp2oFn(oVcMa%JYT53A)qPT9FEV@djdZ{c+9 z#VL8W-yP9A#}KJ`I!tu+N~^uHJ3VjDt5|(V=l+Jb!RH)4+y6}ZSpIIy{AF*Q*H@jB zH_2^JDcc+qJ7IGrlR*IY^wY=q{8y`T-`S&TbBL8YQR#l>^36*to?BGxUA&pM{9f+s zSdGnJW|ReW?*8`3vgjwnvOOD|z9+WJE?aiv!{X1=51QHBW9hfL7^wb4Co%EdhBgf? z$kf5Xnd&Z+&g{PWuk2{E=6|7MO;dgu8mPq|SN532c+ARgM$qj?JQsFcK5Jqdag8B0 z`d5$GxyB>;4bMNke7nNk{m7Xb4T(kh`?__b7idO&W)W!pxiDfIkF@?$_ietv_v}(B z%THrm@e&#J8$XtY(%kS2X7e zPS{q(EeP(xYgl|{iJLuVM{@3e?)Q_O^=cI+)YfumH8szD`_I+%wCrQ!@|Rji|K!&f zY=6Afivf}fmQ)^^Te9Qr{roN?#%zP!bIn^P zFLLRfq0Z|w<@4Wg@%7;+A3jx9JGtz4`{}lC{wI$~&+oY#YMwA9JDpPd+!k@2L)DIT0q$ zxyZ(CY%$Iq_O`jMK?j0M-cjpm!DGY4v{vFKCzxS{iQ zU^BQD;<{$T=(Cqs{`<@~b#CRWBQHI;_J8)TJ@5Q>Pkgh&^mB;90gg=JtJ`v;x5f#} zu{N)?@_CpzS8axxPI*AQ(2>88B;+_-o%d5$oXr27F;^eEZe8@f?)iOf_N_0L&Qtt; zp?mU^*E(XsDkl-MReKHmK`Sbi5*LC-q&Y#eX0z0V#K2L$pi*h!!pb2krIGV_OHzwT z_5}y2ADM3_I9G560bF<9%)cHP8N-t?r2X3?!+ zle@cXubxP|wBWeO`E5>+iN;rvPc&P91~TVN^eNt+=#Z^;Hp*nOp!S~ILhm^{;+|-- z3;TQySq+JSy+-~W1}yw;8yT<49x#ZoO0^B-l+Y<>=;f`*6H$&^Z{PJ$lEGF`;PMLB z*(+wM@8`3*&U4+iuD7d7qD?bzp4)?n(_e#2kM)0^v8iWWW!tlp8M8B=B)yfNcwupI zk5$;-@3a0MD7$6|YTs!D)}NUyRS_gD#$!?CP#AyQcgj7n>bKo7D;SOJJdU#8ym25+ z%IH1AN;bn}h90AdM*d>5DrY8F_9k6FmpqT>@WOTTS8~Vd(GSP^4>i>!b-&M~&@03xQ zYTn;zcG19}(W>Br+9BbYjLQ-YOMJ!bxezLmGc?oXdt>2>v7#LZohrYYAA8&;oSji4D~rXG@R z9usYL9A!HcQG3?S@AKc#_p*0lVuD%!Jnzu^nLF>&>jTMlkJLP4s$_y@3FW5}H;BwC<@vn+b)2yS5mX@Z@ z;}QkOAgDO%+*Rph!p6&uU`NwV`pj1R!_|n z;58~(e$>ds|JByac|6IJJ46!Wxjrvh9K@!1cJg_J%2P`-%J(6bD@-)?Pup^Avu>VO zjoOhOrAnU8^>Z?FjEwy^dIT?u;dPk@3hO1;K*98JwtDV@PqEIYLS`&;XWT4gu6K$h z^RdwFJX8OVD<FIujfjDS1}-Bv}|`;3Q$q@5f(DH@OsyDO_hJXU;LXB5+b9gY_>TD8m2WRQb=ZeAp-^hmdKI;Vst0BECQ?*IS* literal 0 HcmV?d00001 diff --git a/.github/assets/screenshot-states.png b/.github/assets/screenshot-states.png new file mode 100644 index 0000000000000000000000000000000000000000..15b527661a43c0bc68298634263e5c0b0c403d68 GIT binary patch literal 117735 zcmeAS@N?(olHy`uVBq!ia0y~yU^Ql7U~=VPV_;xl6iS{8qKbpuofy`glX=O&z`&C3 z=G+H+wm2LayHZe}8%H zum85*^Ov8#T)TX$uI^f!tJAL8t-ZUXbYWDlc9(>!SYUvNz|kEEM_2<58#*{xSPsb@ z5>imqzN!9NCFQ>Jw+S0mgoS?mIsg4mwR(BlyOe1@hf+S({{Ch0eO~puozL&hw4C$v zN%gs(A*H3J9`idb&L?e*&=KoCilFLN`#U!sD?bDl^n_u1JIc$*JzHXh~yKp0(!#qv9|9Wjd<5T3iQZZGcO|m=S_w!fl zo$vS=@fcq#+`cwNd2-^6jXYZ$@2pReXp@u)|LZk9O|3V4`GvB7-2#UVl0~!ruaGS< zt7iu})!^OMsU~M8NlL!>`{KKEjA3yr9`&Hz==6M8|HZ`&#*HNoV4NP4*Vr?ag$wx-)p+q;kHvzX2?<<0XBIQO3ut>WQ7Un5gI zKc-{4^Zf(U{L2($KKkgEJ9r|CC(bDuUC%xx%YqeA_BmWvy#}nSA>6DJAY#w$16Cmo8nB zIpb4xZfoh+8{(A$-Sg@NgO}TP?OtYQvf^EJ0o%NqG)}YpyN$s|m%eycmS?fM1n54#@kAgdWGk18t=&L-iNc6x%mIYgD zJ=)UZ>*b}avIM7YX7?_$Yy5rfUW;|uU&-=4?_<8N`)MtfQ!O%oNsdG2W6duyU;atP z2!+0XvA1W+wz@T1dpmyK6gzW5CZ>O;PQ}(0;_Ont0&4#WRo%SEQ?X+2<2u#D24^1d zTw7m~nRdyLuP|LAZH@Cqt#h}$^3)~U5=CORPHlFyf0w!G`=(7sb1aM5-oDL!kzl!E zukVVzvTBo09y{~q?cR<{n~K)_{gCWapVD`A&7SgG%0>SdO7`E(*d;J?ZuxzAozJ!! zpZ4r~WV+%hvy2zOYmB%fI*~Z!UZC*HvhT$<(pl43&KG zPW1aD#lMTrowc1_@S#M0-r4u2CVF3IWp8@WnZx}oCGx|H8_PS+_G{@aTCrMQ%*=#Y z4pLg2O8}P^ZHW?TANn=FFu_m#&(5^PG_Y?{=ls)YQncKCj+{hlM`= zyH);{OY)?^odup8b`k##ujSv!S$y@idE&v<+6JZ1gq7AOW%$jtV?I4?f9skzF+b;3 zwk!>IOYu_vJk9dbf;}o%N**shvHN45X0Kk}Nf2>i0E2rRUxGW+0X`zcEw(AG2d(-!iqiyKS;Gi?TP_ z%yOTsWE8DCT_Ey)`|_e#>6s~UcGK^7ho2F%nV)~~@=mXoXOC}+t7R@l2eS9Ru0Utix#XH1MfMs%Gxk&~UhdDhG&)24;h zY)TeiW@~f$*oirY>2iI?k99u3rF1TP^D0-3)!R7pkEA|an416iT|3*k*-JhAU%;+vg?R*&*{+?EH1EQ=FaX!$)+iqqlzHr?9C+OluERPHxjaN@qF_wH!4lQ(zO#~mk|DsPJA ztlQOb`R=>8xstsF;6l>$+^tg&4UIn^E~qVFiMkTov9{wup@ey#iQMG0HSLkIdxNSq zSRAi+eBqodu;(04_oOuwB)bYv`@ZU1V90dvS8ZG348a!bxjcsr)_vTzHpTd|?Zla$ z>u1fpnY>X!RhQXcCnCpG#gpI0M0mCRjfP{((sr)e8TU0e{ex#F^SraG4lJH8bL7hM z>5ta2%Wa!y{rcnb4aMi{zPHc5{NdB#&K)+TidFe@T8quk9*SJ3x3Fur+);OP+m1+c z|D&(X{hj~$_MdQVc(G&RuA@Jv=gnu|vy(9+D^%aWVsEWjO30ief7bFpF5A8EX?fdU z(amOeyT5*Xzi`b?iMird7p~T_mA*CYICZmk!d`#R#h2~8ioY%Uy}>+tcl6=2nJyJK zcX~$z)?7Te=Sp_iG*`V}pNkUL%f#)|INxOM|21i;MZRm{dJ)N4CU52}GV3@0deF(u zP&#o3r{y6DpM@ES7l9`e@ci(q$I$l_2bU>(mLiF{+KJ_JU-#eY!q|hR& zRjHDbX2kQ@y3_GK^No8+izXo@#k07oSl_o zLCen^nRh!qIs8I$hd`913HSFqKMnrXr5n5xSg+{V-}S}v(~ou5&l0}drzfAcm)rX4 z#uWF!+Rr{Qv%U&Vn6WZ0pF5YO411;Cok7#W$|m5WZ}phE?&>3)xAt!>s1(ehQxVEAM%Cn&Dz;a51&xSY-6e zYU9oKdp0b+_+k~`@keX7W-XdKQ*!OBnNz;^3Ajuv*JOVqdF0HNC+m-gNM5vL?dD&3 zUGQhKxZbfToR1CuO$%5PZz?K$y!Y^tofB6~ZOhbMG~MUmkHW`>sh(T(il%@<9#II) zp84+Ny)!nUL0Y}uXFHZWeA;hkCOrP@=Lkm z>y6>}4)}6Udo=(1xg+-PImD_s`0vW~tlfUj;d`t>bv_s-9xbndZ*cWn6rXZh#M z>`Q*;{3>)#@cmlJ>{D~yQzxC=xoL@byZ`sZ{8;(2Yu_Gr+*o$BZkkxchpdH0XXg0$ zcAZ=D!q-aw$Q|u)*K{Uf$@o?K%>Khab{|@E_{_ygO<%d2 z3?}-Vj7{x3eCBHLnn`JDGZ!rMP~_jTU+m$*&w{?UQ_@zkt*X2$@_Jk5QlXDGOEdPF zuFY}YkSn5b*r11d$7N6ViYj2dVVmm)uL^$}eaOTpA5^t4dc3)-Q1%;dQ&$ASty*)4dzO49?=ZA$%YeMav)P-F~nCcK+8UOenyr_s;vcJik8rHU=)hM93^ep-sH(R_b& z<1#y!D{(F*i-l(H^foow9uj**dU@5yNf!TqGI3Aqek*=;lUPUb_iNu*h9@%dB4VJ} z`0|#TJ?Cn8oOam6iqG`CIMw@cj<5Hk{j3+g@5-dz6Y9KqI_Qdv)HS{wcG#wwFXz2^e&&ox>f?XyQ#wxlmRs`h zsp5$S^&rbB{S~Xo*@q9w$OeJN@ z%cmw6EYgU|5ns4FymM2=o16pcE2RyxKi4i=v;TyVy~V~LIq9B5>%1doyv|6u#Pw8K zOYhW9efyRBG{iYx?W&O6`*-@p@@GQdu6%u%(417%w(4`nq-V3H7-=l}JUPqEm{}AN z#L>o=CpArIy|{r-N}vCDj+9@X@tHeKQ>HJ|Qd@2z=i+>6ZE>34v96p`iyxgiBcoze zY+$_k^@XG}_6wwD$m*Udm26AAgIXEhbWcl5NqxMJJ^jSPr$rCv-Ti&!W0|z_J? zo&J68p2}i_&ePNN6*vB{5PLU6RoQI%yP545n7u#zc~=>+r&urS_`cSzXF{1ZVrEYs zPd(v!ttjK%*{g>6CyUq?FZe7p@61M1V|nAq4eK5)>N&LJ#p#}JbtT{V*ZAGjzqk9* zI`13t_uH9yyKerh*_T_QKGkvcEaOJb@7j`Wi7}7Yto^cbW~L{X;|IOkEsG@+555k( zmT#S|HgiUByjP>^pYUzov1e=UxUlH@OBy(FE>ByQckWDflfa_c#>EE4#R=!m6wa{7 zO>ix3OOz;o0BV}{WbetV(wQ53_`jcxZu#Aup7^$1e)ao4Zog*- ziazJ8KYM0&n(l$va@!vEcU2tU@BQQm`}a9e)#&rp(pRi(>We?Ayo>x?E^Ykz?Thdy z8@;Fh-e_33wX|^l^D_I!@BRB5CFSq8F8KbxNqxI*&zxC2&c>5ZE!=4uoA_>zN_)%e z-V?`t#cBfT1Xp`iKmFJ}U2pO7|3(qVojVCA0XjpFemdQ?dv9DO6;B6)SgxPb7PBmTV`23@1 z*$Fw5RhbK2jW)lF*}cc9eA&&i8^Ot2lB0fdZQ{9Gx#`U^xjQR!SD*NtDr>SO`_{DQ zdZ_iCozdkfbG4sukJ+KZ(s@$T?C|N#-49EY`;H%bxU%*9$7I@|Dh zg3&w=&5ed8&yS@%URZuD`B$?4!^QoFua+NY&8*yT=i847XU<5L+crFZzl;0w`x*~J z6(AygyG8r>8w*e*LeJm-GGUBDTj@ zZhBXo(_1ykMSI5j^*b~c8+5+53g; zQ3H=>6!vc3HtDQSTcVAdv9Rdl3-`{vans6urMP&ak87sIE!pdRD|c6&=#kmcwpKAK zza}f~S;NUyQq!+(%8ZZqKhNtszsySWQ;d20`ramGgInoemUjGT{GH)=XP0QliSik` zl^r*xYR}KqDEauu?hw=Gd!iBYi~E=)SZ7S@K0mQ8h52xg0<8UJY0S;l{o&B*^@*pq zahRBHkMKQn>{yzJm}v6(eatidpAowA`M}{zC$5xyKV1Cb!Rq%3&-yaX*iLL#5AvS+ z;>?$#Gq=Rs)x*{t^DA5zog#kl(}70|XU^pJIey9g-SIEga$#rASU+RTE?*%oAyd3} z#s6t9{m!28^~_$A_H6TRxyhdsf>PAp+HZX;xIFFIV)^^cwli<1ST+Zi{+krj8?Zo2jYsYMazlzq4mf zr&QFOl&1G9=x0Qu|?H_^uJnIh2 zc0aG=;&&f)bgbueY_Zw=>)nn8Yow+!ryvta8=D z*le55xr;LeZhXbcwtUxGp?tZjz;!9_GScD>85U0g^$L>A;%@vFnI>By=3Qp1_x-;0 zHz}{#ewH(4Iwsa1-E93_Y4fEO;mO|B?^EBb+N*Kad#|MD@7JfRpB5Bt(iJrhHq_0W z=VN57X}fNz{P~+RXBr>fnKD6a{$7>!2U+Cyy$)ZM_RPDmuhvVk`}S*>83M12KVP1` z$>yzoTw>eX{uexlU%(?H!)?K*tU{|ZG5Ik;afct?jhi*|mg$;_)3YpBK9ZO_SxR|b z=o0Pda=VA}Y0L8T9x@!hcIJyn`B@+9wzFqs6mOhKVPeULHNtLQPIfHxoN1zOp7)PM z`+juqaxpu7Ums`Y%MM!}oZq|o`Qv}8rw#s|HLb|`TbAQ<%IWpbysvi)9iK}^^yN6e zdGx#BJh%R%cT-gM1ckPRuRg_ob@EczAGiPgH8T4A?m^@XvwFREd+XW4&h3()`+7d} zDy=KrJm+q{(~~)MxBKPm|C+rXQ9GV*UGe-v(KgQ&f~jYhPY4le{t8{m42+-xtVpA&zCkUR<06no%^?F<9z4k zzn@$vwA!}um6DI_^d*ry*FIZx^~n{N;(~XpUQaI0HfrpA|7+^1nU+uLzpvji)ACe; z@#I7|mh@cJ*)w?#SDa}OT*32S&OmdnOWLH19xqcgZb?tin>BOG+L`@rZcD=Y_Dstv zx-}&?+FW|^72lfcFGY5k7*F*}_)sPs6vmo-iRGf_Tmw$W(~mDCeHJe(RowdeSof!6 z$(LdkJGO+mo3ff8QLMSA(sO;?_4}-scio(^Mt9#f6Q)9Glg}HuCl~x7gR+IjT%|7q3p<>#^(_d0KqJjj> zs*TiwZho9?RQO`U(Ng7yz$l7_6wmq-L~Yvo4N7}kJc>jkH-!Q+}T>S=>qQz>3C^`&gPh7?{?YQ%(G4Kn3N$ne<|zUOD|$( zT-WCH^Qt#m9=DT+y% z6E4XM{9XSu`gBUY|I6(iCaX)-?y^3W$&L@X-o9bhH1#y^$Dqalc*MZaENzhqf3LE!=;I*I%bzB9uIQgolA0EE zdQA_ zY&;jX_QjFj`2sDXo?BnMTH$)fd-Bqk66KA3T61-^l3%UzSfbKdbR_qB-tuz~Q(9y+ zoVzuy@5$b%6K?!-7vCANRXo;Ptd&D=OR}hatO^m^uRZ5 z;(qR}GZ!t^usSjM`i!Ie;oUpyoJH8zdJQ|7kavv3Bk^#-8=Be_4H*YrJ-e%|VXSA$H7~+Rans zIiqUzk0`dxd+%NNsQ60jv`-!_cNSFJe?DCBcZVj2{`8%ymmcg>XPqe%4=;T&*#i}8Uqn&a?(Wj~J)$P%YP#<}&&WJ5<4cH%$=|Do;#9VNt2VHiA=&$5 zUHr>^GcEtkQfN8#FG|-Zd&U1U+e4p@7IO1?ivMMusIJns?P{G{y{?kW|JGk&npc(f zMm8+cFYSKXEQQG2>yDg6qc{UVYj^0(8b{T7KfYG-5lC-2D5 z-dU{tbc*x2U%5Pof5<@#QG>ikyZx3snBJ3FDmlli{oSw7Uq4Un60r4ua?xsa?8OKj ztH!Eg;lF7ue;r?QXw8kDZS>1^=as@cDc16VH`_Fs)#huI@7qz<@ht7s-;W%gLEm!& z)|qEiw#^N`ooK0B_4e3VX^8_}|D zSxn%(=f$q`UcYq8c_?VJw^r?4obAmEYh-*xCVIL(>)Ya+@ycfHv{!a}7oAd#oh}e| zt6TkzlF41cnF|)W?zOmhrCqPIP(iKWW9)*5giuT&? zWpA=ayUOs`?eFPNr++hk%Wvi_LGKd&y0#^@F{kG)%|GkY z)$~c>R_CH6HnPt&KYp;{Ic%`+0I1k`!yu{8u*4n~Nb7sqXgIiOxXSn~jx$@jR#MrpC@4Wk#+WoQD4~AJiC@YOixc!%PpU4+x_E6O3R*4QgT%#>!Jf^O?iIM zJ?-($rTU5Q|7pz0%h{xP-B^!NJiFoAc9+9Ny&3EOaclh8a$!G@-sk_!UdI@}8aHrj z7TwrY9&@bnbfn7kIS0Idi#0vFP@?WNS?u>hOQY#;YM9tJl`q_Rx~?hz`_77&Mq1~; zCKq3IwV8fjyS+zRa(dN8f#O-lpZ{L!_Q-vGJkreQI18xh_g7>wYr~cf$E#X9^yyS-Ynx?U`#%_s$1%HBY9g9X1GOxL<0;>L#lz{ierCbg!pty{`Em zZ5z)r8(tsBOckrR>-&r|Jh={5SYC-SWZt?z#QMRJ1)Fuxe~i;noyc+Z>_dq?Hh$*+ zC+VN{xy`q-d~R^G@#PK0zAxsrTFVx&zghSCTq)b$^t;v9CT)@Xyw!5mnoD!l?Zv%B zn)ex>H@Nozl6i{K(z3pM`?hGs4iWkG{G*e^gs zwN@i)@*@3{50>mSnJ z?u#au4ljFeu;9VoP3fuSeQl0a_2OsPoz{L1iF&$Z{|mqPQ``2hRQl~!_E-1s-|1_@ zmpL0edo%J-I%N>|F3<^N>VKYujjIb6~7-fAw>;=XCTWo~o3dOcN7b2yfC z-;ICh8GGfM)QqN0#>;s(OCDHgyn^pMpQV6q+|{2`dJ0zCJg%PjC7SJ7L5#%Pq!~MF z+b^z*Ic$@a@@$jH&gb05!JRX%oY!16X)n_)CGU472V^(wUnhM%@LXZfy({&8OAObZ zTbbG0RJ!AMRLgxw=d+8Su6|r#*j6Z?59w%JoHO%Vz^|X&;sFVIQ!Y-E&*eG3>gwwc z?|x^uUVE^21OJDIZN4|QnhVWbCbOmU)34vXhu*gzNWIwpUi5usdP~Otd91IRU4x6w z1B%-(%T?&TU$|S%cyu{;p+dU3nMzCI)s5FTetKcFM|LmI zd)eqd{>S$Y-#hd6#L9C=W`2JA$@qo$i7PqpL&L24vtAtTn`PG$9y=v)_WX{wwO?y3 zZeHuZ#XbGP<@%Z8za^7@tc$mdNn3VneuR|n^BpBGvQPc%d~C_p_i}4=v%$MI0n7hO zugOcU0mtdW|6V8T`eN(7>;D&7bG-h{`_6Oj{mEy2+CIw9**f*G_{_*Fz1EkjHt24W zUiqs;bZ@8UvSt5bQYZ4IUVV6MS$9xa@uVKl->;8wN1ooU5RwypTHIt=>20Z%JckY9 z9_;p8eqhDo)aJQ?%afARPFaescVF>W=&_0B&NnJjGn#JIbTpqUT$vDM7w1=JckkpP zQ?7aEW_og8^Eo)hJXc(J)7uSNU+3+LHBVoh*naVyE%>{p-b+#=TZg_`d5*C|&mb_CHCZo_6Sna*S)55s$IehuHm> zj;AgA^gX^aM&uL!qWq-q+u4l%iCih3Gjry(O+Bw)yp(xuXK1|j=hLr$eI7Mkt2cXR zZTZDj_E$I0VIKB#w@xLR8f!WQt*esB%vY0IQ}HIDVpXNfEEDsmh7;2g(%Qr{*_OH3 zE;BdydQ9!GLC^Ld>n>|fkX3kWX*_j>m-S-5qsPvOS^VFkY}O?OsHi#qO(Tre1w{^tbZ7^0gN8lw-cjHXTi~|SC|9WkmpJbB0Y(h^{$>}p8KKouzKe}*f;2d{PG2yTPdEe-> zF|VKd&gjpmoRt-2HDhL9>Ph=!LG`MhU&L3R?T9fAotc-h|W>7d-0TkM4I1IsTlXp zXG?0&?zkekY}Un-20X?rpf2r-W3v~gJU=OGJbA-{oN$h&b<+ChW$R-NocO1l?3fSG!|{X9({UVg*%5*?mfBM*yG8UdJLAxY|!7$deM8ChjYOn zFRxM)npIyC%B7T98nAZX-&lj2 zd+$G+5l}dHWygx(O&V8}{^-dio#T;(ls>|{Tr{XvgQ2Z~Rh5z)N( zO5Wz`PGwc`Qvw@JJYJ@s*cYs!b%@nm7H*7F;=X@wafF+f)Bd61rUSeClKD^3Pn44P@Nrh8N8Quy$>Z`;un`Pm(<>Lb^oU5T>-P@72E6B zbGOd+@8Gm~qU58=G21ZC{L1{qJ;!f@21&mkNmDz_!q#{Zybvr7R20dBmO}_Du=p(h z>?El)J^OGUZ=1*arHQt}f?I6PS>0*lOnCC@#bd+FIEzy?yJr{}yFsV@KN=Lbaq=)N ziw7l^9*b=4n`b~PDumya81Wq5aSYi$)Nxt~^M@#7`!;9hBr-9-1Fd8OP5Oe{DLu1M z=f=&9CDjZbz(>YGo#DefILocT^A3 z^Y6YWlaY`Fg5Q{rb3lm*zna1nP)OtVc}|**{BmmP1TO~ue&%aEaS!tul zYRxY*wQc^zj~DgKj3v{4ym-5}lTdF&4l zzR3^436O;eVs8HIodVbITeI)q_0uuurl@Fv;4Dy3>}%iW?HyMgFEz7%&wE3in%t>X zDL*96)jhRt)%@}LqHkR1xm1a^kKzdD7L-{@9+UlKu79A3mHWf7jcTSx=c&ouuZj6H zpVMSk-(JQ0zggG+{{Q=RZuHBwGiOem+<)c{)6Uvm8wKlYA8`K3d=-B{6pcIdm@RZ%1ahjo|pOa?OOZCU%%zqeOlCJ&*XV* z{p+{>!=L71$&=2VQ2AT?YqCZ8KPEAqYWH{S_M3Ucf7fJZ@VW0Vs{3X5=j92p`5mW{ zB-(afLJIm9B}P5BpX6`pJe8f5C)IIHIajSb*x2~<#z61*4S{cXT}q7>_y0MY|Da;? zF69N^6$L#lG4fr~mdxpKZ6lYc9Tj_v66V27$VB zd3?R1H=4=lzN(qLIMOCv?eGsaBcuBw8yt*%O8nfSu6OqYnt8P zm(wrm-_neK_`2>XSFzPP`#+ujuBk>mhhse9vD&%bdM?jn!?VXfw)Iw;CPY2W54(Kg zjLPERH=1eFlUi;{C!KlWSec)hyf`UrQsLVVa@x1OSe9FqvEI+lzwK3i*2l=mzVAlA z{mV1#d)Mu*ZELptc)a@gi7ma>O1!6Kd>@_gY5T|<{axnJ`nUgnIJ!MspEtAb{(aNa zv(m2C78l+ZKL6OMyg%yDg%7Fwjg~HD+;6`nH)6`AI}74k6D87~KnMAG9;@wpQ)1M! zJc3tOseZA^TDD6j$-&0PC%=UBWvZ#op1D~3b|&kmje)af_PsY;=<+)5=J#8-w_TDo zp6s(HRJz>4)X3D+`SY~)59cGh3*PTpI^+AJ*%89~gr5b^JA3?PKR7Kq`_?Mk-P-5B zg+Kq{y`TCIuDOdpx;1;Q#LQXmW_SN~jfhhKjo8yVkxR(eXOM8~lcIH*%_u$I)Z|^-;`TN{Cy(XSt6&Ei&zwjk= z|D~E`ve#W*c~-xW0%b@h$Y6-Kj8n+tb3)=xum1})W!!q{@A#s}L;aY0!pxZ-oLh}Q z>3CFTY8^VAxm)aEu(Lq9qg+A%F%B0CQ?_>b`!i<#`d(2oW9GBa?u`{EYx>{U1{|rD zeDg8={MGNxXU|ML5)N9)F~i~5?QKN{#*=yWgx~*vqO8wndXOD zGp@HNd~(omdZc4yRk%&6H{)b{|H|t%%xRLVxLkZXXL>t;<#GPwm`llr{C$GAS3cW8#vFJ`3J- zydB#SC($Gl$ap;6l0mXza=u{c;!h>#OJA{V4*I?;qvcNG^t2Cua_2W@c}P#aF0t0; za(0tJ{V|c*=?PJKuQbldoA=#I7e9ah0xn_n0rQ zQdoM%`cQ(B^RFh(#8<{L6>+O3zAo^&v%QAR>C3)L#&Zl$KHMz4r@(NTk3{>&?H$=2 zOu6&#GU#6P+Sgfr#{I+EzWefTYYYEJc09g(?eH4&TSwCi{z+}VW3qncntjVtIp$02 z{nOvK-TQg+#;UzH*{>|mh+r3=?<#uv)6rA%_8P0A|H@sEcTJIg^vYIRvF0jhIVxw$ zo?Pv@GiPqeIR84^Oj4Rl@5}Sw&lhdlb!^$5i+X)$QY7wQzHuq*MjOwn-_IVMOnqOG zoAGN^yrs4!~n#)h$ zuXnB3P{Dkp?6h8>+|$?U$?vO5ce73`p8B|CZ^G3Ut=j@U8=Q8oRFb-8eN8(*=46%6 z#QY}y^~ot|lcsL9;h3B+*>UR1{p`2b63*DW+&*{4r%|Ka@TuVWl8v=<*Pbt8_$8n_ z?c9WSQIG5%P19-%46o!mQ=+)*{>pRn_6PZS@GOlK*dDNFd+)KQFE0m8>r7Am%=6`c z#mA+wQOg&vjMNb-OTI zbH&>%$H--zTOWzMJ)K?R^M78H)LDa2?Xb90-lvL}e*f_+Rr7_=`DHoHX&lmj53jF} z)8zQueE0YA>Uf(^&+t!*G8J|2+^Yt(9 zF}Eh^ML$1O%&e?)yUnnqb+^I%##NHTIzNkzO^=ZYJ;Nx#_ub5F8mRQf3#-pmz^^;l~-0J}kISN+_j;<+blhPLZRQ7dFhC)_&vMYRymQ*qdBAGZcgTxNt!b@nX+uopV{JBtejf1_Uk>~ zzMFfN%W85=ojALMcWd9RTFx);=O4EJWA=Tmta;u1SiA1fM^oRmz7Tl&%D$LMQg^0g z-v#Hb9dS}@lfV63{5qE(^iqjGn=@Q(=Ksw0>4=tND^8Sf;+r|ezN^D|-M!5d z4UloC=04R<>niSDOUvP?TK!~BkV@+G8y%N=KJiTl zjSoD0cxmbBil2MGw&ivi2i`8Ztz2yIeyQk-cOTEld_NJn%Cr$cTuk`=f7Lz@K z^BYzx^DF4sI?L7DC^GgRIsW+93Jn_x;bza@%2!wK?EX>v;LI5l2}O_n;%k(5zA&Fy zx+O~E+IlGo)fGKyELr@T$Dgj)R};yk^z7{MM>~H7CZ}@ni|yBbA=BkDd*R=*#2Fhq ze)OH;aP(^bk#{a&vC-Pa@?RI5l->`0{k}%jx8~CSQ8lCbZbVz5)-TL zJ<@j;ycFuaU6Xqw+r7J|MEbx1pJJ(p=2KqZNc?{6_MO#sMxVn>vo;2FN68%6qs6jW zydzz8eexF__6zkJ&SW;qj*@rvUTg~M$R;jwxaB8o^FCm3rVF}OA zH~Pl2+5OS^(D);LZd~U4OD`>+F8)&@XZ-3#=4*$W`;Hl=OGMp>_w%6X7H}70(TJC)R==}GyyRYo!Py8j^yhbl) zx&8hB7G`g|OiY;C7xlJZDmZ*uUOMaZ!;-_rZO))}IFJ07xm6F}Zf(o$o_ONK8h?&s zPkzfEZc5d*l`49-y6)+by%T>-y}-P&u;NdJPMTczLEGa=hW2)h!TmBvCvD-mBVEk2 zzpv-e+ecs9cYja5)>-kV>OSM1Df2q-cU`x4lRKi!*;%2aD0%<(UjFkc^N++#{B9R` zPo7Wa$p5ZSw$6X-0-6Qbmi$RGlv2$2o9l8RU+L@jPRPRJ)Copb-hQ5<>t?8YiGI;% z@bPqGoiVGnqsFely9V`Gx#_ps57=7I#hqY5n%&s}8h zERZay<9)`s#QexXpU-zoqxjCq&9D#Xzbl<@Z2vSU_ngnH>4Al7)bARyUVr^r_-Epb zHQvUv^n;xLAC%fL?NQ6+tvWLkIfS0~?N8m9@kUhmLIs~kb9zF+y@cX4A(Nb{Jv}>0 zKK_||r+eefm%G3FHXmA_`+uRavQCBVsj8!v-din=ohLf&7c8w0<%yc7#2fIs*xbS` z?QYe_o@0|E!^Ky+9E&p+?S8&8KI3lf%lrEq)6EY&C{E+sqyNsH`{T^niT`5-1JWPz zm9DVe8-H!;&)%~>k3lK&igR&sP_z4tY5sBygRbte0e1c%)1}=*a?hh9K zXW+Zfld^Zsj45v`A5~}=av`UxbY?XM8}!9t&Ge?TrlI`2Nw?|g)e>wG==G?wuyHozpFa8WU=OI1&-nOY##>L+s zO57>>FZ0Cq8$(`ILf@R(=T3MSTr~coq7P$VV^N{StjXx%5{I3(QlrWdK zYwHiX{4uRaYo3qSL6O2Y=1TXcELJzj3s;e|lWX^^UR<#M#{Eh28mGJLoB!Uf;ikIK z$GLMSMhMLBdEZ&H`jSz>T8-K39!jR9Wwmh_ewckfqh-O__pBdhd)&UZJbL@)!vcF^ zfA77a>dx`Xz53Q4vz2`AAEY+_=zaLOHC8e$B&h8}G&|GM9`{VXyJxP{Pd|BI;+2MX zdhSz$88dkf8#H!;3V_FEvS|v5%Y+ImA31(h>Z|eCUyt%Ea7IxVa zdv^59vy&HP?_ce{r}^CSq7^n;(@)8B?#b2unwI8Ssrt|Iv*aE7T(gYOB4y@-9>4pJ zSk0*4H_3kHOwedto4Aq5{K~Jghs%z6@ONx`)#pCnF}eG9z0Q}={h#^+kALjG%Wjw$ zE>fo#=efnU?m1)6{f+X%M^=~l{9iTq^WhE;AAz45f77j}ewp9>C31p835WCoR zWz+Q=OebZikeEGxl-F&}egv(`x z)-O5yNot;YNtpZ$2<#Qy9|iIA_`^P7Epl4oq}T08S)&J?v?voNA%~($u7s4WngMrAwOA(%KUK3C%JNo?>~v?wrqiw@54a@M(Ohm1J6gmzid9+%g@8 zD(kC95}Qsfe|_QWi(O|rf_GFdpYz%1-^=NVFGK!KvW*G&St7ejF1CEfhiz-Om>YYR z^778u_CVG7v%}?He%sEyhb1PiJURc2PgSb% z@7G)V7u#vZ`btW^s49uw^{?PTZggf!+N7-4(gvmXxZBhYJz;@ma|8jXK9H-OgvP_#|3eTRUU(m#>XxFZS)+VfjPTgg-G#ApV>7d;5u<@^3B7 zkj6hC!^}sf&-H8DShP*(#?RtZKJWU5SB_s7RvFqY-@J)!_WFO_a}HmUUr-fLyCQDi zq1)RgojG&tnA?;oQx-UzzWuUl?P~WAhf*(j?D+YR)!^w&*-t!b%E=dmc}r(lN=|V5 z`YCMIOwaSVUslA|iM@LD>cWK!4}`B?zkWJAetPAvFPVpv?{%HZ$;>pYP}31|Jb(Pf z%}V8}d%nKCi{8|=XIHM~zI%7Z*E>ZQ*=*H%&wP3n)m-y8Z}y6PZ^Dn6Zh6>#JGsp@ z#O$!anW={m0ln$+Nduv-29?JK-*rt7`Y-quu-C%r^6~%{70LFQ+SgWT{$sq_{_5#! z4&VISUPeqO9F!)nTegfXG&GdYDm12vf7_cQA6rrmKRy={7S$EJyz9c9h;}jkuYcOx zKit{XoVWb;DyijNT!Dd+i4pexA(4@q@xqTDFa7D9m6x||;MkUOeu0{^)&|`iKSZAA5B2oLKDM z-*vuBm1q9K_C@di99*F>Wr~P&-1+FM*RGv3(=fI^|G~o6x_q%y?cMb?c8ewKZ-~7P z4g{60;X>)TPe06_x$EkQKXa$WE}ZAfr}OUYcJ0MC*Xk{e?mW9CCQ|m)dxHu#v(*=S zJ$*Q4i(77ZB`$36W7p-A21Op*_q(Lra{jvLm!VyATgh*4mu0pg`m5%H)T&olq~@ln>r zX*JO`-+R}Y#_amP@As3h>sw}e-YK?WzyDw5ocf|opg|&&bGJ?z=BK^#JtSyq{C4`* z=|4JWW=5E+ZUr^Q zrpnu4lV(N+#`UeQd8&Qs%g>iTgqD~@7*d}{N~H~=jPs)-u4wXQMU2g{*}7oT1H17?z;cy)P}r!8HfCjKVBvOPvHKK zW7j`!U0)}9|Iab=4{P)5g#TL?F1m2FZhdF?f2N(5YE!08>-fGW$K%%KYV|t1hv`3# zp0Dp)v&QGJ&Ab(>7RjtWd?=-I|I9DhDyqk%pM7?@8Y*g0^kl=H{Kd~7mhbwNRTEq@ zZ{NSI>pRciu6S&7SfR=;Q&T-KIJQ-*E9qO%)zGOOy_S9Uf9}8MS^qdYaBuym$@4qR zULW7__}lH&>awM0&aAsyDNH)? zCTfR7uv*XIAJHzec1?+RtEifL$254OUA&5!Tf#r7%{FOq#*wCnPYKwW$NY((GH1VB z@cM;+963&haILfzX;PS}c1k1U^0#xxRd!t~{VW)%K6%Y`sru7NF0Xr3jqXm_FQnZT ze0rV0nRuhnmH%T*H@$0;e7)RPq*5*E72D>wUKx+>t=pfyD(V~8(>m6^>znKK)LKrx z=DInfEpNumM|U<@Px-~Nc2o4swfn^`dM``(v*Y6o6Mdnrf0cS)Rh$+n%ne-k?#C3> zi_G86lPBq&U6<$fJmN~aQKHbZ^Sovz#?8xkZI0in`)J43e$eOZ#w7|VZAJc4*S?n> z649{>(Z2kpZN=AnU%xMsnIutf_Qo!r>GpSXp4VG{2Ziq_E{(~*!*`|s-r}PXHK!{g z|8K}SwQ^7X@*KPPW_#we5(7~B>j~QA8EIQ%;_!9JFQYh61sMb7@v(wRC*$nw?2lX5 z*Ybu>UOBOZXXne~6P_=4ET?JyR^pR!hP9j2%+#Npda7s7nA=`nn0&D`Vg@&T&&(1BsUWDX? zrIeiEo;p{{VdsR4hU-=4CH~ahJmvf=&G(VFuiJ~~M*lw(bMl3a|LF~r!^}TMDZR3K zd42z}>HEH@%2>;!`i8GtC_Z;}-s#6tm9Mt>&icD=o9}w35}%muK{?^m#CT()cK-VG zb^WcaYfCTfUp1-p($d>=tS4-bx$-;Xi(<_)M7DqnY@3uDH}M9GFnc_iEibcfMsBPo z$6jTgG$TE6w*}MUrn6}uOY>F{^KQNj9`U=RYIag0T1@j(=|Y{OJz`pmtz?3iYDTnu zEkEP#;>o@1)fo>>LG>5wLpRACT$Q*gQF^9i&jg8G9jt}iQ%gS2QfN`@-11NFh;D38 zhSeIyce$smmhM?ML%`>5RCbN+;^&W2Q_{k}B(dzB*>r22*p$>+0ot)Dy~>ipPwx#XA^IioSwFuSlpA0Q)>TeJDY3SlFz2W8Q120`n51wCG6;!+OUeEC_TS^n& zhSxRzT2f+qbU#G&-lN>;mFC9vKlj_WTWYhVR(|wSS=yhHV$wII=hfr2r#=Z6+GMf+ z`+NU?tlj$cbzfIk{5n~gv1aw5m#>OUu7v#9>&I<+EsQTRjR z;%$pquK)dJ{$sv=Ehp#3<2;{vr?)+}mC3tky!iQ-mHxG-^3V7@a&eyR9yn{t^mCwj zR`oesr`pM<-J2xTH{oWm!OLAYy;4%19WW?f%{WE2?ZMkFNwyNjqIF3RW=_+0D@n6( z;=ig?l_}v+}AyY)Tq~)y{c`iwlpR*>Xhj$UTwQ`HrWq37pxN zDx`LDh5O84r-`i(zvWL~*SBoytvO3t3?H=ofsOX8iMy0M$bEbb*b0p)I-A@Qrs9~*A$x9|V4{BKEIhfLk0)_KoA3DJ8*a)YI07u$fcUk4=nC3780 z77;!vB)&+(ekNPmvdJebEB!VkX_U%xO}$-clySkVNq)_j-PaEL9JgMyafXjd zx8%g#J}(v(ojxq+k|`p(Qz5AH!JGF^-LclWQFd3e&PdLTd}ex(<8+wcrjE%|CEP>{ z^W7%S8V?UJ1P*f(X_iB3pIO{zX@j(eJ3 zpWyS88lLXPg$&K-+b*9tvvBsz;42!*pRG(!-B>!)^25&}aOUwSwVKJW%z2BOe7)R@ zw;{riD7ZSmaQ3!CLTmm#s)(?fS}*L*4T_kn_oczhVZOL@&5WEezjxU#EtC1{9G~2q zo3SFzc(SR<#+fg7{<#ZUVGoMYtK_JT zSSpM1rlXxRQ|B57&omB_=hQrFT51>YT4o_r`)f&CaVeqt(-l4Op3kQD?aF95;c@cg z43lKrGg&jQ{fg-Kce(IMHF!NnYZpt-c@y?gia=uiwfH74kuAFl{e?T=DkA`EwK8 zPQ9Gv^(1AP)eIe-Y0Hz0FBgMW$nY4~fCAX=%$&rYjS8tZBjn@Go=@0vc&=ZX$AvkQ z{Aa?GG00}GPu!gVO3_9)NEay zX}eL~a`Q}z8^bK_knLJOX0*(AR5$=~ipUx!KbsZrD$YOdw||$AX7}^Sgvi?@XioGjnd=QJ!gE<)za$s*X4=GJA7UK zJ0WJ%=kw)@9z7^5J^y{ruUD(Dec!)wrKj&#-H`fS{Qi)&Qo@$AX0m^Kn`S)u>9kd+ zyEdj?c6)Po>*-a^zrQcqx2or?k3MK!mI0cr6}-G>|GlukGIwEOe%iX&-C?t4&Yj!W z&6Rs+N8sI0iY4+kS84(>GQO=kAG`G3&uRPi+0C)9mlIa=*-*zOSN+EDzVvmi68U+2 zrr)=%O)dE%}@QYd&oOhzWZz5@$g15y!iL~{qfD`?Ybwc z`zvW{bKCzs=`SECxbRtkMcJDfCU&)aciHdqKc2Fw)_ueKX_>F)Z+`%u}M2Or?aPrXLV=g_q*lM+vRIMIL>#^ zxP;=Y89(QBye*o&*f?@sN=jN={;e}<%ce_dr!AYPc`#{X#Jv;3@MR?@GhUY%)wz6G zYuBCT5t4uz*MD8nNiF}EDPq111U-2!^#8+1DOTM?K^3&fXp=Z|bdZo3q_Q3(hFW=I<%}3wLj=d-t=? z{9c9gzU5V!ZlwVM6Yj+CtJ!(yyC}%b^B&|zd#0uN`tr`6nRn9A$XNUB5|hw3y<%dg z3^zz9cmF*_{ztg9Y zlD6;1qwZJp`gbyYH=Y&o^JV_Z=zZz`|NRX!o_yxj>vg-&?fU=WF#qbEHQmd>!UOg*q*~E#*jf{*hPZGXyB4wv)dfKx~&*KjkpVvz>zU&M+ zDskRP^rU)3)Zo9EZd8kL`neU=c5P?RI7<2%PhQ};Lt zd-rY@-+iN?G&NVj>C>hyTC#-YYN)8jId6wb$3=}3j~mT0-n??%sS6p28{a%V14;@; zdqh4N`8`eFJo9BE&_xGqx3(J(GEn z*`B3KkA9f3?DO;Ur)|Eh@?ZYq&*t>=x0Y;-SaVwow56J4P2+2Ry;Pmj&Ci8Cd4rmN4Msk8k^+OqtkY0H$1)HC^>JGb*a zytA{I@3`jK9JAB*F@Ylc?D*$Khli`5y|8GJlI13sdcE$WTsoCa(DMl-%q4T@`W@@P z@w0e=t^Zxgr=SdE;CHj;Yy5>-@k`$I=7CbZ&B58*Ce{6zZ&xeD%gcM|%A1UA*Frsh z<8nQBGi}>8M@Y}?TYg>Un5=${lIv}G84vg1-x=TD8~L`iY+_BU^GEPGEi zv2qKDinh*}A+cra){{oDuGO0&)?8HeFMyw#rhM~d3RBOE=*ClfU!Buv2j>v4C%FeT zZkM~|sM0JtExk13_eAsWr;-gLpJwGf2l>O)%*-p;$kbHy zDhIdzL0+S>z%$`PS-_DU;gNBxgoOMtlO)& ztKxdLM#9W^?kPi~&jC;3UYm8B&H#Jh=9xV=uJ@}S=6jgow(`zxfwIlkY z^MZs$qRLp6O@i+(EN*sQ_{88wUXR0uyFc@szYA&vE<4QPYW!|@>a1PQEx*KB?K}SE zUZ;uI^h@`G!)FL+ePVxK^h0GQ>)rO{fy?e~nVORJ?c29gH7QI}ri!hEl_&g@W@e^? zs+%O^%iE=R51*<2{cB^EKUZGGuVm@9_vWkd`j;CRseDlqp2XvEYgva^*RdrUXRpWC z%ewc=OQu?tWAe%A1=_(7M?6j|6$qMCG!77js`1#y1`>C zZe;fP*shYaG+tig^$xY6GZ%s?v1|k5&s)Re-2>Nkp7r53kvel`-aR{=h~fyh#BKB6 zPV4f~oAf#Dk|D^?ZNVuyS)bFSWYafBEDlHaf&%+L4Lg^>SAEtzc@8tDt!7)=l@>Q^ z+G@3oWELk)seYgHvAc4OEKZ);;`Za_G-C}=v+Sm#s(+%05JPg`b`bK_pi6`$3NpS?Tp z8SAPGduyIvGBaV<37_W|>)Fs+5iDC8Dc@)_UXZ|#(~dEv?xi`HC8tN*wA_md5`-nRQkU5@+p zVuH!>Y3r;zzPzci*;lmwtYzb>gJ;gnG=Bc*YpVWl-;`$=74q)gGj~1=n08j6_|mc$ zIbT;~_waCvb(@-rh>EtV&#$@U#x1TlrM|@A)O#=F$lC=xOSsZL{pSIzIaY7Mm_S4Gbw4lAIe=9q7T3@*E2Hxp0FHB71^O0YW zEOEwXmHE~CmldD2fD1?a>Tf#d%1TeZz4b~i1u{jf=PJ>0f;GOpKUZd-Ocs>DtpTH#yWt?+9GBvwNcA!K97H`o6S13+VBg1gZ^d zv(wDJJ~($m?#4g0O$sKDZ0r`Fd}bm0+HKV?cavqY5+|e=ugUOSa%|4=xtZcuZI6HE zcp6i?tLSaxXBOAHeP0(;WnaHCH$PZ>;RU6y4YE_tKa=u16?|ddan_vdpvgJ!o-1v0 z%{sni5A&v-eGT(YJM4WkpL^=kIm_2u&rL4*?hBOnVw&l{W8uguDPbPKFoA_FzbctHM1se$*;}t{S}bLPq` z-j>VmZ>yj7?flsvH?1Cgt6r{u{^YjH&s`<1w10YWKitXpGLL`z_q++|Ck);hT>@2k zFAbP+kHzf1GUX!U)#RkF`IKj-+%ZJRl&3TOMDJtL|erl73soEKazCneQ&>XcV|jaycJ z{_#bw-5>tGufM-)|Np=5AK98qKUk3z72%=u#>SEerC&;I+l zalpQRbv3DJw``TS&TILmGd272*Ih^Vh4pRz?%n^fdbz}$Z_k6`7H4?;-u3FebANK} zlK(o=t*Y)e&sCi{^ULFSV}`EoPggCC_jaGrIoWOQ!!H9W;x-rWzItPedGxVG|Gu4Z zTXRdW*;4yk!Yl3DsVVVUVREllos#`jSELc?_QjX~?!y8d0n=G$&RqOp*C%HDdFom9 zoM)$1_6pTG*}jrec6M67XOGRAn4Lj>l0wqb-5)*_?3~aac<nVyldQiE-p>#X9HC9#EdtIl27Wcsh<`qEtyuV3ua zt6G!#>WtIvPc2(YYULW&pYeHJy5vgHoAVpZjbBP$Dmii<8!Z@k=@ z)imGv>ylreVrIrU7W=LHdrCiG{*-mgewc^&uUcwaQRaWEE;O5*zd#ce5mG4<8jT52I)x9_Zut<_ln?v~s0%&(g(-?LS=@jTW82kDGSz6ajLud=S=yE^&R=?dS6&*t8! zdVBw>?fBaAhndba7c;=Tg*Vf1P&zK`F- zVu7?p`2mK@)0gvBUVD|K>z`k8^Xk#4{MtIvOtz@Afp_;kEDqS98C`=@Rb?u7y=y@iXdS(_JR~zhl?q- zW?bJ_yUhsvw{g+Ml(b2I{??`#7dwdXdODvqHQuyjiHp>PTLr~kGnOPfn;L&Td`45( z+FJU&-S0J}`_6I-tDWcwSnDI!{WKobsR5nC(Uv%aA#I8E+%pmn4#?X^+^uO@SQYqJ zV*bX@=gX_M?)vHB@O9CzpG^6;yo`)Bl{T+m@BePPm^feNxibf*KQ{RMx4LZMzOJ)A zdowsTrQHfhi4VM}X=c(2iQmT=hQ`j@k3`O2yzIi-kFNr+OGpa2U79_y*)`40-TkzN zmysH~vGGe8t#&c-sI6I3FQ4!+@|rwp(i8ckQqt0uGc3EG9tXKo6}ozB&DtkrlRnPk z?mcjBKJ(7}m7H}TVCHEDSnPd3W5&kSF%DksNXTI%J-?cX0~MYk=hcJ79HK_&OxnUj;%A0KFBHu9S6^Wt{?{@7)jg<@Y+v@7G?JnrT`5%;(R$^8JVB*;eN~JF{ekkCAnzYbhcopgsiEwP`&C+ky=F z{;E1nMyv&LlWPAbw9xY_Xhls;E4V4Re%&gklqZZePqpI@=BxkZYK=S|DEDFYq-t$< zxcqOB>|dz_&j%FJ623WRH>t)1&4EQ zMqT=<#^LYWk1cx`ca=rCH`TcqQodf9J#+irGU=-7>c2)t#VaHvg>=NapZ0>Po|l)- ztXaR_y}W$8PD`Rh+8U>~3R~OV-Y(f+W;HXALwB3=>h}45A;NAPyX0c0i~p7k%-AR! zWxEHl;4EI}TXM^C`wJ&eERp|b;<91YDz4C}Q(t6cI~L~Iociomch1M3=ftEt7BhS| zwjK8f5qoNAWoBYIYo>ai?6hTFdoLP=ZT&R)afLxid+GgqEXorFw`G(*nPF_=K6$34 z)IImTGb{hf|5Fg(wlMMhm2JFj%X6PjJ$7P3!o~=leDLtq^t80>Z0%jUcBLqS@0vJw z&hMt2)Yi%WZoN!#(^Q?^;2X~tB*w6JZq_3M$ETM|L967_j%eQ5BMe%HeZ>B+k0d^wO%tcGtUthbQ?-bTrIZ|6@W%^~w|8MpkZ%HnCa1+nduq z)BM_$$0fN-7hW_9Gkx4U_eX*MoYMOzSd_1Z@=7G8s;crGXAWF^^30d--zV;?U(#~p z@eJd1zCKy&uD(9LogBQgXX=P`r|N)Hhl%ma^toXaQLotiL_m*e< z+Y+P2Gv=8oWa@qR_VEuGX4>7g zZ(j-CcYv10cU z^XAkwT6(6^Pg9wfiMhT}UHbgSlZ!5HH>z(Z2ZWtUTeg4Z%zyvvvMyVNbf>TKJ?o}j zep&18x@B3Pm#qE54(`Pr{p1F!G)gklCTU2mTzYi-XH7$%!#!fNX3w7MmOAOr*2^bZ z7-Z8@UwmhOH2<|Qe^>p6#aEc)oSp|;hR99zerjCj9G%!H^K0*?%lDnWeEGS;q9Vrj zk!*?GRA-Q9{&_q&%U@z-oY_(7>dLAY`|9}4b$(NxsK!k^apt1p@x*7<$_!}1-jYA z7nEF>IJ)NOL@d=g_xWNyLwkOhlD77Drni+|OP((6_KK@Qtkf^Dn|gTSj5=OdzqIwY z6vUrLe%-kmG_7K^X;aR9!7b0kPkNm8Ui4P+1?m#&0{5g_k3l|R$jDHWWDr?-vvQg2 z(MnFxsN7qFVxwTLB40i>?+0Da*h-ff&Gjl>lwq5G>G|dP`62rPYu%>bIP+!hmc(a; z#}_}9-`DwVTbNwhxulH|&EPqYyxB8VvKC}#XFt7DxJ9CEHQTI2iL@<_Y-(Hcubd04 zHji*wrUpt-j9iivQ-YT1GcL+!E&CTU^Yf>}=d}cQdeyMH{NpbzwOm^X35UZ z?5wOOb)~7ST)rDYVgA_Q^NQNtN6L1ZguUUNeqiD~k1fUqrE1I87Dg^vb)oacjI{?} z?pv4{;tqCTcyO)huiw8@qQk<%)RfQ0_GWxpwsy}Xl+qNE*mRV%NPu_Kyg{v_wEy%>?>}*dqhx4t)8fX2Jj4!Y3IdkT2@`*EBjE!WK3{8bM z3-n%1TiE32a=bO?jPKlqnWm<_Jid#qe(eV*fcLX4ueW@De*WaQo?BK@4}~)(&eRTH z2f7z&J~$!dfUZ}%Ch&CqTtBrB9yhNywFRC3H%s#*592wJ)z&@>1Txan^7Ge=1cnE< z9(p4T3W&dRW&DvNjO-6dJ1XvZEzk_T zo5e&EU#CDT~RS#G6Y>Nn1OX`Ok0|DH7^mi}svmqklUOFPSSH?++(<~bb0 z37-Dj6rpoX;OJbxv?Uf_9A))NzOAg53)myL!g9qsgGPsM)$)mV&Fec4e?K1@{D(ci zZb5L0n(X>DD<&LE%PzL{dwQfWWmzz&Ke#)){SM!o7d~@mW+ta(IiLEfwk$0njWc!8 zN;T&_%ea@W6uNEXE3;W@cGBrm$KIeh=QS>s%9y7A?eGG{^4E;2mR4opr{de!Trs}yW#G}XiB>X9jFsZz66 z2ZzjcDGuFJ;TyE=YDs3^sXZw*S<9ALw=D~l{TrW?_RH^o*5jy%q8uU zL2+B+jD?`Rgg4GSxq2p2Cu~7?xN-WQiSIW5wb-;T?(XLa$0j@6yZwsK_?G5<`P}3* zxw;31Upwj@au+SnRsH*7d54-%U|3X76J%(oxXY6UM}8G13#{sHS3y(Z?$u?=Amj3$PyRw@O#yp36%vQuM{?>bI=d4*$ zH{VHaZLca^K3S48>a~tX$A=={R3PghSAaLQz=Ey}CdIp=;*ddew|(;*rY zr+2uko#E=TXLhI2IIJHL5t)ZZ4ps-Ii-=+8^;u5m`z{-=tk8u=`W`K|bT z`ID{ekSu&`*G$i6$-b3_U7t2zj0jJiaXdCK@=H|NDV}cEnV+Zs>OYnB47B+}B5j35 zw)R~!V`*vWi+Aq4DSF4>mN>(q@z(BT%hYcElib=pYscl2JuFwB6`!!VwCr7F$5Q4F z?y&M14i8T-`!86#!sYH~`8_`y{nFA>Q&mr$KAn2I`d^N?*C!$HAC!Ll0GTiWKQa=om> zt-N#GPwg=Z3ZA3Q>uc&1EH$fy_qEpZR|Ump#_mgB{Jt0x-WqFdcaXC%-?{hPm3{eN zLsJ*ceLXc-a%=LJ_L&aG#xL*bh;i%1>^P9LF(R(>mO!pZ-)@j+jf^#oI(vLT4P}n4 z_06sszd*&P&Kb}2038UNN>VeO+J;IVpU@bPVX_sYJ_%{6k`w|OzF8eKed z=H!V<$(vY~ZCT;-_roU}jqbk_ey)(1Dekq*OL+1kz35eZi#PuX-4gcBG5c!W!o5zv zLtG!_+%z`oKNDRmBkuL`j_@KKsUpAPjI`6)67K0zS1hF?`qrJ1nK@B=Hv27uVl&W~gT6~{Cum?ScGgUjbQxaV z=N&$6iO00A{0p>xk;CT;s)-w2Up0zdnP;{BU8Uo%Vu$pZpaj|oO5Jt7DLG=|PZukE zwwOKBFV#Lhjn(gZSk&SpOkuxv8oh0w9=`l^;vcp5BYf5#hf!e5&rvr{m*!;o= z?1`frdwDG`Us?UGveA+!qa(O{hJ(TdC1Xvld0~vp-u~K^B{Ito)CKRGIMbcqy;3&T ztZK=8?aO9b!I3j<0^LG`rle+dwpAQ4Z~m?Pv7|PtqwXJvXg%AsN&hDw zXIo^p_{q+*R|}my7scf6*lA}KY=7krsH8PAjxSG3J2$KS_xrv23ChOFi})kA&(6}G zIDKyClP4}q=J`zO&Zzt)d+A?8fc};1*VS*Y@9N^p%-O}o_6ei%zI0|uXszvD@TGr_ zfithbdJ#U$GgwHXi>l+&i?)OQU4|#viUiC=B-VuR;_B7V;r3CvLQm}S^&5K z^=tZ_mySZRj}@Qs9PTN7vHtDiwUu&1y(|bz%%0K45(5a7{wp54B zr8vD$*7$Sh@%b)MK{XCDXNu%ThDUZSZs%XHa$l7~?7pCuv{sp_dDCV!U76gOk+-7I z+G+Bs1$#akx=)-sRW;mDU3qf*%*_!x(~7|pWB)|Ut0p{I->Z4p;EadyEvvac4+3)S z`g;zqeKl|TmyXYmz@dGj#!vs88heEzMR!}^z0(bo-N&5)ynmsRpg@Q^Aii7g>ESbSfdjxndR4( z*|%qzS$^7rSu;;N-**UHr+GlhSUYga^xivbOmdG+eiG_~%dNtP2qMaIkc!sYCq|8C7N;yK)70*NV0!?zm7 zb3?zkIb2G1DBry6a8KEdGcu=(OpF&_NqqJ#&0FDQ;Xa?qGHiO!Pp|26_b%NsM|*OZ z)^d&0=h^~3p1dSE{anZo%`M@1BG2ErWj~FJWSi>R&8f-FnE5%4Z^q11duAT{`lXyV zEp6Y%8dkrj>FiTx-i(QF{xX?!h1TMZsK+)cT^G)r^m2Erk_wtE{8Y*D-HcgN)B{b8 z|9|Y)EK)5lcQfnTR<{&9y9Hf@^+kWj)kaM1pa$XE?JKIC72fAA zFpRvL=w_Xh)|YBIYvx6E<-104ZkySRjWsuYin(bsN4Gj^u642aE~)7q{?q0bW~vw& zuQtVikF)DkwtMe;cS2VI+Ab6%qm*?d56T6 z@TrmKGuIf}h3BXJ`#k@@$b8N361ji9l%1Q7CT-jU4ebK-&}N(dOF#MiDXE`UDXWwl zAN60AP1|xf?ODXMb7!Wsc_qxAF^jGD%$j8<&O9z+l#iM@=h=ar_4jn_BC8&MD!lt` zG4t(|w6L5>&oAr@aq|xC(J(%E;LMW~KJgtshb9V1PC3peKVkhk-uhI&vv1CX%B-HwGil3L?OqdZg4JiK;hbLXk7%*=`F-Hxx!S=GGxv5wg5 zQ{WMBEHTpjY?j=S_ZdOo+fK|3cDR)UYKU%_X*unw#LStKXIiN}JG10yxrlRN!a1L# zXM85GsYp(gow_=8imGci(|oZ@JU+~Ge~bL`S|?Pw{iLPY`H(dNsWO3Q&$uj4OIvVm zrlnAtT5Xy)_vgSUlRsM8S^}qzUvz#qd*&&bSyMecyPTt5c^>Ib4{&;FxzbqHTItmH zbmPqn-|Ad*TlV;+X?x*u?)e@Uzp=Alx_G6|@2shXTU$$9%D-n%HCOMN+?Z)Ld*Zx# zeWkCjJv=c{*~8Co+OtRR?(Tk?#Rd~2Bcr0Ud;^TU zGvn*$T;k^9vN7_A)VX#7+++H(e#hnJB@Z*eDOLI0Ew4QFnGcs3{Ny>~WcT=p$%99g zrR|buH_Wtj(h@Mz{;F}_drHv>W0$F~PU+|tEU$4lF24VcW2fwmwq<)~&RlG4EL^zo zhu#;F`z_Z_*)I60e_Lz&!p;9oKfe4WuDG|e^6-JOi|GZ+KE=)q*JB9T%f5H{I>+j# zRe@=fH16jyE?|g1TeIV=PgjbL=9$C&>wYQj`#d+lzh~dy+?U3WgYY2K=Pa(3^h z2%oT>vu<000`VNEKdoH#=7wQqzPjI>0}Gwom;9VDdv^ER+uN;Y%oOpRIcZYUg$n^c zy|OYgCaAr5d3pI#Pfu>$qsNb*-ZV`wHp{pA|KIP04-PO&idOyjkhtgDt!#^`FDrU@ zmS*q&`E0gPer2WQwKb8=Teg_Y{++Nf;;=qwr0dfDM}GX1YVLvywy-%{r-pBu`{vco zj>%7RZ_J2kovy@fXnm(;x4p;FDc>g*Jhtxqgcv_DT->}PxAzfyY5%V0%tdkRvx2V5 z9=pHsORQ5`DEqc$S07w$cAc@?RQmfm@1yo%s>yK`MjPH4A2_DZU|pwFxAC8>M7lzaZftBdeNsQmF#~(i(gBf zx_HLN*XE(ym75vUv(hFljEG$@Yi5ecrL1S387r+6OG4Ip=<%LCC7ZGORu}i%@16cL z(o+8UzNkyx{UyF&Th6{Sm%~IA&n-S$&$npluN4~ld}l*XE_!34IC)3fx;<$NX3t!| z_t&ovGiJU@pM2&_Qn4OrPm1jQKhMk!3LjmKdkw05AMg8LTl4hv|K`HS`}$X}|Hr!S z-_i4Lzi|7^_+XrKZ$n(%ec{dXKTTQ~Ic2``yyN-2r{pg5*Z)cW^J10!kGV4|H?EiW zZ<9TAu_tKf{mOO6v(n1uyq%WG9^C!$%q`^?;<+CZKr6dcRaGC}OrL+WTYq0iczmts z&!0cTj3@U^o){6Rcx%;_kW9UxkkquaWj(jHm>X+0eSUDT`I5}loqeqWY+Bb&vxH zJ2!7DT5{XdNa_1?lZ1=wZ0>xkGm5*BKh5p<$xUV<+vd5ae|P;78)S76IzV-w`}dho zHdgabB_?04fBAN2%iq;mNBfmb4~k!rVdAn6D3_>zlJs9y#?<`ed$YfRhh{FB`LprG zDa*_6;+Z1#yW`iZW}Q@S-k!U(JWWlue3`{uzlcz~jxRsgJfC%Rnfv6x*y>>mZI?Gvwvd%?HhZ}1E4fBS5f@#x2Q&bt)-#Gq8`@UBUfzHM1)&sJW) z^6WH^kMZZ9>C;by?%!)_ESUO)fit-4|4%(yl3{eG_|KoEiE2LSDFAA9!Qo(JBGaj~CO?+3PVhdVs5%e~t^fv1r@n9|A zp6!deCx+czC-;%hYW}H(pH{?Z&Ht#siu=6sTZisl&y~+zXXZ4%Dtl~xhHd)6Uq`&| z?EAH(H*@9nFT9c!Rq5-5GG%A;%IszC*u~f}gJI2o^W@;kC11)G*oE!A^x;u%^vj(y zU!K_^q7|@Y#*~e#lI~2H7Ct#CD@=5*%bAI~8ChPg#ok;|QNeGgn5AspwoF;MRNqIP z|73>md89@cX~Bd-ddi%^}s%iv`}AFWkHJ z;;g(S#!k=AZB@>>T7Buv9l3ph=Q=Y>&OQ{AocC?@%-^$T-n@Lfoi&^9w8oilo9_lq zJn33=&&TQ+AEb~!vwU6^*ZY0H*WFQP4(eXDve5&ujMQJjBGU5?%XN4yX@_xXDNY!g7+$)%f7k0J6vi-mGS0%`{JH0H_FWTxiWY; zXyL-T382{{&;N_8Z9Sy=j)AW6yq@t)^{>RX>FaxZ({rB|Y@aM*x$~EcWmDMl6DPd7 zwtP_q5A|OzwFtUg@6ns%xV2K^+4d!_4OU%OWS@Bzdv5Wc?H49FYr2S|?8HFsV&~~H zMeio{IX$@I?r^5|K-+oA*|TzYW<7fdT6}*i`}EZ;htDouxKsM=)rXIQ-MSek&ipAJqxWZx)s`4j zBiA@9_sAdZi;Vocj4of@R4np2WY)}A0-qU7jNkL@;&!c6TYu)vuT@1?!lo(yJ7Sn~ zYlB?q(l2v*KHZ!>^Q^_?hjY)(f9-JN_v$5`J04HgJ)E4j%dnK$_x*$SY0LBtj8s7T zJ)}WBnoHl)($X}J^>VwpEZ+6rf&0<5wGJ-(T<@0mX8yG3;qDViH{v;bW0B<4FUmXC zuRnjQ?CZC0UKh=le*gYGy4N>Inb&p(_J2<@ zvMzo2oOtN=woBH=m#>{L={pj_d!|O}&|1w~e(p~THQ9@e#Y~q^no#gZI6`OVs+lvj zrz{V))7krKb(RmGR(4$VQ%`4O(@Jw6&30GYNdeQ+OaAwzr3Kb)Yd$GdFo|u^-+B5l~0!sZ+vi4{lk;VpF_@yzpQ?0Xz9P~-iay2saf3r145RW z9hvj&^p&<5?9*mSO8sK{_HL2ul2dz)j8~uT7L+e%D%@yj5jvs!F#EStTN*24WS^h; zvUKLo+Skt<+qd&Ce|6OMQt_v9f&{VNRjY~YsO6IC28Ty-t9gn zYrHu%+(3FBkMXe!vuC8l?2D~nye@0qfBvmq|9O`=$J5K_9q;F#cl@#B^S~?Dlt1$x zy&&__Po4Mgyx*Q?T(7LJPMO%a&i(q**T&&}r~VXsbZ3Qrui@b}?VrE!ok4h4|Kyy- zhM9@-vyPWVtmrN?G3CE!pu^2tc{{8sFU`u>s8nrPZ)K&+3j-}K9+#IvQ{40OwA#|% zu4KtfD~msItTpfGnKv&_PCJ&?@4BM>o9IN%%-!>s-(G&=Tw|^3`n>pAb5b(I6!glh zjV5QF*csTe?D3qLB9iGTTUe%fm0q0rZS}0|;`B=e#YQHNL!3XaxR98cW%u{X<)RbX znHLwOr_7q89Q^E}lF`eUzcDknasTJxG33`VE`E5()uQmthT~~!weMc7Iwc@nEW`P+ zaE0db@5^MiKKlA%A+u3bki701bL0B*yQLY~xh~PCGy7c+U)X(ai_)9DB&H}9$&=Jed>3FuI_R~rA z+${wUoh+v|2iApyDxRmJ2FAASpgRcPT$G+^se5XudnVl;OSh%pVK}+|EF4kRh;b$&{oObGiQv#)0P=42fKy6^6|-xkQ1`%IQ2)v z`J&-W_h+F#zPdr1cWKCRZj8QoX-?xgcyN8K7Q%Q&zXznFwL=cue@CFjOQ)COU9*2&6Cv$R-S^h zzI8L*bW5AG=-c~=ZXpi*#^GmuRIM&&yz}W=az%QU$rGDvSy^V`T5V-lS2`#C@9?l+ zCAZkpXljQ~X3DGf>9@~M&6G`&i%5I6YpeW%KOv3B)6%wTbo-oX*&&e`WVY(1@7Jpy z_A@n3U*Z3MZT8IlGfg-}m$t6Zv46Tzt0ild)`w@G0y35K_+#5m_ut0D>>C~)-n#hEnI)m8P3J|`&H!z|dAbd073}(k znVC*6b`)~lTe?|xwX$H*b(J3Rmyg$TyG3{{IU_u4rOdT6KEYlRP3xugPs#UtmbWVT zi7lLB?x&u8GghrdpW~3rOgq8J>FtWGcm3KVO=Ya^_D?@6zR_a)b;s_astp%P7>k3g zKiXHHlaiX@b}OhXZd>j>myAA_<(oHdX5W0+w+L}wjI#UhOzuVIJi#sVj=gmG=>KB% zD?a0(3nhH#-<;VzYldTX(1ZvpAHf~xUTCo|`e&7pvF>;Hf@ce=3SLy)4wITSb7vK^ ze_&>ulGoK0XM&7OKWA8nJz1*ZwXi8UV3DDzaImQXHy?*9*IV)5OKMjgFjYMSR@6>(k*5y>{*uS436Lc?NzRq`Ttw$H`$LE-uRO{?~v~#9l@gL!x z8Uc}w|12%|jQekG&(4`K)9m8R6oXgovnMaioD?|q(uFC{Qkt@|<^}ZlTFzglcJ;^_ zKaoY2M!{|_ipcV zy-X#$KNp%K{=S=S^mp#|xZ~0Jd(=+H{k>;h@%!vkrQDqs+k_I8a*s+U)gP5MdcUeX z>_P8!=cBK07u7$F&hL1<{$#FD?*8B3qEG#+&R=!>%Ih^1!ug%W*Z2L`bD9_5^#A94 z{U3X0R(?NieWB)=jo ztKP+r5ryKC)5_AGwS1gmQoi@h~kq*!Q|z_5}F!H z!TBlYZYDYT{x)#Cze_3cTEjxmRo2VoB%~k*vmH8L*L35BtVh?~G9d#WzER(ajdN5iMnlc@pg2}Dy_mj zN@BLQX>;u7_kY?X{Aixgg)39qHchHf4cffi;fbe9XsXh#>Q5JLW*&NZ`K3j*8T-_! zg^Og2jJel1pVOVDbVTOnGKuZ0X1=yKv&Ts)U{<2Y4khM5L*v}V##4_rh=8U9^?W^} zL_Ca5HG?MmcpI6U21@Blxm=u)>16VL+1`mOrQ`DEYEOP;xb5tjRhyDj)Q%_{{$uIl zF5-E40f6h$lEV;E_ zOZ@zTbwVdQ)eDRa`If(46&N!^zyE2f;5c}Vr6R7D)uxYCFI$jDSUrc zHzvOHdfmP2iOlSIZs~mG%O|&$@S7@aUcQ|D@^b& z#@I;nR*<3L>d)J}K5yEk;FMu4^*P?D_F#3A zUbI-(+WBubWL-Yin>TZ&iMv|uv!vwYiL;7?LYIE2nPby^%ggeNjjrao3uoq3e7$Nh zQ=t3Hl8p6JCfCWF*AF{$=I-wDM?UV|F%m2GuJ!B^6IaSC&befmyMOWCle_#Yx4O1! z%s;y?YFcS@d5pDNYD(H1na^p@K0ZFavF>lx9P4twZH?>qeB#Q=%HlbD=HQVuwQkWL zkfxHk@zOv`^ymMWZidXT}g^xrM zBd6{U@7#0dey7`K?Ti!k(pOod1A{lP`u_C381P}+oiLHUzNX(xZmyiOIec26;;B`7 zJ;yp<^iP~xCiAs4b;VjuSKrc|CMMZgT2J`PcI}@f<+>#{G*tEd@;yg%-o;N8DxI0X zS3Y&4ypefu!8@C?b7!u4c2UXrbK%DGIi|+E%Enhg3(ww8XbVi>H&%Df5pce2^~5G# zBV1~7Sn%XUdMCGBQCVzY${cKLn0fB_!Jab*42)Nw?%J4n*!{-F+|{A%-``JlTdZxo zdDR{X&rVK{p8m<&FM>lh1RDphShHyEyL?T6-UHA?l&AzGjhGqh zc0^n8J)V|5ZAqK{8J~l#MlO2udrw~P_?$WKtmMKD>3Q|i5^Wz9kEtzFyL#q~MfdFf zZBnzQuKUtIgYRa?ukM+Zdp3BklY7Vgg8i$ze65NvW7jbrKE7!$zf3Q(G7DKTFC;## zB4gdlgA>1gy_76t_^OXNa{UVHGX)c(?xmR;W%ixXw%T$>(mh|^z$EimqvC_y=$8p; zWtpq1wb>RZsb)9c`SV~Cc0r& zm(!Jy=^Rrv+tNx~_n0eR{A+S0?6j5jz0(aR4utUdZOotE-xD34rlx797B$P&tu5$5 z$Gdz3=gDo&tJ{_}uk`$!xvDrTYP%`3vHwZ!^OwbbU*y%@E;X|;F^x|xJx%Rwzo%Q} zqt3@3-oB1EHLLuZrDrag)+V*{l*QTEn)dS_RWCL-_WnFMU|HR^(?`>9=ZdN1n#o-rn3s z#>P+OpPCv^{3Eni)r~VVJ0Hk*&Rc!_1XZQ$H_ydS^QGj}vFEBxY(l zKk=Syn0F}2<+DL?+sEuXoo8yS7ISZx&owd8j?IjUS^cKqXYnC(IRo!=c zbaBacwL=dkimUrS<(t zvVWKKlELohzlMdDEv$`uakCmA}`xrKKOty>w*Biqo^2dNVXDq)J({mu5z$|8v;0F8HhBEiSpKl~eXaU&!3& zD>Zv6YxU9ZpA3Zh>smf7n!IA2it*-%3y8Vb$@nRTBhC18LM7CHb_s~dA93fqxRV|Dl5~LEnK+LHFK5o`L)N?W=^Zh zWLuV&lyz~&JBgVpi>>|CMY;gZ>Cq`t;`rsn(rN2XQX!Gga&6<>0>vp=7Im(}eh3oS1# zdiZvxWM*A{T3cRcv4OGk_G7EvW0O-nEQEdc3JM7+@p|UY*lG7D$k;gZ-Wg6|wFkfN z|KBGfIqgbWkln0h$!BMoZaVMxJV0vpOr5X<@I+VmoikI4<98XSFSAa2c7@~0s^8Pj zh|VZ_&9&r3`*i8Ei%%N#+&BC&XXdJzGb{Ou{Y)xM)1U2*?A;{zmEE}bYiCi-jDUsj zczL@{Txpwnzu+)$m~ru^Cnv9c;gvR9v2$oPnR9ju^QIab8=uU2abw2JR|dtpO?n&JXHI^@>wEP|2(Pbm@a3$W zS7&_B9+;x0_SD$GklobC)9tJD-Al7)#{arBeM9l@uo;&o6)JtRFuD{J80E2S!^fR& zS$QiCeth9v)F?YSz|BkOQBvQ-`~TkFH~9D}Ug6=5Je8vXEMFQT-+L`(_!_r#6ag5PfKsWj%3 zx67GpT>SFV(ob=q`E#Smmoh#oc;)BiJv-qemdyi>`@<<|ey$aX;=3|Z3->PBn30|4 zRN@kT$6&*(O7F@!AG4n6o$*O^T#(Q*y~F5*llUV6Bga1-@e&Kyq}^NLn$~thY7Wog zAF9o3)AngBXXiFPpIjGYT(@oWy$c&kmgT>*Y`(nQzgvEq?Wgw7Q%#Il-_}#eDCIgA z{p*Uz`4Jp&X2j(-4{4--MWRX%Y$n{yFZVahltNPe6e4;;G)r_$EV+}=w`bl z#yGv=YE}Hjs&h%EM(<;%cfE_d>(%zp>y9&XTiU}Y(~HJ?aN*H?1K z4ew_O(*oFZ?!J8S+Sl3FRfNm9Y@v<+OyyucU40JswLg`wi0{)sd(x^{Lg)ET5b)c^eaf8no?uCJeMXLx-U3W%%_IYwPw7Dp z@6In3Gc0w|*335x+<7j}G~Ju)P@4ZSmx*S1b238GKZ9-uo3(($cd_F0Ewg?q1)Ga4 zDO327AZfRM?a>Q6OfH?8qBX~|*lk<>m%}%OYwo^WaiL8s;8!6_3C-aRQ?$=t?L%4Rkdx;U7Bqi+?k|OeE!m8-ESw)EIDz;Yuz(e&JF@@vBoSSFGG5ou&8gtVgNo)D>EbtN7E>o-N+0EBjpk zNtfB2XG~uWjDk&jxt3mhlA*RSVxC8STHR;q_`~9M|0`p@9+p=!)s*Yqy6*XjD@$th zPA{GL^Vz2W$)}!e%6scRcE@j-H8cL#Bk_XI_y2dj3eGfJw$%7!;>%}~D<1B2T{K7k z#OHP;t!AE!^Z$K0{^RTVbsxR<@A_8%@#^|IN&9`T+LLdc0j&Tp2XAwDbk<2$DE^f8uKL-P5{CnJ+^FZ=zTQrAzxri6ukhenZrxc9pDaE5c_n6v zT3>bf{&CydlGL6)9W(q&Y(KuO=pEo9rQ z`3}>6s$CIXBgWPI^iKbg(2&oUJdVykbaT=ElY2_e-E2CUVQAl)_1Wx-*jrbpFIh8u z-2=jdA55FM^32T0?#Z4P7rk58K6CrlYRfY*ru|vknHpQxEmNy-{hxcbnh6ddXaOzl7u?dT;J*yZQe485u3)U0^})&%SiL zw84Ct?!+0jcSBl^PFc7#Ibp`nACeRO#6KrWv@P^glAL4`Gl$1ZYTBm@eIpry$8r0l zhlg4tHY6~v+yAd>&-Z)PuLAEoew8=9m{#ZEnI_J+Zmo0nT=`jTst+A*B|Vs9CT#jx z)BUXPwH0S_Uz%pE%3olX^X|^hPyC`Px4yX^**f*x9!BHOC)Q>y`dx85Y?qnwR7>O4 ztA#I=PZVP~)v{DwcG|m;FOfk3(L3W!jmsD8dLVp(>*-cj`I8=}e>H_Z;#!sY(r2$z z>whI>tEn%}^ao7dA30^K=+@TV6ACPNB-Ot!+_#Kj>s6C0F|*B!KUf-X_U$zLo~XZ* zVZu)XD{Eg3O-sFR+_`ap0uq&mP%ZO?`HpKXzxK#;M+;^XAGY{M_X)FfX9*#2(`t({C$Y z>8rKO?OXIE=G5X0|1jgYn7z!_N+-_z5jQiA)Db()TM0TZs4Cx&f0ElG!(7kE{-3W5 zJ{JnBDQ0A{oT|_=YfFrAkahhP=s$6quC8v;(<3`}Sjbowxt!Zr{NjS*kt0VAq!@V~ z4mLJ=$^Ln&nX&lWQ_fMTyLUa0ez)&;o=)T@mVWzxJ4`Gy4wXn{y~|$luSC8lJwoq` z^4|dMN986D@623ye!5l86*ZOAD4sT`8Hm;VOIZLkM z0b}KvbeSKQ8y04MT(kDmwzVa;XKehpJhr^_<@MC=|0xG9O=j~h=lZ3i9vHoo-*x8g zz=^wgO`rJ|t@DXmIcJg1?9<|1+5y|Iw{qFHvVD$zBon3bf;p0HYH0Wt?vw7<7kvt0 z%F-%+d*+N$W&XL6T?;QgH7K_7D^At=nbW^BIc*Z3>${01y-;j*!jgFHC2uYxL62&+3#kmOJ?6<>e0-oG%-upVQg%=Mndf zoqvUA6mMGc(rik`k&9Z@#=+4Mr>>tePJK}Dgzw|dvigVe^R@NmmKQxbTD?zfHvils zw{ItH&|Jo0Z9IFU_G;JA*^Re02Jd-yHt%?~VBw8!g+r_kt$5lA~GkiZYecr_SJF^Xw+1|~!3jO!H z;`CYb$LH(+8Q1-t?fK*9k<}j;&;KKF|L?u>k6-g^nBxWK+ea0oynXdOFfd4i_v}J9 z)jqYGGiLsOY;7EDe?2WZRF%Jb7%SWs2Z86EhqNq z?b-QM;#=zWr0_KEvh=Nq+dcQpn#CTc>1%3qxNQEB}W_VymT@_4o1^OK*<_G~!6=H1U}EiEjWS!d?iM*A|# z)%{4ES@!9Pr|Hr1fG?7F&relR-zNK7Y|4Y)-`pFXF69cDeSW3;pXSrk8&2&hw%GU5 zWYTw&#ectO%<0}_#nn+SVbIPvS2auNanG4me5+?lPJB3f;jgb>zyA39zCJ%WPO5X+ zj0Jm~pZR=jm^stH*|&s0|F+ll!)HEUxHzwI)-9JWu}fNudd{BdvWYrc)4l7*`St~t z2GU&j#li!4y;mMDz1`23GkNCB{Z8|op9e`^{TUsYe6P1o|$hS|8KU0jEs)E=AF9V zZ+Cn=CjGQL^-{jtL-AEJf6jeh%OC&u*Yywg<9{&yd%b^u!~44L)fHc#PEXmHeD=(e zpW*e|^UhA+_r-ONg~fDaZ~`TW@z zg_H5p!EK2W)h5B0J@y?4pL?$NWT;*A(L3`%CtL0R^He`~(dJvXZWVmJ8lHT#OEjzN z{es^Me(wx=a)B98I-e;%A1}uO7DAi<2`Qef+yc1VvO_aL%D8Bno#o?uK z3$%nh=CLo)7rgzuVRQJqz2DPcR4<%4^I4mo>D6j3ZtlXjTdzyan(nq}&(ft!U)^+0 zcwH`HT>SiMR#P>kUMybvFg$oM=z@2K()1hWW_lhz^Lg9pO1+aEIVSs+7VBin`8>Gn zv??=mN%4HA_bcC@7CoEUcXnCR^QU`er=(pIf9qOwhW&)iPRX-d)dSP)dVVC&o-lQf zyl=0jhkxIZg)iP}m@QwlP>*|KlJ&D2ekNC@ELf=MYHB>!J^fl&^&-VDrCT}8+|*9q zwKV?z?v~>AS|03~D_4|-J-_GCu zxPAZqj@Rq|E7yIU9sl_G{vA@`e_tH`@vVGc`~RP(!av5(|8Mi}bMgP)|9{T^KXiTn zKi)Stci#O_)u?4;{JPaHYP(qe{-3-4^hiFH?lLhpe#kX@<`$!>!aSL8@9uQ;viq-^ zIAiS%sqmiiaTHc;+eZ#7rnRqRp9%?~cd`~G}7{o})7{;PW#udR(X*I6xhx%R@} zlC80Olzx498N_Zp*@gS{oYyZV7YA*-@@>KM82hSfy@mVpUmP==>i9Ws-s>G2i*=;6 zcc+?U6jrHkT69}r_fwN^>()44T9-B@we;bkNc(x>m!mTpX9PEG=xDZc@df*4?NsWzwxs&3bN?MVi)ZU*O3GJXIcOrKt;C%7`dM$qtCh=_oH(-N zdgbS5k2mi0PdK)C^M?mt67JNV54lry`|XTB+oeCYzy9A6?>no7(_Y>9vwGLepHJ66 z+W+tO=O5ea|NGax+i%x@zUJBH9Z#3d4vaS37S^@=d`PP2LX%^eqMwf4@;Y2|@6`M= zXM%UCZ?JyEeDute!0EX+lCzH9I>D1;71v^W^X;WGcT$Dc&s^K#vC{d4`^2J*pX+pH zMNbB;A2|9c1T+seWp8jgV!On?S0(58%olF@x@VX9u7$rB{I=Y-@nP4CuPa&G(w@CK z-YvLyA>&2)zaP73iEhU9aJ%R5VHchzjvARL{@|1_KIE@>RSf2X$-O2N znj`)-yzt%LM=^?(@03q{{2{6IU)XDkhUBUsf2~YS|JcH_cW3GLo_Q&4bfx`?bjCgV zvs;!t+_ZA#%AhT4mj=ttpSbJ7`!i=gKQAtD(_>gNYo^JSJ=VUa#>!W(o>2~%yWe-6 zOWf@Jj#87qvc6w@?+>4qbY^MU(LF5x)Jpbmiv6XZq0cd`^3|C{C;0{25}32N4juho zW&Nt_o1*GB<~grz8t0txpD<$&b2OivhU= z{l~TK`-|k9Q+wA0S*L`9$`}cj`otgUCFez%Y}h`s&Wi)fWQ4 zH|&tQE9|@Xqxpj?%L{gWFa@m?*6wDjZP?6OKRq=da;q!*l9`qJv?ViFMsIlQVWL$0 zdgJjc9d{#ryBHT`1^XyQMH!c*J$o>5=910nn=js-tR9z{xY%ZP@;g4z$&5*7X3dn; z^o-b(p}5R&yh-tLAa)Wmf%U*;;b%IO3J*BthkJ#+0;O^-W!T}}z_$<@9qndTSRv*p(IImVlh zt1LM)`{wo8jj&0BcXA=3Z`?``@|7ws@GaW0Gbv-8qqCwb%Y`I|l(c`W)4Aj#P8U;<3HC;_VB)|b@t3(%ibHwS%=o0ag#1SENwhl$?xPD zn_{;+lf%=}EV+t|jxU+$>DAYN^l`EL;k)Jcdw0Lv<-KpxyFH)%SngI%?>V8O{JHI9 z{qe8$e~jxs-@gB_zP?ho?$_q|$#?kI9ooL{Z)|4NzwgiYb4bPcxv6!Anf^Z{zQ3hc zz7~}5Lu*4Nxjy@>Sgqq)=*TCP-JU+<=TtLe@1tA49zG*`#%JN)o4GeC?)12>-`D-O za*MzOp{Xl7L8~8UJUj|MhvBe6&+H$oE?Z7Hac0({&nvVy&P`wPF<@m@(!670b1O6R z(_VcqwmWn9cYDUs&&C#;mWWuEtP@XrHns0eibVaBXXTTZ%-pGSYI~a6+JJRuC$EfO zdnfW&K&N)zMybu9pkFCb(+wH6V z?DZ;HW_73Lz=b~oGe5phIyl+?!AbS!D`%}gH}U7f3HMIi(K~fR_4F!TtDYw}crEJ9 zSTi-}KbZVt@s0V#QL|?5ZvFDCZF$hnKP5;asQp2YOYcZ z=ii+S4|Y>;|K$IB>P*kJ1*PZC*hO8O)0UFnd0pqr*R+MpJ{g>OSsge{yz|tbzbmwE zBxh$l%NJWOy>#WzFPHtJ!}rB)d68))AZ_q=)a_ zHvc}E@MmYvWF4CK=5p6c$7gPC#%4!ngc@&-Ff|U2oi)>SQB4%Xiz_P^n|)55bVoL= z?#F?BhSq2P+<2&Qr}+Eb8xa>8H_g5LVf+5IE>-TleLnMgwlB`UaIcc@?ak~DvtQRO zmzcSKMu)t<*MXBSmVY>Sa&pAqt4~j4=p8&}ZLh9ulz+E|%lD)H!~Oq%bN}f7|3~It z?Qh+~uh|y8`0PA?<)uS4mgQ+bH8-1_?)_AyefCU`{WkYV1`Yj-$5Lc8PqH=?`6WE> z+q#RjLF3jYUhAXTDRJuex-A;-&U{pzmv!`Zbbn%Y&B{-;;=jL~ng}X{4tK+k{Qela z{POIpXQZ`vZ_N6pIVGiJ;jJ3KRa@7&%Sm53qf#6sK3DVevuR$NgWO74qnsqq-l#EQKdbxe#F>NQ3_&XI zHkQgVvp+tRuj@~r zw!iV^`_zS6uWc>2)Eqj${#bwgr{JF72j>@jU%mRnxj!d=JnHVuI2DmNS36kPHTBY- znd|p|TlKJ%IehA;%_cF&cBG~KI-2#&a^sD?f0eUS)H1`vjK9sewV}%2$V^7J!E8%S zz8`y7UjN~^$wCuXK6!cqbbQ0ZBgh4iXLr*ao$TQ6cbdx(2QZ zUla8z@Kx)rs_?a;tJg+EXRh_l*6r$A)D(8qc+s`h9Wrj~3fMRl8}A%{w$J?evwiQG zIaq`|JidIM|K8Ym_RX_7-xOxQQM+FrKmXmEGjGnMOs<_%`?+?WJI8_ceO`grD%9t@ zOsSl6M&+e?OMLu1nN{aPQqz1zx;8ll1PZjhI&{~`DJXQQj>-0g>ofwU&2oy!o8lxo z!|RCER;kHq0=E?7ypONY<8iL_mC$ zpP2LZp!CxD+eK>KefduMtU9x1W#{pQ&O0+!_?K)n+RRltb*b}`i!PoOFZ}M5-_I{N z+-qET_s>_2JG;eiddnRvHILDM$``yThX4ak^@MK7QOO@;ts^;*2lP&uFG6 ze^QUk@cz@^dLktCS>|N5$9uPnznoWY)3&NQbIqAOYfKDhS1vR>t(%;7DPqo*voX_V zCSSiAqiuZkluuQR;57FC9ZZJZKflc>+gO+VEiT7r<>B4h<)@4;-%xCM<}z{S7vrzr zzAak5y#4UQf*7u?ihW!9qjI)R)iIWAOO#1jl4o^&$(1B$x4oav6uOvycD?GnYvyci zgSETcSKmEzbjOTSW>M*K(vx>hTdj1(bf%|0`(GQL#X%R#ICy{S950=lvBqbOnfHMz z{W|@5k@+I_hlTH_X=TeCzPjzw#Jp#}zojl*Xc%cF7itt_UcA$4^ZyLFS6Pb!r|jew z{rPZ(-jbG?t!ciA8zQr~5;a$udgh%n^!5qtmDJhWwrkaCrOmqxm`YU(J-T=&tkV>Z zl#Je}Ya$`d+ZvItF)^sCVTaM0e*NRej(D-W@pA|dm;3emYTmOvIpg4!$Cl(Byml#L zQOl~J_j^9`#r!?Q7xI3++l@oN3$C7De{5=aeCz6U|18)2un-QFod4j`>AJ4&+PbFC z=l63w_xo*l&g!>}Ve?N-`}p=t+dTe6i_ZMtQ3pclsQGORBHD8aK8Jw?|6Wz z+DV_}G_6;wPX|tzZ28D&a?qUl%`Y~rz4Y9B;>=|i#b-Usd)BC(I&AXnV1go0Hg#>Z8wVZ=U0Mb7JypWA&~xDHBgVEO1l0GR>lS?Y2p&YKIMa zntQJJre|G^+q3oZ5}RVay_^llCT(x>ee^tW#+O%novKy}^&jiLbN%k}oVgK4rf-{l zzx{+#@!BoSB?=d=^G{A?IMMG?s_3e1vhwv#CDY6z z1)DtH zUDK`Y+WsQ#-HbC!u3S}p@ZcbmiK*)1_2-LbFE%nxId1m(lZ*F{b#r&T{brx&oTK=XNekJeOz?u)E~1s8?7YHH{kEZAic>9uN$&RYFR*P?sRdOly0dwTyr4()P@BQv%~8%up` ztnZLepX{=%G-TyY6AR|-MO`jWpIuwCjZ@KhZ7WOr;fFh<(l=ZU+XT96t8l`^T`%8Q z6^ZaySAGu`{bl*$d7_%x@9HP}GehfED&Dje%zXdKbU(X%>vXN`x0g4b<=+=(H2K;| zq03%ze4qCu&(^OIJMC_$r^cDJcYW<7t#32x+hmWarLIlhaVyN~(%C;B9x|1bU$&A8 zpTx3o&cDU1D^_>9CZ<`O`n)(GR76*0$%Kin*Mf{)CQi$Gu~Wz3tWfYCH?f%|Yjz1S z3niOBxNtB=(fGRi5oL>f{%x0URGzA+%9=CxY>b~_pGUuY--S)t$!W*j+IHslIQ#qi z9Lc*=_B(gSiRVdk>L0T1c(923=;DZd0a4`#T`k@p7w^b>8q!;NY-W1mr6BH7}lZcFwoxk6*)gJ(pd-aNV=g(9~z{du!+PmFQG0iTv)}{i*Phr+VTu-s_9^ zBn0TJs=t4H)~ZD=B5AJ5riJW5yazQ-^tesUe4*l8a&FTemYkUXrq^1QCZ^Y^+uct( zu1-S*^Ix^X@tOIQibMouN0TU2W5KoE@1~WFgbnT*wxkt9sg}Em4A9 z^5pDuthc!)1a9`Pd^Y#O*URS}Q?9>z?DJra#|oov{nr6g7v0M~R@|{uy6EWn|G$^6 z{BdsL#>Ob-huhaOA3HkDD0te$Eh({ES8{m0EZt{k_u=%i+RWWs?mu$SY(9M{@S*zK z$BSFOZEY{xb7t3G<>Jek>(`dJfM(1eA7FI-_EmH0Ocmqb+=1c3%TJt<2oXJ&)uI-f z$>-%OF0CTob(g_SBhQuO@AKxoz35g-n@EDu#Me<#$Wfm;L==GpF`kh>z{` zqP{&f=S+O&YK14wvwW&^aqS$HrCU#IQ8mu=Ior#aU2@)9&bi*7*HrYxNe{=`P|Hu7 zjgLr7ull`9XwuA7waB!41$Rp|v^^&L+m^aVZm zuTs6_@0VhMz-4CVSor?xu6T6X>UQz0l6d1d;b^6_D?*P?u6pknvvSWlvsEP_txMQk z!%it(@=Cj8<$HALibrRrE!8O1|9*PfH$l-^vrhSJoF|rZ`LcNf`@iWr>*g+5vl*1D zjMAPZn14=kP1Bk>^Ka{lwVm#XX-5{E5t%rxd(l$&&@8Q_U=Q!yltxW0!>L(5p(&?~ zj0305Z2u%W)ARAmw+GFXPxd8GpSg4A!W-N5*?<1rz2tk>t_#+R##2w7S@UHI=k2pL z+Pae8|9{&)qv%_t#osf=OXhT*O!w#yTER7W*W_$fwNmZvVl%6ZCV!i2_SUAQ_00v_ z_Z2;Nj_wg+J?3K)5`JLHeB(vQX;G(rK7Nmx@}y?U^_BBECpNK&q`G_0ZD2}Gla{MivgO>~#PmOUv#7T7<&DbIj~#z=pv*AGg*_v!_-@Df zT6QzF(8r5ScW+QEu>E)c|8GG^L@2-_;>?bgJSEwk6HD4A?s>Un-AxrPJ{h@v-DQQv z;$5@&G|rodoD7Zs&U#ftJLOaJ79~yIg}JBLt~_`hy6oW0b4#!9PYGb@K37yX;oDZ{ zg|C+t7l;?PW!$Te_*`y2WBN7=i^*3kmaHuPU>m4CBX`o<^mVgDF1{67*X1c186dU& zXH;MROY0lDwim943r;_G@{B_ArYl2Y*z>N%=Q!tK2tX5>a!s8&+D4MY<~85!IzA^X9H(S?#bmpdw%?k6ZX`PYOkHeXFX@0fnr)5kPw;?Kpx{$e|SB*`yPSAQ|D z+-CXKg>1S(M;4ze3tv2S=H=!8MQ(4?aSnJ;w0v{z=^_^Py6&uCu2oZBoxAl&L}JnQ z#QJo(z79o=C+8>pxvAw4awO`C$L|wL!BxKd7(GI=Rofma( zpHtI3ZJy^G@WJZO{{P?qM}P}IW_aOujCIwXCHjZ21#XwsR<6DLbN>yl-J559|5~r^ z;^F#RF_ky4d3|VSfk z;i%35jsG#Td%e2%-|jqKz3}L>@5;;91xH%jWTh@E+;~eiW-h~tRY}3h8kzBt8D6i? z{J!}5ir%YpC(o#dr*>v9dv&JS{nym3eUG!EJ>PCEO@qdAzFzavrESUmu`?rE{{Aj5{83=XS0dS-Qy!9*wp^8aW~Oexnv=(by&r!$&8d2& z64<)Z%X`v9^?4;-u2)K{Zf`OAJSEuw@JIDO^6&OOm%9==4b*gKyLY}Qc;5_`6`QtX zxU?<{OS@Nf+Q;zo|Ao%eEGq)~_QWXImA~5dE-z}?#^TQtr^~9<-dlRU+320x$ulSJ zzFvBMlF`4M_(qP}e|<8Bih60xr_Lmos4ZHjA}YV{JDc3EyZImd@2h{@zTfs)(7c)? zF}dn5i9R!n)X?%@Px#`gGyeYl`)u3l_3Qmxre*#;aG)VFXV)o@`z4nwN;dEFb1r`$ z5E-z4Jv(bJyGxMv_ebmh&0KNw^4c8PBg;x;lq^LnZ>Amcj#uf-{pq;#MEH{Q6=^f? zw7a%05B;hhs~@|4^WztWkLT~bTkE0o{==`23GQ8g+WtN}Kk@D>AO1!y!>ieGGb7fn zDdh{l?Ul87>(rU-d?}Al2nq?hKD+Rv{Ow!GQz@23<|S1sJ0oLd*DD+M+o~96dk0Mv z*v#6_C#s~Y%JKW#Q)Ojkk(-i&&)mJL+zhf-1+_T5d~K{~m}q?KR#4%J+uP18$$MsL zX}vT#?O+Da#CzJ4wqCg!bMM`Y88hDsSDf+j+N3CE|NrOt))O{2SDgwv>#06pY1gdz zW|>B6^XuAnN=-hptjxa3@AyK!SJ7r{FXrX%aqF2G8Qb{gOUjk-sfzm6vblLu#-F!E zr$74eQ0V3Rn1Ak^zjiM_|K)(g$}4hHyw9&(U9oYK&+RAY1-Ej4iI^bYHfayb<}+*b zyqr>=s1@2&8tGaVNe8y9_^{PL@^PT%TXikVrRTrSm#gNO{v(EGez*RykH_WpS82zd z6}_yVKJ(z)k~v$aCaW3mx4zZPd{cU(`7_~(%r|ErwQ5V0aKGr~BQ$-!==TTD zUgXW$wO9H$d)edm=-q`s-{sj%zuG%#ultd@<4PuX3orjHtr6XKQIIWa=FG6I;{G+W zJGEZF@==uelCrEcRBhp#^()Wim_AY3a-wDZ%I=*L=3lZ46P@*_G|Xu7y6Nj?CrbR@ zvpsu*)#O#-wllY_Q?AJ9w9)?ie#!CLyS1mpo)>Igd*#%b?Dan_ySjw~!e?u&^%h&X z*3%_0QDu`+b71^@rCjf6eKS>Zy+uyXlstIC=T`40rk#t5mMoe&Q^n28ODQl;u=&&~ z3&nu7!I6^Pn=VOAnR;}=3I(3Kwv}l=7l}{Oacxb!u|IwuL&v<;MeUCwQqy>o79VQs zSll_S;p+WIn@;N=U%A}3sr2g8PW8Dxo|Dy*UtK9)vVUW7;kzxFGi)l2>b@+Vtn*D+ z>GPA|!hb)-AH8+W?|SUFr7K)fru&miYFcGs<83Wt&osU|vlkmVe9x}z@aj6WErWSm zi+WPBhfdkCqe{mmG;0~d?9DIjt7UagNo)Ob^SsBsPUS}tul5zaC>9kKWeCojvt|9# zGjpoGi6|;h|MTA4#objw$;QWPhjV9Fk3))!#lBxzoPO4&N&XC5^6%S~l$CWo{rmfS z_tU-A-@S4ZC9|@!3LYF_EGaKvymf16&ho!}vQ{m}j=7bTmUdphth{#lUn?uCBa5qQ z_}Y!@9`S6ub;@w!%)ha3Gw%Mfk=p-vv1`=!y$%1IPwnvMIc#uvf#}Ri+4jQNBZ`U! zxvfW@b#0QnxGr$&GHs{JvgUJ5o;^v{zV!9@(J3$9{^wgCRc^fem6xx1@#l)^T2-&7 zoH|qEJnQL?8LNxVUNR~Powak;Wo8Fl-496;iXS~BzoPJEMMMT zTU6Y8;_SHw#VYkzUYE~Y%2!!bW*nZX)_XKP(Dr7y!o1ZJ)nyfLDuvI=Ji27ZJhcuA}Z~`^w~-qjqB%6+54(46#ec_`}=R*ikh}Srmy`tsC-zgZ@m9MU#{bN9>JMex18ga zUyHvvGjT?zPsC}4d#18S5B=Gv7&)PwWxZ6jf%#vCWh}B+L}ye*#^2PvWfrbwlDl>4 z@mqe!M7AfsbNegV{8gPH%l(B`q{WJVyWH&~zU#J4jhb0`nAcpv++4h{uuwx^ z-`&%*^M{d7e~HYhC!PjJlr1eKB_$;<)oskk%5tg-I?{34r>pghO;Ws{)pVtowDd@F({jgM!I_?S&uDvbl|H@b(lvv@)63IQtKL;Ll*N95&eMfU&u8oQ(L zS?`XQ%VrlG{%W3ZtSA1#tnBro=hyFi7Imla_uC(To%tWOKA-P=U{?Ob`EwiFUVZ9R zH(VNVE-kL_5tH&A)7d+9j8hx^H}}8qF;-aefnnRzHxn-@s$IP`ZF9H{zfswN`tVs_ zBt6CF3%;te3rSn1q^l?)`&ROj;nu4&U0=NLa4(9PATaMyu}aFa)YN6#Mw6YGbQKj3 zE>2DRe6Kk|Zc^v;>8+0^Za3??@Tu#9&z85vIW^g~iVJ&nXL?#zd{9`lc=6HmV)F9+ z8#WjyYJL9vd1K}0XNpFX>-fLS3ySvtbou;w{?^vkq~F)pMt?l1KELDd>Q$?bD2LXG ziHmpVzTf*@Zr1GCy6bumKdcCwb1*F0@|Dr%yV-K~ z%3BMV74>VfOlRz~lf8Xqrei0+8 zu_toJv?JLKldspsW@c)Ozq8Sh3Rv~xi^xpR3n^CdF4f;!tV(KaUh*H_aqP8YYg*(E zm2E+huO%f;S)IA=W_hi1c1*7JR-@0qUauFA_gcMrbwG5qvR3Ykue;0ImDH`Jmo8n} z(bFTsGcRyH+rPwFY8t=xOba{t>8zKG@0S%t zmuJ3^O;>W2Gu|u{xzcmRS`Al6KeZ_L4OYb~&Mevgnd#-{_&(W*g*PHM$lu<4dxLGd z{AYMaTj*6x!B*wi^EYcJ2v7Ctg#S5~T; z>(rU)b4!oym^L#}^68HJgHeB8UrumI2~*g=Dzs(V|DPxMHtL!k|M$W1n$aPvFa6%@ z_kNSQSNA)&=EFgDC%aWflebMivS!^nG10Gqfq{!Y8AWHEe<{D}ON!ds<Bt zZ_eKrcC$JqAT=$2n|zwS*`n~se77k5^GvJyf2TbA@M7Neb6bthR5WNA<#zdKa(}9F z4SmL)IoHC`J>ph(z323j$+w@ElrCGlt!T+D&shpb?!9@I%o;m=yHCO)gLxj+;rjm` z%}*3-yRR16=V~3cB>eqWfti8JlGpDk+q!nkiZgruRps2?7Fu&N)Kx9?LCd>IscNAQ z0RP@xDA(@$4-iOSsCvVVQ&FZ+bS}^5Kv#eWWPM*h#1M4KD zg11G~r2XoP@?!{5T&kfiCb;|h;{%N`FN;^bO^fSTr>DH=kePzLwbL&h)y}`_O=oOw z**Az!Z&TFr{&)NZb9>*6)U@XZ4=3Gy_r++>CL1Q^AZZ(!DkrDe54DUl|D2vMK|oRZ z<=#bacbB~tTEB4HooQ#5kPJey{@f!pzP{# zNKW|A{27Y2^BW_*Q$F6@m@D{fubs5X+81r( zzctK9>trW#e|#|2ZpOa@GC%B8-kU$!^Y^n)15jwOg)!;s~2xNX1w6_!qA11McZHsx z{5@l=9=g{txXpUXzgBqHe`_%)n#(tS z^68$ZX54v=*}cW$%M>o(U3|ObcImYx_RT#rt5%F@qS{@{E4SC@$l7j6P6-NIenl&~ zPZk`zd*0p34hW4@tlhb^&^URbLha3^Sw5={smzRwk5`{&yxC~tj42vg#-3HnR9mY1 z&n)q5TH?Oyh>mvmvBq^WQ>Gt()Ul;6ts=~MGSf*TW5Lj$A+6ICc3nR9=2p+5J0+0` zUW%Se)cbAB?<~D7ym;!&;Bz*@oq6leEIGN}!{XBORSuOp$Nn+1f`+-~&b|5K-MQ)4 zCQp$0^X6h5E9*q3v}PpxtFhx z|4Dtgh^zSi!NW?5nyN{6d~QACxMq~3v#3n{`t4&63-+jkhM#0gXa4+q@o)yOjMe0w zv$nm}fB5^~#I^r8ztjr+d#a)!e?+M{>(7_eWrZJ=mPG!2JZ0PN?S+P(NBLT8RKq<^ z&Fu3pPW|~}`r5nxanmII(!BcLFVTF>wm$ns&yz?Qt6z$X~&PL zWP08`lWAY&7c^N#t@G@$wyO10XIfe=%*a0NmiJEYrP}@oEB8)UA>Qb~S<^c<{ZX-G z?d0IU*=Cb0CY7!2TD196>C=hoZ7q@AN{M%6_kJ$UX}`Cb&HU7vX;$7dZ(equ&;Cm% zz#~HJ0cdvMdxV?V%vW_qT%QgvJ?}ba#hEicT&~yatfs|ut`xtut$x4YLu=-M*f~A7 zE|vWGxv~BD-x)KNTjx!XczYpYrpca;duDv?k8MGNNKaRs_DNb$Eg!Yb#@Z!iTh7ex z#(93J>n9$zVZ7j=%9M8Ad5J~q(lc{()mS&JIg``D>oDt!mF>!msAt_vcYdndD;vA$ z(dE*anX8S0Yq4~$xFNZ!X`}$MjShZx`86VfQH2FiuAJ6Lb&it9`w>rbc zHZ*P3g%oR(>K_+wp1I3CxBuMf$$t%cey_NFyZO2K(lYND1d3!#(>C}8pO}&UQIT(_@p{iX@sQ(!i*iw$F6Ia zgfDr#eCbjpO*^)uKP@#*oqc8@Ak}VaZ|!{Iv++3#W<|%_CEtrpOa5$8VfM56=rX75 z)k=$ZJ0ABb+8gNqf52{k;Nx-meqp~2!kO~7!fR#sw$1zhq1|5QyV2*r49s#Bx(;Vz z{DhhA{(dF3@m%1S7MAwIGc7K1olO>;DFUvoW}dN`u(z_~%$rl3A=~_Hs-mSYxZXXo zna;LKd#P{&Y^7aeRVJ&(D{0KirM&JUQE{AW*bPReU(Yag-54C(dU zJp1yEnI$@};{1$sY;9$)hK8oijEtVNNL!^}wb^L$u7=m%p4WpK8XJQYjhFjcho2A# zcY1&GbMbsf>62%y&dXX)KIXk?M#II`m=W|wmnArFJ@#Cm-|36m#*f{C)GT+Jlr5}%$Isaa@-um~O z&8{A6{8(msZl1P!&zDjQ(4gw$5RVMG)hCvKGr*Y?wPa7{8%k$(U1PVqey*)v@%)p+ z28SKZJH9O4zJ9&`9T&FrsShq4NQ+Y+l{F9Ds>zP%1O6>ZA>z4~(RFuS6*&Q}Wc8_r6o)EHEboN;O{JEWWN?La9)Q)*RGOt${87m%CIJVBN}n7e4(=XnVJMzu#HKkLKlM;GEiPkrvaZUHy7_*~2+2gO`>* z=UFGc(&P9%tFJj9tF6CY30`{tYo(n0t~ArC+qR1K|33D+8$9P!e7WjOPk;Y+u9-h? z?2f*$Z@;u|HGk5z+q2z5(k|sZHQBu5o>{o_`}fuF_p0};UAMS6V}9_9fB!D~+l&6r zzW%y3&6b~!&&+=Lb)AR}4u8zucFVUPE{q3vkCz#3PX2ydEK#DZP-K^_?@#KSpO<;SvoSWUvGU$Dvpf5DGGr?Y1;)ne?z{8l`*&+yqt1)_UT$Fx^_z5N z!K$_G`@IfEA5;sBoEfzB!P~Q%cN2K6)mn%GnrI886#?D=aJZ8LxSa{wlppvCq49MFpn1hXzMRMlRwm%*)fe z6dZi<#tnsSQ>RZ?RxvrUcIwQ|jg2qf>Ix^v1YQ3y@l^T!(8sI)*EU4w|5Saq=P_S< z(e=6IT%PWy_wW2Jcc=LK`j4Ny^*cUt&Tc)i*(J^a1R?9co7I~D~oO%mb}`@M9jnzEXj)XZ6>Tk;nv@-`)r&1x=mO*A|yN;g**F^qKG1 zcFP;3J*#H?cq;7o3r@~lx1h9DhbG;-KXKa3Yn|QQhcElvpEa6%Sn?=1Gx~sgVVh69 z@rhfxCRL)XkY`t*ilOJ!X|l4i8oIio=92{l1#j%FHa~Re(2?o!i!W;A6@^UnjGwVL zGA&C{aAwq2@$8_i);}|Z6GW2Z>{gWME_JW+d!1?h*8TV911S>c+gVa-GOhRZtlSV; zm?^vJ!IGP&$|YtWTXXBy)onrA#_8wgbbi@la?y8&3iIA+X-4PIopW3JWzH(Yl|_@U zt>)zCb}iados#BT$hD{}w@ITN2=EYQ1SDzJ~xvs+}X-mG9WLx4MK}a9x->=sJK}k+76*DHx4B5D~^mW*iCr>yQ zMutRm#3~-l$jTB*&Xg)H77&+jzp_N*%Cse~xK1Yfx;ip>6+d>jNv#ax;<;og)HdgG zS<1}FgflHx(c3Gp<>_|_^zxc=aNTLywW{cI)ufpx+aeShu9ljtoT#bKUU@UjXmiZ8 znOo*wySyuG(#)AFozhmV&!0Xm(=AVE$*b3%F~_{uuU)GndR_6F=lZ3c`KwNMpMCA7 zr4piC{@&#DnRB)0!v7cAn8(c2bk{ca)LHdkVr#|6q>X(nNfi|qyLRo`kaAK;k^gIo znyk0zv@P>?HB7z};yh{cwu|-|d;kCY{itsKxuvJhELflrc*fyA=bt}+ju@@||75cN zk?bu-ley$qJeogmGbmv7K>-U|C((0#F_+h*St>f7Ija^v-*WG2mXGe@+RG;mcpmre z02fZB*JInYUV5k3)!8{YJG-v9UAZ!7?#(M#6xzfW>L2T5YkrY;N2ylR&$FY%J9XKt zu0t%_UEkMib2cjOo*@}Eb%x5LkjUq}iVHPP`e@V@GF>7)^d0;*nN$n8jhSrm<(hq!m|Ad95`FXS?i!e$_Iw7P#DvzZ<@L&Ai#i;^HG>c3#;xWz(Sh_6Et?A;vmnjl$ zAKf71kgK%UJLEj!Q;{k=l)&NSbjNR_jQDQZi-FM-6?E0rS`^I81SSefPmtIzywLi( zvR;w&s;)^TPwrQo4ggQ!M|_i7c+zOHvEL(Iqs#ew6Fbg)xjkV)&er~U6E3v5C~`Vi z`RAPU*=qE;TYsOxrwQAFOH-qxXRnyBq|4W88rTcV78@1+{`Pj4==@ie7BYRmLDM;2 z(?Bbj`HXIv&+WV1HlzPpcVVMlUtaJv-k=@tl8%FPi=B} zxXbi{&DV;UTy4*^ODT3L#`Tq-zgm>6U}0t!nN_en~facTGdNMJ2%Z!yRfkK zRKWcSEsNe*OnDkSJvaNO`8}hMv}NkjEf5J#F!s6P?FEtG1c~70%_QLd8>g)|?QY6l|rK*8CQYEENAD)=#^yM30+Wmbx zCMGJL$F&{v;~V$HRoPTsX_+;PMI^<|-)3fHd~cXnvyEWi^^}&BwCLpCFP~3O{;}cA zlOESSu4!`GRZfRyZZLRKV37r?;7=M&PLw$2X!CVh@mY66iIB;;8QJP9e{NiHWzPO} z0fKXyug>14Wg*%2Q6T4p&*$e+KXVq_vdOPLzja~mCGBf7uCEn5E~y)ntGzb$*}-)+ zGL}IpVl&x|>Ii~XEyZC9NV8K~`|EgWNHF2is6sGj!%f4LUS$oFhh)c&Sm+Xnp zHcBea)C^AxD@;{OKXb_F^2QBku(+&OKwQNprO;8RWl6i1bITc>&V$EK)y2#_X&~44 zMRMze$)7#bF4=qVgk^9Y^fU_o8N(lzDt_w!fyC0Di$}oy7ZIo6Qx6YsS<~T@o|5Ls zbn57uhtAt8VOR<8#F=kf(_Vr?(njWK@tQ?pX?$ho;YXIq&a@1hUmSkh z%RS}ULg$?~uFveD465?!Lme?!mVpd zmdyTnZ|}@Yg+j5f)9lWrrqzbt6zF_*F>{6Efm>HQI`8xzxuK!r;n~%H>{sb(L3vrl zd8c0|3CL{WwceIzw%)s=*CpkK-kRv$Y}@ZuaqIm)sk-As6ZeOS_kW-8@DvFOvE@JQ z)3^Ju(d5Gq3+8l#M(9C>&0&jb%kAJ7?!? zs!pG9z0~;ru1w}tr$F0$gzuh}UUj;4#hSG`g6ChaI(0E1xbe;#o+nGxxqo`Re!20? z6#ZkIwO=OMKlpY#@8G8O`&%AoppJdU#jGcCmY|wZ`*I9%?#{(PZn(L)iU<|WTmcU zd^}ud_ss3r!WV8_<`ll9GRMRycxunOGiiS71s84VOE-Sr;|p13R16v*f-bTAxNWu{ z=q|_uSGQfNGyhy;n3k3s%k=BFcSKrL&&0KksRua~3CECV~p^&HU7I!m6oU6TB<3{(`^t=l&B+wPg-N#H_GZQ=g? zH=Hs)&6pXQk-OrmXr|n3!D%y9v@_#F(^ef{k{X#7mX)G^@{B=bn%DWG)hkZuq!%M6b7E6<25y6Bb0wNPViaO$$kxmw1XFP(Xvu{$j7l+kv! zx4vIboIiPF-!?Txx0OM~`Ps@rLXVGZ+Oy8@gzGHMxoWb!aXetzEWU%!62WX!zA&%-n0=(5!xeAN#d^wh@W zYQH_{6PY2lV0X0Gj9V4escEi`lU%=i+j!@045F`O(RztMroH72iEe48&QF`Fwt@sUpj3IMK;z(hZZerHeK0cc?h) z_^`T(JbUVKQfz&17l(7>mpIX9X^R#5w@C^LZj(Ot#%Ck{+vt!9-BVneBc@NEyfEhT zi4%z^!CZ7En83h(f{_FL0K<+W`ZDVau!S1(&ttUGY!^siHS7q{&UT6hxSTYcm13pZ!>E_){O zvf|y;nOCmvwlMee((!tuqOK}4o9UW@zIyP9^)AjbiP_EI2)uaReIV>_3f0#NX>V?=*VfH_dT1{D#mkqKYj?g(2e0gA0~Z}<4$io6Tite- zW7?|N9nNJx7VpuKQeAc;F*r=9(^q=mp*~qh4_|Ll`{c+-Ng-k3iBo4b9y{jtlhx*? z8G~+CQuCjsQuItR{Xy&VXI&fBJm*!;I+L?Ta>~rZITWC_`zA`!E$;tLQp#4^$q8~Ktoc+uOH0vDw zP5ab6<>q;3O8BQWZ7jN~qhs_%>%5zk?MzFXT}GQt1@?8v25;withYkx^0E_CHYuzR z4@uoO;aaC~Nz2l`Kc<&-8k)^rdk&?T`u_gBTXy!Y5}WTKX{(Hkt&--J*rtA}oLW3% zaiyI>K-Yn@rzgyt*7athkbf)#gh50b!k;s?DBJ#)V8h5pA5`Mogv)C0*+$|`w zLy)(0>W%H&d4F5qcFowic(J5${th`+)}0xPBhM|pz~bon(Qww|3vZrZb?UknI<-6U zYNjmb@uoREhTE2JzMiJ9V|=_kA`P@nmp?l9>59{5rfpeX%DT)<_hs>vz3yyxPo2r# z_u=$A*IU+~1ul7J?-Ka=N@>a0GLPSYr=a#Q*owL?vs3Kxj0g{JYDD17?LZaBB^bBn!?ZBqIdv0usGw%R?4;65yMWLIBq z_mbjsVdr*={CiY-`QwArW6<&8Jjj~ zU1)uMbYN=Q8s3>VcluA9X`KGZ{QEs+EyqkId7)0uje*Y$4Q5ZxI{jmXfuf#g+NG*l znzr`V%%+#NM$Z2oaoDK1rX9Q&?e~VOVST&KywQnX2wKk)ztQVvb;<@^z42NmnY85GB1|D^Lf>svwO62 z<`v0gTHfT}JZ}J^23G!TITdI%{SARBo9%Gj<}k;p@pJCMBf{jBaa``ksVe|nq( zZ8CYtb1W>{Ql>8?txVRWtI)V#E;MBs^VFFp|59BYmApPb(!CbEitDN5v8xebX;G)n z?6Z40CuyfgjCg9o0-lc-qn8wi^H=i6c&55a#?AO`;%D)YWzLILXV!dQaO%u1Ek)f- zAMeG6?2(cyo!)Gn6fkAm%YDb(-h`&{ojN1^p!j2J#HXj}8TsqPl}v96Xv@1-^;YCB z`L1cF6?5WDn(Mz+Mw@r-iOAV*_kUwc>a!nw$c6ULyBC{RaLrq%ZK!!w(fGsNhM-** zPCVMi>^&cl+9TF!Y!I1oi({r{?)22Ol@{LwU&griop>&Ex+Jeq zm-SM;yO-dORLx75f~M*{+cf1$SnR6P*Un$9y?ViF(#-Srzo#e}*QY4zw0AE*lkzrTA~)hR`Q{2h)yMlSm&FZmI1C2XoC0!|m#+#*T~_5=vi8}j zGs!#r_HH;Mb3YMW3)$stow``hxbxpUwUBo^N;*!gxl<_li$7Xow_sw#OwGioO&rC~ zejn6$a`%JL<+{{mS`yL%2D5jqwT_-?DP(jxEOi<0%#|Kj`<^gJ99h3xyv@9QUpkL* z6pww{G9}~9{10dTNSl7_%parWrAM?uCBKAZ_-(J}yJk+eP%&QZd-BHKWZ6ebO#&OL zSc4uP4hWsna;k|X#B<@Qvkz03d4{B{3Z1DE#6JJ@)S0)L_q(RKwY8opQT_5u@MT(@ z{WqiFwRKCnPOMp@vqbgi;>A1v%~*H(;i)sy>yBSp8GO{mXQ`@cM`zz8x$eN|K&4d% zPm_74&J??QCNiyP4##PDGeCHzr-|yH%8oNRUNtc{tv0dz;w$_6?c+q%xvrt94}?soyn|b_9iCbD$y`I&2E#s9& zUxMcv8h`DJ<*a}CeCEHMTilaX#8xJ!>G?753}}_N$D^I@Nk(51o z-G(hwKR10_ymYDSub>*2n!+?t+!$}#aVxBE#TlJRPgA_^_#JW0aa^m(x-(Po#+Eu$ zq2#8cKW0{m&RTWm&Xzh(K6yQt=l^6xW-A56ifXf@6&H7Xv60Drn|bN0$f8wReA(f( zojZ0&sHm%VzdCj4Qjp>7%Cxj)Gp?v#&k$MEoMxP#u|jKT^n?7lh z(%+b^9sS)$?dQ$uecW#^_y2qSJxfM`xwBWv7+;<=aoV&tomD49ZujM;6{PQ)Ir+?z z@6-QX+_e9{hFN}0|FS46`_n$>P8`3Tk`}-J&R;#1ts<6B&tBTvwVda1;f3cNi|W#g zpH)j6*MZ8?nP=XdsLt*Hk?uEN9sE+Vpi$?(!}OTFE6U`*8-Qnh|IdA8lrS-0Atm&b z!9CW{j8~{=wf(77B4=f;JNd+t=O(MGE=!?lFn3EK9R83YvW3VvPDV`PZhAr_WTe zr>NC_%+1Yh{eS9d#=6OU%6r}H#6V$obaqPG`-_Zwb}2dO($Yl+P-qukIO;xxbx)JsKqRVrq=5xp%l1YTBd zN^MPR)G>Z~^GtW)lJIFWoEL?()P0Y&&TSv-xC$d9Af(Z@S5%BiTA>HPc`K+-gY5<`TqU!XYxN}U1N+9x}>`A^hIUc zf86xMjtQh@>*yH{OgzFJo9?%f8kf7fRPQ1`VIa<=x-Mdo%K-$HaAO zm&Ht*u48PeESW!HrtMz$k9Yo;aZX%$>dcQjSr?z~*|jt{Btz}l^{%hVj`^RI&IG9) z7A07Uk&S9c_zoR`j(DS^rhz-*`UJ`od5F6)keO2@j~G76iFU77Z;a= z@9*w%aC1B7ckTLGmw^k^XlBCe@~rZDk)3zep@-KWY43-RJG8AyxTAG z6^&A7YP#MEi=CjMWn#WAzP06rY!Z*J(5!Q(&iwlqlyh&3O9{XIH_nDv6Q|E^7Jv5d z_Ukm`d|}aZMw54`nwgn({eLEJcc*!YOLI*u zD5NC(r_EfoXA9rQx(3GixxRv@K7X)Tz`JBeyzNm$$3xZwRazOx0>AEY8A5hXpF(P zjaS2p%!}I+_ekxET*Br1s>tsyUxv*Y+v;x%_U?`4ICt)xpNvgKK+VFy|E%XlCwlfA z0}mZ-f1DM)a_UU;`z5-&cCF~%-skaVYPy*GoiGL2{T!xS*+fz%p6O}VR2PdjU*|Ga z^QKc;n5c^9#8$OZ&GVj|$B&h2Pn6OR&zN*#$q^SekN;;i0?#ch=PhDmJ0-X=TlbV# z*U_!PTkf9CjI7Pemp*yn%ome0^&c(QzfPMsags(_$@z00rst#H@y%TMHZ3kbJTW{? z=*#^-@Apk^pLTP5Ie*dSOBb(2sLQJNq^hO6JX&k?d2i{*8Cx?ZNAUUS9h`svw$e2B zwzwsG7khQ^BgnLD~-AcdAD6@YT=kfeES8m$P=-&3;In$9-`Uj%TgTiy=RuxpOKg{ll$qm1Dw$FJ3dy0IC=(6Zc**@HT^Q@ z+U}^{m8m9>l3x}&jk+s4AbxJ^vuPz;wk?|(96h_?(j|>8Tb3=}X(_9#yTwlQt*oTR ziB6ZKH-0H9Z`^`%43wOikKC9UHPceXHDsY=>)F@)T|-`|rKq+l8ykw;W}3cuv9s0w z627-TpDvj-{r0WR0(+80A1x|NzPzlrqo=FI&|28>_BN%0&uS^lmMhqrO3pURJJ7cv z_r&RwE+(3PS7@v1ZhAUF$MA3V*X_|uFGTeGI;A#kX4prLH7ROrS`Yub%m4WC& z=A}%F$2})4emw5fe?(P+@ z>#BaHcJ$uJ3Qr4KwdBgwWe>ak?;X7w{?BJmob5mV`d`6kW)yy%@Or_V;&UO2F`gU# zy-Bwh{T;u2&$M+PH=WiyJoVDYbw7`uKWg^%R*vodZ?hyH{nSt6$=g0X-s5^%=lP?T zw$8EDFn+D&?wb}Qm8B&hE7`W^4bK+ytBY4|3o75A+^b{!)X+F~>P(iub0^mPs(Klo zap^?(w3*7%RsESaXkbgo}aG-|2P{_1&gZ*~4iQ~lGl}mtafqN5=&0IHS6r2y(blCdcH2|z3Fsd z+m+(fXCBLz?v(7jbpPNd)&EDAci%tQ{(f)ci;mcg{QS#z>urumr>GS!Sud}z$7_7r zXVIce{j|M%IrVjJu3aOUxaqG!>`Okq3+J-_Ec-fRri1$nnKK_g9_(>Xdnm`Ay6m6D zoE~#0=f(>$>{F*s{c-i2TI@v6*PsnnN7ikzf7}>RZk*q9WRZK!|DIcr+@?A^7NefF&>p=oUA)BB44e7TYL zV`_&hr`55aqarr&vKo~8pkIKyr25D zH1qu8&vKWt*W53jA``am;HMkSzb|fGaozlm>FV2G>ey#}cWS(+{nKJvJ$v)(uYHFN z=7E|FVQG^lWaVeOJiljF@=iHW)io+_)%s({zP@%5f4F9fN2FU<a=e`if{Gf;%7A6`I2$O<;}5W4fzx2%`&Or_4-!+?y&9tf2PakR`aR(&T6^Q@m9m^ znCJDajEat{1$JagUR(Q<$uWAr`LHyhEq(h~(w8h>xNw(KN&oWieW}kj9u1k%9;*GXJ%4(A;zZT?`})i8 z*NBEr72UP|^S6rbGb&a6d=I8R6-=s(ZsPpDXOq@8a%z4NlBmrI?SPSWA>#K%_kDN9s6pT9SVXJ7jIKpc3Zsn)W2 zwPt2(e@iX2zIH%hzaZ<+#Ecw|srw3!D(Py<9JIcc`21Ra;3R4F@J6woGp6CElH~kFNeU$Ebh5R{u}DdEGl8 zGHS{1TMrkV@)5du?e3Y>lW!u-i=C#<3}KuyML@sLSI}tloYZC7D_5^QxJuN!t;5GO zWa-a?*6Wkc&*Qs$y}EdhXPQv$-=fGX=cWirXwO=jE$T0MOnE_dlMzo_$A(i zyf+-Tx|aM9Sfchu?6G}Nt77MwjZrJZCi8B*q z8+Qo>&n~}L!P+Q&{pG%UvmfUgO-{6t&D#BPbHR@fi68s@Vq;?;O`LbRN^GVucQouQ zqr&&T>H2>}7P?-_+R(jmYQ<&2Do}_{{ZJH^b}Dw(RgK#(WuBGZdL1-Tb-^#IwNn#Z*=3%^!ADdvfry*AtI^qGfs?9Z~Ls%)P< z&WOw2=hu{~_W0nATVYkJ&+K_|h}+=*(dSFE{ayV1?Pkrow6P@Hrs?yyr0R2aIbJJW zJ${t2u>VvxR}MNeZ(Vg9yR-dfL(?FudtonYZ#3SzRI=nqJ6GnlH_J|z$Mg!UE)8`` z3!5UsHPdsl^;;e;m#`=!qaYz!uVr7X7yMN&XH7o5>uSWmuRtRb%JkKMBmFnlbG9KOq! zUby6?U(2}c@Pu5cOpQdv!#W%8^l!KmzPWbCXYMw+ZV8)tT7TQD?>#VVu`%(szWsjU zfs5bUrk%aMBCcO5b#IthR$O+a-`lSl^?A{+b?=9N+?;uCt=#oJ{YOiq_D!3nTUXZh zQ6VN*d#`KSCDWHD&-~gY^5y$%f%#(H3+9;Rn7=!G?Tzh?iMz#5Hw#UW*v@Qc3sf`hC- zPO6GOUbFe!p_}RRbz9|R&)&4iJ~h+gagTAqua}dTKGj^r6fAu!{neGtM9Z>D>g4Iye!lvA^v8p@*Gu~! zo<$6$>VU>LWsbZxpR01=Oi^~gtw=8Ow^iCDDf3zlHbd)o z#H|*2TBXycUU%+c-}1e3KP#rK5B^l#}BHGWP(ckgd9y1ea- z%$@jZpS$-PcgUUnnmsT3zV_R%ovCvl8}}@Km=*o6EK92IB?c+}ofC3#=47cx+1qB`_W#Q=Yd6i{S+UIGsCG(P=}gZ3 zd*9ognPVAT!utK$or1$(f1FgcOWG4MtMdP|+4+Zj%`GQMfBaWnl&ZvOkgfS_ZDw}% z#i`S;MSi;3{^LjU_7W_i(E@I-y?G3F;O31JQ_?KUjvrOB*nin6Eze_xOUW0roSQp% zMD_d_TtsI&-rjadK(;3<*hs5OXvtU3BpE;d=`%%M75N#rn_sCY&nQ}asw?PD_4!z* zI8jm2r1$e|GxGDxHLj>e`*&1UZ(9?eI&IqP>uV-#l6f7jFCKa%d*4s5HSvG$N&hH2 z8oq1&_h+c^!Kb{x{5o}Jmx!Wq@}(6<#r)fzzByW!Y~9pTy}@oD3)|tcoO?e^ zPtBdf&{5|mrEHvWTkVMJso2w@icyM7ms#A@PEl)Jciw1foVe8g%?o$Q^#6I7sJPy% ztNX0e>5CDkH+-C6lRPalEvT$LN4(!L+I-gay}yrXuUMlqJ>IzQ9{aDkkTqib&~+y$ z?h>2nd2#y8m6y&3G|iYI!ScLxM(VS^JGDQwE;L+enR{bf>dcZF_Vtk*r&ChY+mnA! z`|8odIZ@ka?YGZrGcEO$mZXW!tND~~@vucWASkHm+&RD4lcpDiHYImQ#=HwqNqcb2 z^wGz^uH1sVl2^Zby~h@uia~RyB=`U6M|kvT>i&s<4a`#(rg-(36dx$QX-z5nZenAuhR_r3e~xHEhnR24L4 zzlUv?Vabzc7h;jzwASeJzUucISDab1-v7`62hCrMsL2gLC-?$KNOqYKQ3@zMa3f@ztxW*n_5l z!J(0QD$kCU-cMNjKRjZ8n(DpE$D&)dZhiUAUhWWl#YJSA5zpg|D?UwfiZ9xFF;7|- zu~sTh-MGG(|J>{{Jy7E_tnRPnw!EKGkuw!D*7zUZSL?PU-Bsb1K=*~%Y{4z5s-G?? z2f7|FFF12XwlRC@=k*R#H6@?2P7?~&HjPZ{^Sw3e)djuOJ$^5Wbplh<4!n9A@#$w$ z&6k5e)}C4dPtm6Wr_W@*)SKlPs{JXqW!ptNp2I#lA&r&>1`YrI)m@bP`t@t)1*5f# zb64Gv;ceUfa!GcgMBB_40nrvQ(UvmzG>r8v{@eJ>GT|%~Y}7c{>H4BCe?!{U^dBz{ zvqrqx&M?1z7sp0H=_Rjy1dm@^y=KeQtYEE4kIR$3_c2_VzHFgW)!MCBmTWj9v-<+i zTOH$eHJub`vvq&jq_3?xvb_7gqff|=cXf|d;i;?WO6s#!8)WYM{q!nvv)Z8*Z_XN= zacR!`r}3qA#;pa)?tKfkZ@;c%sJX@Gb%wO<^4hD{cM3=6rUnNK^U2$-d2`f&$GC9e zwh6af_HMg$&vvQ#=`%MroL!;kC-yjInz-5m6>~Y)$cq|hy$gOyX?w(osVZw*^6V~O z;~wN=;Pr`TQPAXbi{FLE&gq)+BS6t8bf)K|vr&5r?@V5D$4KS*v}hI8^-keczEkd$ z->=>A``zxdKabo0Te$mv1L}MCw*3(dhzO2(6=OswwXI*ED9X9bXMgq-COOIKW*m4-R6tjc8LX6 zMo#G1RMX568vJVB;W!@^qo=7>Zn>UXM#9b;62D%*v9(sW=+dI4xtf=LiI{|@Dr>6- z?TPo@s$=YK{LflQhs$V7r1-p*+WXX;?o~YIy;J>u?=#Rwhw}X&g}`gG-yIH%woEfJ zw%Z;RY3z~XH}%FbqsfVCY9)`|L#Kt5dS11y%AFL)#l`jEtoi*1xAXTOT@|`|L)F); z?wng&IB(v(c`7pIcv$q!yLWZ9CcfylIBamnrS;Z34uPdqDkF95}VtSdrH5W9PmJ_jAg#Z7$I-UQ&w6}P@ z_xFxnx~}4JPtCv0N#g%L`yLV4`SscMi4}+Ki%$INobu~em5Auf3wQ3U*3_Ci z(o1f|7XkAkjsJXtc65c>jLAwy`E7BO?bNU)Z^4Qir!r z4e7jcG4b`}a|S%d|1$IQw?|I(+*t1a@~V+>jdfOcaG-9ie`#RXie(}~K~{3Q(eBlD zrS|T@^YR^18y$P5M#fKT)0Lji-D&jx=kt`AE0ynByyHCW!-wL7nn#Coop;Ure>H#h z?388i7Z|MJZg|fBYZKKD zyRqh6Nb9qWAsKwv!-ArsqmNFn{ADT8mZ-*cw^tkkWsp-ov(DC>nxe^Z zZxSI<}%-JfAY6YX1%GvJ}=Rg0<(G*XOw>~c`22ibdT?+lw_KXlXwXKC$t_99E zk8xclRsDU+Jq^$)M_T8svbmpT_PN;w&Q+-Vy(l8;I``q#Ql458erZ|fu1{s#A00Y#WwDKKYTDCN zku$ch6_SnRHBb%T6%%;MU|ye=ajf9XERQt4>sMoXSMyvC%W}T^bm1H25C4qaEWO;q0$ zRHkTXCXjj3QNy53(OQ}1qhX^*?|n^|B@*11TeEzp&73EgJ@e z!4ja=b8{-D&eW1sobkDC)+ZJN-I>bY6nDN@{>8~uFRsO8^}R*6@*W;LdhUeJ%a5{O zYh*8(t~3gMUU4h$ne9}LpEYu6_icsw>-56gjm}iG>^l-P+d0&39fxn#-Rtr7x#4NM zc7vCtTl@m$vq@*J2-Y5#vRY;gIzD!~?oa()ySWRWc-U1{cN-W4v@Kdx@Z?#W*Z?$lm5063#HL(dxL}Q+hwrqR!s>`R=+UV& ziCV^?Ggq#KY}zm3xt_Xg?J1wynU>YYrJCTqh#8j1#8d9tn=L@ zI{BnZ%Cm?*@B((3+aQPboavDcn&~#OfC zIP>R~aPOHVug*BVOgHQN^7Z0prqwf+d{-8{m^eFTmEO|V=`$_AJFKwp%zQWR?3}|73oH&Fzh}F&*r?dt zNa%3jq}dHZk58yuo1W5~>3R2zZc3Uwr*~x5EwA`^!9(S1-6HqdJl;8P%N9{k(wH_= zrfc~rpI`TEFWp})*_P-g9i98MWx~vPPU}m8@44Q+2HB@Q?_7H#+rrs)zm&W;&%E5T z;~dA?En7@#J{}d1*jco6-H9dZo*oaHxY8&l`Xne3KTLilulB z4BUNp+Kv?kIdS`srI~+DOkKA3i|B&)8Jv$En=LZYZ%kb_y)E_Gi8OzA_FK1aFFn8a zUzvaJ@>6HNGxI3*IZXe(t6ro>^z13AwnT~Z2g0IH8WoFVazwX8E)ps+`@_BS*rdcY z3#IL|%K7AMI!>MPx^m?TPv+z`&c840`yy5K?!?)}psZG8A;Xvcv`YF)>jw;_q9p<66f1<8l;@fB*7vW?l9oPOsZ1>fU~P{)4ywxSAJF zn>la(^Zhp(pWAIeBY!VOKb1M|NBjy`xyo$ie;=-~sXU+d`jmcOU53-fz181eF>bF9= zm#uEOC8u5jIeo4A2_JAI`?U0Y%Xv2U_pOcP{@h%jivyx!8iXd#4xZBAbSGzv$<@dX zpRTQEV*Y(~mzo*b@b6#Jo4d1B_quC3<$U`-y{q)^U)4+1iIcXaDj(dysk-yyf}5AW zwmdCJoN4*%;^djN`q>x)UE9aoIKP_}7mM7v7Q*_;5l=!sM%y$^WC5oSvSX z@bH1grwIk#@f?EAhYWd?FVEf^qIqbto%2nzt`AYn53}d#FB4xjb>9q6XRUj0xc?`u zzMk?s)9&!OWbn$~omw{T;R#ur&5Xit&Q6@s*%p>@iRY<`Zg6n0pr~l;-QDHg;CAqq z^BEmJNjtU$@z!o!uK&0sww+&I>^i7rd2hkC-dh$~uV1}>?RtCpb+s%|vYdA&q&VUK zyujYCKQ(_%o#|>(w8Y`W>82)+H(XK8FD{(z=;Lt6^c3+~<{|Q|UC~H~AyF~A^@ND% zOdaFBMx~*tD^_X<-d*&SMLhk{k!UUB#hbK`mcM0><92Wek^A*4^N;a^({D2?Uf91b zd~iYVV~xwnZ=1CZ+s`>Cb}l_5bL3YkuXI{+;3loWj8Au$8l1FW#5QT6%}Tw)H`?r! zxsN`%kyLTSohd*w|H1|C<}W{kpEo)tE7aUKIpM4|?RWAu?uiqYUksDbH!6166{x!+ z%XMF`NZ9tST2OOzh129T^YHyTm(MEICI;*`S+QxtH%Z=!o;=sXf-=_l@3Nb9q9aiv zZIMH6Ty*s84Cb%5+)Tf{)0CY$Gi0IVtc`Z|uG+@uXBn}^*S=K$Tdv6;8|(IRugCFs zbF*E)7kX7P`f7=EH=WU#@=R*U^Uh1ZL_%JAc)1&|IkRSU%FLI?R=j^}{J)NC|BR2t zQ>IVtlv9e@d30vP$B>yn4^}mET&yl(sIMyLz853Pyq}lTDa-C(ox-*w;$2U3IbD!PPn;G#OwqUx;lC@N=Rm(v0RdHxi zU)u?lwruT{TAeOx&}Q;OEt9u*gZZY;?7c8~<~)JWw77zQl{2co-Mq2yq`c?G+Y<%0 z@@{qUX z_TKaVt6pjTdEx%QebS?(X)}dw?gh;BoH}EF><+%Szn|(oTl!VD;#s$VqS60v56!;t z?^rp*L)&<_lHqxC2Hqs6J0?E@pDx+5V(O&W&`g7i@0fWxX6SYcwWq3ePG7#{y2tVL zF|WIq9zB)C-8OmV=Lgg8sLtKZJ#qcLWjf^wDer6om%81(cJ*zW`P5UUZIb0TCeMuJ zsGPiNUgTw{P9(Ub#+bEA!^ClV9gfT`gC#%j+_z2%elM(eE~OYL#Se&eo|W zaWb`x&%1ou64i{ZumweDWb^n|f1griG+FJh;=-4a8>h^itmf+!<07!iS967ihg)i* zkrr2K=gV~=tNJhP)JaWS8=1DsDR_6z>z%uD{>J3T?|m+;saqQQDEt!@lorb<5|)%-p$tH9P;FZFwgG@=7`RHg>2~wJ-S||*?lf;vc95~` z{7)w;&g@93n0a&dUrn*ri6^(;GP}@l;<~ufhQP{ou379?&Tm+I{&N9*I=Ex)wn?CF z6b~qUwC}rdd)2uu<^Gw9#_9JOu1#a(pE@(h%DUiw?f!#5?dP?5E}z>rW5e1_M|2jv zyT5pHk8YG5K5wV3Wh|YobulDb)#;LN+&U9(kEpQirq@rNpWV7(wP63P z=b!dnTR(OF>Gf-cexF)Dn_*(zIk%V3*RR(*zQ(;y|K9gUNweN$>RVY^$#B)$tx1(Q zzWXs~g}YHE(}I38)kho4w=ejfv42ylNZ|^j;%$))65{74sq+h7op|oF&&8u(HRFH0 zDl=4%nL0CM=F9G-JBuvS^4-PDjnj{5evzE`@7YUZ&*Zdo)9f~XV&lwSsqb1-S9SFH zjq}%19Cbh=rpLshb5+m2xP3V}*8jW#kMYL^t9-JAzuxky{yrsYX647D;tx04{}b%D z|Cio>mi7663D8akKg*{ncmB_gPrmkE{(v(7efzG7=hWwZo^F0#MCj?J(|QNr%hz%I zuluF>hU;Em&lHxxm6Gzpop%)-S7>O8Oup9QdD>8LcT0ng=837_?zT;7i744(qF`&u zxVt>hz0okZ&&ZCq>5Clq_~@#m~5 zTNn3)rFpGLPV18F=T7~2QFOO}aZQb&(%WU{7TNr5Y>jxKUH-nzD~(IAcy_PevzFIJ z#RX^1ysY@})g$th(HV;++b_s(UYln8o?~va<-+Fqsom!%&A1t(ZMk*6At$(_pM1K& zeD0m2a}p)kl58G7ymji~g;Qr%2A@B+#B(`k;**t@Z?6OwzS3OYF{kX-${8lBEP0oE zJ^%lZzrI7*&mzu4gzMzh**}J(06=y;F6t?5)|amp(5lRRuRO4o#Z3-SpbR z$SS|}!snNM-I6c8H_U7G7M=ZJb$+M6+`4rt=;GCX|ChYB`1j+nddQXUG5NFOUX+(! z@>EZ{x2N*Oo$b@LO^yfJgr)_BE}QY<`Cf;+o#kJHu5XL4G(~FX@H|#{2j2EB$-m=P z*rYRS)~^>g`g}@z-GOrp*524^d?Hy`YLDzf>n8!KPu3-5{>^Z;`fq-EzfjSe|Nq{% zJ1x49Vj><_V3+p)r@xnZp-|tZmXPUJpR1_r&eJ}uzOWUw%A`jE;XZL+xHn;n6 zpY=H<_4zfIcAV=}|L5V7KReFD>hk-J)Mr1skCz$8i|eN>GY(YeNs?&$C<0%PaO5s{ z{P;0r_-(J;)MsC>$7&x_UE<|v8X~x&c1xU`-rHNLFHNh~UzWRXb?a@FWS;z6tG9Jo zS8{J_oDaHjk&_MvS+_WeC@S)ZG*#JN%Joy3#UI{ zle8uyGxOk|pPyYlw(RIyox613nJKFUEU&F}^m3Bvvwp{(R3s_YKQSe3t0rqG>!u8u zsWVavXU(+=Ni(~8Ds=0w>-HO+oSD}|?d(Z?X2A>Zgn`bO;CZZ2bNOVC#M;IWUq3bn zR%%^{elFtncBh zEB?=Q{c+;GZir}5s@iEEe{Xl=(`P=)UGb^{rPupk7 zZCq1h@VBwGBS)l~5!Apj6~3JL_w2q8`fozxUNg(9*4_NHjyJK};B{1#IrHXwJuc0c zw%*whad$a$*A|ftv zF(kV4*{&?tG&L=wV0W8c$~wlfT&K@`l*Q^znTa#)_I=Qww!6tZ=iAXQ?o1aHbfxX* z^WEp&!cy{7@93K7>4#M2MTVwbWA(WGXM$V1M217ddVVpnmFlv0MW@Z=Yem^n z0*cz6>kj5^eRpJbaqoMX;>OdSvMlYl!z_WRin0xvH* zCOPwX@8@1(RMAOYwk^9#Tq%AA-~Fv>B`5App6T#m#>{)7!Et6<-dq=B!k4Z*qcYRd zEB1^g_JHVjojP6Q*QC|GWw$d9n$JDc@o>$VD_+MN`IfAm(;jK=FaIV@Z^N{im+HKq zJ&?P2Hm0w3rsl;|rxQs(OYfYmd2}#2t;&6clCi3ySf!<;Q_7|@U*4eZ?EqD;A3bnQ z0sL4tS<=wTsb!5%@UaadCTvOHrp|wL+Q&C_*|dAB7=El;zj4ycy(ha394u=DhT{M&6smEIop5%Jit;mnu53l%d(7a2`XUADX(C1_=gXWlG+q7l~mG~wc_ zh>4*OEt6yq8wgA9`nh<*iX~<*PE1sO@%F8$w+|P%N;#hy|F!g8OTN|2iOnjHN`B<6 zbJ6EK+o-ohcOU!rn!J{Rey6mk-kC3@HTJGzWbD7gX5Vbol{$CnnLl4n-Q)<-(%gOM zS>mtRY?V4^^o0Lp=J-?_Z8qoaUiZb-VQP-U?n#kpekv7beBOKQmN4ENw#Qw^*wz{) z-#ndh;>@(Y>>sxuy0b-DKyQ}x>t)s#v;yN6C!(F%>UjJfjng{h9QoqTeV(NA2H>;SkIV!iS*Uw_PlaRMpw`LHM;WFsVPz7 ze2d-hkW|mm>FxaT*K#L+2?_{k$lv#q?S9>F-LSP$tyfovN2gp~=6m9t*t2Id9?bSj zyCPwfrgpf8FWEG0navsx`_n$`%E!)_98s8m{LGiGp7fbFuUu^EZ9Q0_RmJ#@KsK4SaQ|XCs!CGn1QTfzqx8}Zk_f9c);@&s6wsQab_ivZc zWFY~8h6fK4p609rWtJ5OjV6CgipkVo`S<r9);w`AKQk*}doAK50& ze0fuHz0u~KizVy3D*{h=&oA9CW47hI{i1ls(-tDLK)E9kG|>r~iVC;BRLZ){?6kAd z)V=O(mnTI=h^*}S5vpG6I7haD?dMGmgFjEWcD*>|W5rj$G4+^!R;XI3WPV=WwcP$T z@5o5Wur(2ho2`OHXB|guqh7jp$$z#hSFR|itFup!ud}TCdU|%?aiiiJCLz-ouiQ2% zb(wg|o|9+x22EOJT#$ZYe#z0N1v|}u=zFX@DRTUR^)~Z1#6-ugGk8YPbHUO3xM>iee)3!PuQdUYwczm0ou+RaU=hfn!%m0FgIg4<~u z`?Ygb|C%pYyVkeocIA@~i7Dx+3wP~_C|Q1Ova_)5mwiiAk1akh1GMr};jwSpH1qIB z|K`?To;o!=X9xFswVDQ2&bp+?{Q37=e=ORxH0t|}dlK-P!}67pvC7tv@PsVkX@TqB z*c$!lluS_F^w-2m;6<+K!3cH#ITOD1DakCYm5a#~di`bFJ*NOBm8cqZ4qll-uN`%SN_zq*3PckD0uC*E4S}$%oSw1vfbsb`S)N$x#fH2 zPg(AiQmG{omnJ2y?=-6@SN3+zS)<^$X?bzW_vTei6KilSmOFB0_H%`n^jWFL7++P) zWapD9@XWaMdf&fa*}IJ|t}L(74Cd#jHWm48<>xfb%OOR-O8*3lT7uGDEW z=gs-3Vw}0`lQ&a};m^sX+aG-V@O*Kjl6w@7F-nN-1Yp!wt(BI1`?2|e@5^YXrD!o9nAlII(SV{>9c-a^v3ns zPk#H%o@UE&|LoKks+k^lkH}A2s?d3;bz+Fmbx>|~ga+T;l{ZPGwPHa8x5 zMSK3|UGUZ|bX>D1%E+hP5-)!#jCcDH?VI}*P1!Ok7q1@v;WbMoDaeAV9Z zR|avMUA|a+QLTUbyPbP9H=XJE9x=0W$$I(ON!%Y!ed0J1FSbtlmfPjZMUl4STtz}5ssn~PN<9}-Q zTrzdhvOnMB{%689&HQwU{I;%QG}~x$x^cQho1|<^ruNJI zj9>aALsI=k#GWjkmHNzJrQ;?=Hh80?M|@`FrI^0mP5)Zv=k5@Wf4=5u-y@EinO}7m zof5vVX?f^((<#^b%v3^`C$|>voiNM0_*%lRHtT}(ea8c@#jN}-mCyB8@9?3r>NRKX z2u!wrQoX%2ZNI#R+;-KBcJE|~HpyzpEkoh0&v`OkuDd`AzK`RH-s3YYt$DvzWGkLc^AmQkP&T=I>C%Uz;_(SjPfgvWG*^)MU8+%$jL(o@sLBcM3rZ zY(6~xeD1I_|0$#3_q)`Sj&+||5_w|%tqOC!$nV}CJz~#9f8X-*-D>ewZ)$Wi4cASJ z^PW1>*z9iims>J-Yxk;#O!e%)U$a?Aeclh{{wXu%*O>GCyq7zdapm9GEmmT6m-g`- zHkjA4c3aVzlx5eGOb_pz@#T3$M$Zu?L%rubXW}xM{GT57NqKRs|JK}QcK$`1HU*WK zZ^=WQ%;>oW-hX8N*elvIw>&slc-GPjpYH9I%~-K6VdhJRV^wE-Leu_zXb~>Ba{^R!((?2W;&A9v8Q+{FW zFQ3c%@|V}7)%{In+^)Ds=iDQidlJ8XK7RbzW}8v)Tfvz#;#aScbeRe|X=q;8+HFNX zsms1b$bwd?yg1gcRs8GMuM0PCb}m|^q@u3Qo#=V@{ME9~X_>OQi#Hl&##iy~ot6sE zoVn(?;onc5JuCXyanqSCbLaGkOc8nZDl9E5!rR^4Rc!gm6JA_LcU}G0!Nu+PW8K^z z@73j%t+kzZozvQ)b@8{Pnit4NTAc0uX<u zRS8S)tlerf`ACLG=9HtBdkp8`#5Gd^4=&$P6#^iQ3+ZP(0f^(Up$Voeu0?C(2tq^B8r3O~Nr%q~Ae?`?S30r6*v z3$1SEtqAk+S|7UF=4Gjg;=9-Uv)b!~tFz86^*d(`?n<*(O%_j#)9?*pEb+W8ruv=F zZ_DlEBSEP*wnuvRB;7FoZ1%ab#^5n${fw#?9AAocjyR99u>3CjhdUeHWt;eU%BnIj4wKrL}V(A&5pp<35 zkDrzCOIzjX@9E-Jwd%`^mcC`XIIVa8te80SRnD_2qhh}|vuDiz(3m%`x7s^A?cV0( zZ#!n3y%U}Cte{Yu@8p@*4v~2ir_~BN*w^(la$l_tHJLgyHu3X=-H!j>yFS&5*U_CO zcBQz~HECXcgO1?ozYlkxVd3$3US}#4d+EPY{MY8dTg$jkuS2;H!g+n#>}NKyC+lXo z9$z&7ahUy*i>BVk>o2T|y0Rrl?9Zgs!dn{-ihn-zB=zwl$I6wlX%ha<>$ZdlS!=v* zpE~nW)z68ba-hcnJc)pm2bP%4)q3xlacSSqoo*phXGWy)O}^3+A@ZWk`1avnV4Tk;sD*>bH{F$#*4z_w5y8rwcnPzmaDKcuC?7_uShME3pr((}|Hi4Il ze$+tnb;#-uo@xKSZQp-%@#eIJ+ct%S^AtWwS)#JQK)-s$853`}?`|i~C{;2o_|hV9 z_mmHp=h-h;T$U_dretau7n0$(#n9w<_n9RvQ;$9rN)=LAd!|PBqqC0Re=qa=rGKvL z7~l8(8?*NcY8tA-RjleJ@VehzgEYu(D+jG|KHyv>9^%CE+~Gql1iV}=XiYa{#dJj z8NHULePm)XwXg2~{^Uu@oXTf2cl_Ix-Fjxuio26!J8$isb5FVI&ZfktGeXnUT=ImA zrXKqwQ#AK$XhwdfLt50Pn1wSZwTJ~4o;JE1)m7{L@k0moLWtM)G1zp1Qbf)x%2{ZQ^=uJGnbI#8b%{$IU z&g}I1VQ(ON*?#)B3(}{9iuFp*_{`5vKfFIc(2!Y8XK%FCz97@gb!T#VjwqF$Ide2q z;j2Z&R!a-B=Vz90Nlv>pZDw8Nt>_8 zoX&f;vTfnrGi#l%&zv@=Vak&f73rj-I?silez%^NqPBF=Qm*gsYlQZ5{?j&_U6wer zuzAInJt_~vcPUo>jJtI`>HdD#JuU4{{~K3Lle(K+ooo}DR<|K;-qAZ@aWPVATX+BI zpUHE$!fYGlygdWHNAG6)rTtml<-KaclCF~*g{JqlY1zEAiYzcX_rNRKb3X5{->cTI zPoMeHAw%b4iZjorBBLj-d^Vqv(UQG8RWUj3){C@tzV5}gMwc&7m^e>Q%hdCfU(5ZE z6_aOHFI5Ro?>gDgvLR>z>Ie?cet1Kfixu&5IJAy%2}xb?IK1I^))#+>d2BecAzW z2Mu^0SKfecnA@FiHS;6;-`a}q9VI5$nl4Y&&pC7C(9~^{T-27X+$i{b-s`%Vp2u@n zt-W&hnP}z;$HY9bQ$9b#rq0}yAanNSnYj~ZW*G%do-k`x*O46>feTk|KVNaGV)D$a zS=O^Yrab9hq!sL+kmdaQkMb|kEhSrC$eo`3c%{*2k9d2tV^1DWeJZ%?aca;$-qViX zC)zy~1vLfR;jz5q_p-^7@)z#2^k#ZqIv>T-cl?65c?T^xS$%r31h=GT?2l|^5^d}(mRb@8@EE|TuS>HiBSeAF`5 z^p(}U5wbMehGZo%;nC1;#+Oy4?vwh);qk}qcrIwtb+ z#;ef#-v#!)Ov!Pa{3T%Nql%ULN=#K2dpx=HylLi+9hbuD{{JmcI<)H8F}5GSe3mJ8WG`?C2U5_KvR%O1*Xj{2a#Ph1;jaV_|NimZdgM(MzSoVF^ zN1;hG*_>^_lK|$>g*T8%fQ*GRr?2F;`8;dI?WfyvZy)J7S9N=@rb?OThezC>DUzf|GVS!IqSm1y~c%Cd*(mhG(WD78+6Nu-|V){=PlXG?-a66tN$yy>U4MO zg^+&RpqU&-#Y*idX)!T6SiBby9xa-3+Vbp|3A;ye|WWeJy)bf`Z<{&zkWUXcwGLt z{J#(E;APi;zuh+cd~D{9sEIShV@d><&8y~9n_Fk)H@n12Z_fv%nh)=+AMMaFw(M(j zQTuCWo`35EsDXKm7oKA|?w9z*WY~Dj`qH*i{Qthb|Ks-LeHCx~H-Efvxc-3c_j~Qp`Ma5~pP$pIyiha&4=5+F9yUetd0vmY;>7>bGiPk*|RTPytrlo*%+p03UR^D_KK(aR1`3udj1Y+*$TEDkL=Y<-6x+BWD#d?2jnrushxMO#L+fLw-kFi(hj; zole*z7Jkd?*CYSi+1Xh~J})~~btQCK?~F4x*SsA3d#u(e|IL|qE{+ZrindEd$_m!-ZA_CKm8}x|9u^=w8cm1^Ll@q&g1{S zSjheRtNi0^?f1vutlu5#HNW2@c|E3kzTbW(@!x+~E}yfOUq0W`wZ!oM#ZL9^IVGP~ zTKxaREhxJ$`a8(m$j7*C(KenvYu1_nUZ6$wfeXtf+;ZMqHYwj~ulz6DzNtsH_*GZ$ zepi)$ezD_Su}_~qPB=V6zMAE{i`>Dt(p6_(*MB>!*b@3hYro_%>B6VnR<$4VGcz4m zi$vM^B&S`Q#Pax*Pqd&gL+B+Ihk2UDYn7j@{_;B~ca3XipxmQHN}%I2=DmJvACvKE zK`VFMQDJ|Z#{K`kuK#iS{y#O>xOso4@8_EM^SAw(Lx1`I_=Tk2D|(%~sQYJV`1u2= z)BTUPivN5tr}+HgCzsD3Ik|j}n#;5Qe_yBsF0K4_rFf%l!Q^+9vAie~o|)lkm+CVz z_ZYM#&gkShd(7wU!Z_=b=5z0P*Vo(g$y%j|<;i@1qJNP6?50nDr^LOnw)x^6SK@2` z?v94{+0$V%hrV5kGm(1{yv^udi+22lm9s4uay3j!63)`{ib%WV(>udMU0>a~!zb$b zOQYb~%WipXF}g_o1eyxG5XGHzf|iA&zAnUDH?w8`6Ta;k3Z{+Tq?Ts#MtF~kR{s8rw(D0X&a|v^p3E}wjETe}&>pD6I~b#Lm&VQnZ4Fs9@ycw_VuVh%t@S3S z^|rQGy;ZqiQYHVzIf`x$M%`x`lq!qFqu*G6vaQ?hD>m!;+Ly;B&9PItB6Dz| z^5Ok`A9Z4WHu=T>v9`8ul~Lg0`V@H9=yKVT@2dLRu^|>gM;Duiq(!;xs*|!dHZ>Jh zbc_zv{=2U9`({CBAC;Q_|5W6vUMSjZ)nUB)cJ7Oum2S6YE&uSKSwAT^i#f_VDzWRA zyxh*8;XA%r%{p4c_U@00)Ug=hN#-_<#>->aKWc!G*9BuiG?!{X6ktP%Nkz}i&|>pd0kpows*!1i7DdZ zR<>nnB|r53yxe%a;Qd~1f$Mj=rnr88bZ>wAf+BypX*%a!V(+p2`FPD2>bN}&Io6J=96fdkL z|8FdsKJi`Jxx;%5c@Cf8F~2g~*=X{mTer0KUHH6T$dJc4aKX2}TeeyMzua;)1$&(R zSCZq^t=Bf44R((2%cx+MdeyqhMzxY}rVQ7y6`>E`g(1h(pKG!yw<%; zZy*21lAOEYeAmM+-GBS<@9(4bCN=;6mRoGtFU))O>8_*><>dKiqaZ@38LnJ5AB~dqubF`5oW4EMrf> z>)ahbr|UmB{r2XH%yWn8-~Zn!FmqA$Pu==sxBveZI)A@Z?BC=oeLeh7HyT|H2vzHy z{qthG{EKD+sop{d$UW-vWxgQG%MdflY8pZCm3^2Kd$`i_Pb(6!M;_>{Yzh- z+@WN=|9754&CligYk&Rb=I3`VjWjGb-Y!@bpK-@yy_9kAt~8A!bo9DauKe=5 zG*PYCtKx;)o^D zzgzb9Y~xEtlmA7Z0i`VCiwn&Pb}YO%XVOd&D}leqr%Odt$+jPU*t0$0x7*t}Yuhec zeUUm=yfDipbX}#_ieEii%ARvB-=ClyU*#q}$x9{7bHgsJCVRhWsn6aBJl}PCsTps_ z^}lbVYiD{c)H=J@HfHI|&Vbn&cm0CevbA3tJ52p>^Je6ZKc7w){4KrSHFMY3BLePx zz0)H@(}awQJJB~aT_a~bz{{QJNc(+CV@tV!& z1(iNe308lY&X}6fU2{yw=)dgEwoPBZcjU_J{|f+DmCL@jw=BI^b;{$k57*z?4+i-> zhka7s@P2)^1#|_6OkZk5T9um0;}x@~X zhqIz&#N&_2X_q8s@|ub!^_=-}BO_$m%sFz(m#rS|E_<}?cK+d~m+v<$Tr4hoHFU+Q z36nO9>fikTVTh zB-h&Q&D}PSU%dKv*7?$NyeH8% zvm;>k^AiF2I>weVe9t8g8(sdqpv&oN_lbzM6s_3P6|c42;xC14^o)P6i8L#5Le5;NteeLozB1Mh&ec?IF=Nry!5nh$0QW-I2 zzVQ;H$*YWPUDLLConL*bYvQCS9ao;XgiP;0!}HZpaChsOJtv+g&8dILy5qqj?xTw% z_60$ZHl#dCT0->{m$Q{Pp__S(+$ z{J(e8w6rgCv_4zRo4B|7>(OPuc1xeUd1mXv{laq3dJ|`S?9+DMyAhNW<|Oy*)qip3 zh2ww4C0rh%%X(d}=*tK%oz%2%L*P-(z3;!Yp8NH8$Eo;fGqpd4 zU!U^cn`88K(Xz>_O>7yXzfV29b?O(^+Jpu3QD$bZC(ar>#HrS$ff_c}>$$LW9 zPh7Wp-!7TLi4Q$Gy4^}E4Wp)DGb5)gh@R=WY3dWz z(3E8=steX_@`-Z4{`Qu{%xfpTypR3e|5ta}oLVtI`_DX@=}Dg?FD({Xb!f@ECsCc6 z<=WdtW>y(Z{x;X_txZeon+vwUeZjVE zWrvS`$EurOdgC{>`fIzDT9LQJA?xv}dv4v>AQQO`8QqQ`0nYqy%GxL`M&z$Edch)H0J+u1L-T0}Vf=0n!p^1|w%)Ih;@48JQ zrcYIG?A2z`Jn8A=5gq^AW68>GLiv042`ryye8f?C!OkZlLSlW-ZNW!#^&jj z%LRWp|F>7a-|7BGrs{dA>8dkJ@=lyTTgzr@YFg$zU;LN5VrWnA^$Y3s|Ns5f(9{&1 z>=IjgHFS~H;3<)MVF*Y>(-}lHOR}NuCeby^PN;CO%&OmhIke0ka?OTVVXI^;p&w zFSqh`)n%_QO#fv6_Qf&Pt_@eX&&K^rTJu_T=C#P^8|{1BBQyHUw!PDC&(?m~yeWY;p{cB|R;|eXw9#M8IBxRPSuIJaObS(YYDSwi7_VL`D*L2&X3xW_J9W2pcYN-< z{qch{KiA64+Ox_tQ=E0`{(YX$J+X3A=+vdNCi~gBmYCY-UP|$?`^!^os@CcIw(iTE zKIhmg+jDp>*xujhV$FCVn_IlAn%CXv-ooE&Crl8q`Tys0#hZ=CKdi6+%dNlni_pBf zUzvYi`qv*aK5x^!HhOzkeT-c8w{lq#rO)Q|bDOVN`ak%0^ZbK{%f%DU@B6Fw|6W8| zk%dg(>%xe%OIu!;*#2^vd7&XuqV3jh&`sT(8)M^GA1HUY7Xj$;lrV`~5cE@b5S?+5hOz=kXoe z#rJCVtuC7`zcS*q&(>S{O2scODDL_H@Ar=9^Qr|TBsw;2GE&rid9Pa~Jv3Cb?9GkF z>i2uaxy5t@uFg(&?JwDH_dX<5rh=hh55{ zbN}>jT->T{6#UN5JJ$H=yP2|Yb62d_N}rjjed%{l(1h+&law}BNaxP<3{;m=n(4Wy z&nWkniECO|WKX5lC8yxOJGZmv9XiZ!tFmP2&ec1o$p8EBzu+LNc+#^ovkM;gnm?Yt ze;2Fx@0g~?eb#X~;k)Ph&#P(M^4Cr0=O^Qk=`)|amSdf1IdS3n-{JAKt-s&zk1sjB z?Q7kIs$cAUt8S@$v=sYqYj^2(`~E#YwnfI>`#U}T%;_w-xxeL0WQ+DyO@VrOx6$N( ztv$P@6}ze(HV_ug`oBVUi)kBZC-_qRWAe=LHeK)U{}cUijQ7XC=G_G)Ut?s}oVzYM zX}Ly`W0SV!O`Ewb zs2MV;;Zmx$R+@WeWTux(Xy7Dgqsy|2FFh1DN(*h_{a=}@`0vMy#Xr8Te}AagIRA*R z`P~-jb^95O)BZ4BkEvc;QFqq#`lG(v`N!YOS8?du{Fh<6R@43G4bR8#W|xG5c~ARz zfBO8n)#2Q!Q$g2w_E4W)76PtGB-LcS_o{vSYU%S%ryp@%8#lpx9(pjZX1(r*> z{ia0+!m1p0+zm9j8)anw~rv(e^cP!>?ow0H8vd!^loYtu;x*M(J z{Zn10zV3vNkVrfK_YQ$(qsiLFxvsVO$Fk1&2=Y&!IZtZZ%;pRBeE+{Jw-@~$a?auZ z+|LA7Gv)Jp+N{&~-v;IBOIE5q)%XUvjSTF_T+l|H8J&Lh_4~c!FU%Bd zXj!VI#T>`9EBA|-!8@k7y+QZhpPlKP^0Z)KCx_eWTg{U5-YdV`b^=<(LcXluMeMRdHinq z{oW@}Ql8GNUSMdH9(?`NaoP1JLN-_3)_b`>vG0FgF08I-PfpVgs;$7a@X2A*QRZmb-~iLDm$I;o{?T6 zAS;`jkucLTXAQdER>mCd7X}CHf*K^TN~_BI}NQsj2F#>p!7YD`**%X?3V#jid+x+|%}d8K2nE2q>d?Hxv!xepl?Pnb1JYE9&3w*L(ekZZcZ!+V~=6n0>Y(8I8-|@JxHZoJY-0Nr7U9*>Uhko8)xpvzm z(DwRokjt4IV}3TB+u>L5$g|-^TBJ{#m0{q4iO$bnoymLYy>07}JG;yE!;`BdS##Y# z_DZMQynmCp&~nm_OJSGnbuJXhi7eW+iD|3o(wLc<#-=M)YbDQoshT)*<@tYqe}6os zz5W4ctU4+xYRcAW7wil6d^lwDbms39Cv=NEQ|fkBbr-Qb2c1!EA;WjPE&_C`j75ib z`~}JDFZ4Z`l=iX2?e!9y+arAw&ZXYT6;I+S!_6Yylv~%|% zTc_g9E5-lnl?bP_J@ShF`S<(%g}Zi1O`R%Q@%pU!hj+L07w%i-16d5Aw#Y!gJ$0FQ zOX{-R6-u8^s?R_2`~Cj@^m&zRj*gB=pCVV?y0fR!SWG8k!JC(HFUmLVf35JY`jcyH zVdmWP;kjXPFRixS^5Sbh{O|@pXxi+U?$)()e-u@(;?R}X20MH4{&fWbDM4cYVy4Zs zR6Hfe_vxYSqtf=rVcZid9~@wmlP{JL416iKaOdi#c6kX-P)by}ARv&Mnre}M%Os}! zt!a{v$F#(4i_7%$ALc!ilb<&8@Z_meTm9{RvUpF^IT)S4xApDq?a5D1P5towyLi&& z*e_mhg9=@F?LOb{*uU?KS>5a3LhJVb_q_A#hD>bZ%4eVR%@^+fe`Eb=cxl<*i7qY}s_J6qG(K{OXHY1$G_@O50c7u48Pj8}hBFp)cyEX^=-sS!t=J z^o)-#B~zx#8Hsgh~hyrvqur<_w)b{;?T zrD?+%8M$9?wr}6Atg6Z?C@9#`+38qWY5D8duPL^7oD(uk@_x+K&#SK~`1UDU?zj26 z-T(71E#7zGPsP(;f`T(!V428xkl)7LcD%Yd$~gfy(pL({7E^$0&IouZQcaM#Zn`_IiZ zZ%$P^+{2<`Ec`ip;)HnRY5k(%`xh7=HZE8pd|!2qtd`N$J%w|1jO~BhUf3L8IhA)3 z)0t13&5wSSbd!9qxca26lkw7dt;<$J+@-{8s&VSHTV)|*cv_WXL|T=e$+U0X`SpF$ z^8eafFVuWk8D6ktJOBT>$I2V;y?>qj-@Py!R$9)#ICRM5 z&;6H}U#qw_etP?_D81O zTi{D)&Ay-O>w6Xi?Xu&wy4`vrKk5t^NVFh{Z1Xtzwg+(lBR}d zS#06wsJpLYwEy=j{ZnjdN&&YP>Z|mdZk=oMd9OAk;EZ@pFD3W>w7qrE=yLL5qsyBY z-al@TY+U}?2X{*;hDX@^~``a_=OpenF_WT6jXO3U$ zulJvll~4E=u4%ENZ4SYjNi`|6{GER!ct1 z`b;~Tw6vnP=SKa^65jia?8et0yyb5?Qr3Oy%)@hEuSrSpd%bsc-l1c>bndJ&Py6fQ zY##QiM*Ak-o4@v!u@R54jEccl%dnWgP3LYb+i&F_pAeP)TKd50Q)iw{`Tm~e+8I`} zJv=&pPd}*AykGy*{ppJ{E{m$}2gWK^>f8*vqg~q=>UY4RXEo?})#sC@&T2VyM+dY? zW#cj?$Jy66ocVHL!CD(J3z)cXx*LcRyCJN&3-)@Omg z%XljuPi6h_V|IYVsoNLU`^3Lj7**{NLSbCS^fA~E&4Qt`HLWyZJ z=bd#(=ib*JxlUg{w^ij?_w=X}-o;`uz3;c_1@)ctaq+tKb?K9hDl7YT&$jLcdni4q zS;SdqlJMiev{UYxg^XtF<2z3sQV9`#;o)B1n!3z6app_eFJEPkX3OgxJ=0$E@#?O% zr=mdT96{5$uWs!=Z z$Uk{qfvHQs+)AD_(}Lr$_~(x|!@Uju-wn9Z^_J)GjmH`P4+zZkeBH+iDhGb?q_Dl@ z_IBTt#rNT-@`2_2jPYmLw&@;nIH&jY#2FX0^{(+=a^5RmO)iuPzwI?`X5aFy3)wU| zH-a`*7arnN@7VLBNk6M=a$w+$hE+`}wLdK`CC+>q5Rq2J-`@Y_(f-rd7jN1Y7x@@; z$rHGYnjiQoEv|dP_GgcKORu!byvR3DFe#qzyHR~!)oH-yRw7@y#nd1_j$#MT@ewh__ z>wL$I7f~7RXGNSQ?K)Yno~wVSfpvY(gmn(Px_U3Y4wHQ90n??u6AUa)nut9YDXVGne2ZkbNSq^%;)o(E6@L4^h+Wot-?p;&>~&u*I&Mwwu9sL zs^R;A+FSa>5ofHoMIXc#-@=|F;8&Phndb zv)$saC46t6SijdhnEw9NNgvKx51x0MZ*aKPul9rU%-!WV0UKs8ykK6VxKrz5KG%`k zC2vFKW)Yx(PJ~?E_I|>q>7RG}Vx7A& zWx-mvaF1QH-UpdoOb9=9QPNFqVOdJ6nrwd|4>-opwWKU#RG;@l`Ok-T`y*F2pBFN^ zeD&0s_2#WF{+UnSR8!pXF54v{%}Qk2%pN&dsL?LQK<%818U zXQ!y{uJ{ugl~Z+3#hrWnO1va(!u#L9Kg7OLi`9JTwa+`IyK!Fgsf&`wCUu;eC^~Cu z_0HWpl_BM)&6Trrw2ZfcE;QP=!c6tGx4U)ZhpSf z;)BOIbg!Q~bI#yQLCM!Ij~cHtwRN0n|F>DLo$tG5wPpY6_zN=~e|0}<Eh2_P&p-ym0Q#%%U;>^zT6VCWdTy4}nXZFQ8smrPl z8%@3lZ$W-!nt9TI=dsPiNYNi>S7)D9`n-=dTX1&pQfQ(w`rE6zE9lbH8u_oHY`3zf zC>!_W@_nsq_gywoc-BYLJ9qEMt=l$L$5@vCw9k94(>`we(YdPahaVQa0pI)gcv@Q@ zbA;5KzNd;aGj3g8x~5bXlvEFt+s6K|OI*8dMu{r(SK-n(g2zN}@qJ6)85r-kYtkp% zJ9ld=AW2p(duHXiD=RCW#n-!U)ts8TjJ+*YZ82yKA4*T*Z__yj=AVHem-0Mu)o@Rh zIDRruZs)5rysj~GEoIhin|jS?^Rh)OXG=E;Y!(-CTg$sOBXdQerV9Jj)V+_bMPJTp zJ#tCrb41c>nUif1-KTse%~TQ7<`P;W@#MHu)U=~%qP{#S%G~?8L@ovWu4()d6TAMD z_s<(yONuX6Wm;Odrami3oQafYHh|Y0354%o;9tV_&GfaGXZWYuLsEfHPa3Qfh_Wkk zx_sl!uOGV~L+@I7cPl#}FwZGA_2q|^T9bqHbf+)e<`lQwZkN!~D_Pm|X3xMxQr%*EeOa|JlmClkIw3HSfL3 z=eb)<%ona)cy6o7wZ$9v@m&p_y5s-$?k7JFJ4m!HYis{{3q(v#a1{>hyD?EKo|W0L6) z-nw?5IbLYX{Nmj^F{7z{E6erVovzQGGDW3tdA;0mr$ehZZDN`p_e*$A<+GW)o^Rrt zTm9khr0FlW3M$SHn3nI5%2>)RKa16S%0|Q7HR~r#TirG3KxwJyaLLgnn}6yR%I>o+^03r8EF-N<3Y>513zrJpT?Zf#$%fWf-_ol9os%6A{<`lhC; zHZ?a-oH66Yg{@DXNx8oGdt$O&?=hK_^ree8?qic(%bSs(d*!V3{g#LJ+U2(+bJ}P1hBGN&He3w@9Ts=I?vAoC=;p{GxwN}B zvUYZMA9j9|J5y&Yptd*hoYkcbCFh!FR4&~z|9r`rngqUS%Stt*-zJpKlPo{q7@wh9 z5mDEcc&AZ2{(`#EU)f8)7b&a7de$d*7KOA~mVNv7<*>bfe1O&LHER1_Z#Xt}+oUIc z$;rxHU0fUU@7uk(x!HZms#RQWiv`*FWE`s9*`MwGEh#teZ~mR%*PlwBpm26)QZ-XRn=Q{yt5wsE6Qvu=*e%^TAkBXkxWQ48BB#^lJ@lvFG^^~?VI|D&@e z-U|w?-RiNlYF_cY75m=)-KVwk-jBZ@_2On^a(&afxVg4vropU|QdK3Tv~3>})7H34 z*+=V!2B&$xos^dO{>7AQPq!L*A3i1dRcOzf7KXu!c?OVK`Tb~X3|8!;9 zipbq|mznqzGv_W=d=@fuugCk#TD_msJhztSE>L|kkC`FB;?mWrme(hXI_dmqGn{ee z>OtH0J5-rO`eOe+D=-S5e^++u3x$I&Ms=^ec*{%no$2f2v*x*y;M2VMa&2uH|Iy-y zpN-x>Jo+hq{}=+~K2xT$zs9t6=FBgL&iGU%K1=`qEMKMM%Y-?W7rUpW z`^!#0wtT+InHG^tSEqJ#%(&UqK67EHX;7$X>?`ZwYAu)Bub1pv<;A4i88_Yb^s?yZ z*E}u@mbVMNp1SPzswJ;3^BP?<;=jnJo1ao3IrUs?RGP!A-*;mf7+4SaGw%)RoNJxN zqgGs+arv{+pNqWPLmCc!O3P1do1Jg#`ucX^%HY{+i6LUH>rMl z%vyfBZQIvl*{4GF&s=ex;wNXRvb^v6uTM`d9u~5iw*R9M@A0D0(;w@ey_&gCcvDRz zbE4Xk3G3FmxEwhnW^ty+gm`fyr9 z=&?0dCKR(ARK2CW+3%M7HhDi2&$AsDL>Lwb{K#AouC-D2bd$ai`}2zn{PXh!zlLu?KkgF)o=ZC ze%k;0_BHF@tMoKia5P8_T5jK&&nG8 zz5HMEY31gR_H{+GLjLcrSFiuTzF(jJ-wVb=J{vs4!GEdo}d&LI3 z7hYIea`bY)bhFQS_t&peF8bG{?Aa!B)!=io{O=hxkA&r?``7$={O#4V`XA^2&HVG( z{+Dv~(a-fC_3aFo{eRW}KjPiK-|w1QYNfjVzyJR${`CBRC+DAdyua$q-P!U|8~)#} z|DAsBb6wJx&*?$-?d);|hkTfPJs@7~-pnwe@x-p3L7}F>Qr~u3A6hGPLLjZrG=G_J znp9O9rp%i~LoR%gGwaK_Kc!dqtPYEu5Iay?OxiLKK{o~r#ug{)85<8@2w&NZqv-t+TW)y-{Z|JWT13V(jxGLdl#wD2=Ye6});(`V(RSFCS4 zryl#gwpQ}cnT>5-v!9<4;oKjre%ZKnX7#+O%eUQ`a8NjV$;Y>wuk|_J{C}wbiM#f; zQ<1d?7COHC{Z{|!x%ulR+x=!@U^sLpr(^C*Q?|)Fz1I6^sJ6bEyYi_+UE(r5h9%lF z@^9@rED@3RY5o2n?fm>VCTzJ&zddM_OKzC?=2Khzt;Am5 z@34OVo?i7Yhvfq-ZspVB z_`Ux2th%T@^SNZ2myXI=a_<3U9%y6^Qm#n2zv%RFB>{wHs zvU1D(Vspj?k37S}pT+w7DRFIWvfOL5aEi#P@@K}o?S(dV{W)~z?(XuN&XxUhEi~hI zzCM2JfYIdnKX36bO}_o(ymRHFBZ0{cGuMcx`AwTQ^I^e_e}5})|Jqk#`m33pKTY)6 z%a02ecHTXFv+`KPvF}V1I-$Y8BXOCyg&(KKXU+2Y?(+_x$zAu|>vNOTOtYg}%d=Kp zxpPxv?%%oJ3_c&ZGbi%GX0LZODa{%3z6=c|ppr+(*?irJ54TVEJWSEMq;2f~=0Csb zSxw&Lz!mpqwx7FS;=Ly3&y}fCJef0A?!E8#{p(G~i2E;%KYcI1lh31LZ25Wh=al#V znm-2Q?(yN%tN(p$^`@ksk3ap{?0MS$)csk!JDBM}(YG%zFBg4#bMwcx zeRc&88f2_WPVoLWYM=R3Ku9RXNf8$I5@~L#J2IACT`)`YYwWVb@SnO2Q>H2V8fni~ zFqvt4UMP*p!R?R91?|p*$=0CUa!I?G=Mvl0bN`p$`oG_E#>;M}ujg`Jr>@uuE;4W5 zzWw%rce4TSkG}afs>SbCPyhev=zA&W(;NT1kN?|f8*`WY+Ofl>>-Wj<8;9HMz4C8o z*3+WOXV?92tS|jIfBU_d={uI4arT{S{Q1PhdleSde|{9++>q#eEm6%ubt1F|&^^Pm zm1W7UtYufWE|=DloAm5vXprgdn^jZ2GQQ@lRy3y_BGT&auCjGo_;y%tYMeKd zVFGKN)QhPtcNbq0@O!4T*T~pmMq~)rMeE|q@(??jHE#|agOq&^r#>bhe{(e2MBw#% zdu8kIZ>Hwo`){0_V7V-QkF%Zm^E}IfA178^SR3+c!|U|B4?dau|MiN?$(piNclF!c z*gwz1_pg}F{e14#+s97}FaMSL`P^aFm$FqqJ|zDB{w8<6P35J3bA{5FdemZLJ3BgN z>UzzPjIs`2CMEV<(laBp_v(XH6PGE!<5Rt+?YlkH^XhVGmG;I*X1jiz_gQUdZaiJ_ z*(r(Qv}57c>SmH1;_!BbND0KJaPY5vLl1E~l^O-dKj;bUynRRHg4sKc829G=ulrx%u|` zd-v|WbXRsubw*v`J*MIe%eL)xpXU|w8ehG4m_6la@cvEAXTswvPx2W{Pq$UGk1Oc^ zGT$?XECF$M6HJ)qI>IyI1 zN--^FFnF-MF;0``Tb_B_kU3P3o_a z%b2`N^YYTf`Cp|j1$Eq;xn6$Vn-e#{No4Nax!)?|&in8ls;cI{tr%JVKXT{aKK7o4 zOE2ozg_pg#HCt#$@AT}rxYYKU>-S$%%8svdd?mjxKCY&&_2ZqSTicep|9gIT_0tC% zgFn{t+kf76X3uNMpSkJ&HTP{#U-SOm*Ziq>{`!gK+ul!K9{*dRPG|0~-JLw8AO>bkE_QdG7*O-*)~+5P3znWiNRjCN>LZnXYvP;8ri=0AU1V8;71%Xgnu zOx)WL-d%o0r`kMtl@$!~sPW#HvI~7;Vd{Y1K zQ2EpK|ML3wY_FU>$L{j{pnDY+3HAOqXKJ18FzamOt;Uk9yi|U+Kv= z|Hb6H?S3a-Z7)Bu@bS4t-t*R%znN&+ziwh~b^nR*x^J8AIvfA2u=@VyW^()ad+)u& zjp|>o-Ogdm$g^a%wxFQlX4e=74u*sxP=(KVgzZ=F!xTNf+?sQ`yvL6C&)B))vK{lL z=V$NzsZKAO|MP_I+D{*X!>baPtxKyrCR?@k@R_etvAoumKR*3)-uvn5?z$Wv!>cdj zf2{NQ9phm4bNl@X|Nk6)f9lNoGn3cf{~i1@^04uxowkNkc1?-fQ4kkq^HrDwdeDn5a@@NznzHK}uoAnMHS9f=p zAFuwJIivE)2|-S_=Cb!b`U=7P^0s>Q-{<}ga`NHT3$;G6=j&_D<92_4?lj5`bGUb7 zgW=LSK`RyC_DC8(IycAiaz&ixpVzzRPgTF3GtFAA+IQcV%W1pY(+d6@a!QLSW!0%? zd_VK&)}m>F$9-OXVOX_&?SAKHXIKm-LrQa@G$w|W&iQOrss>wrx`KW4{CJ!JU(BSY zQ&(6UUM+oh?PB7xbGmmo*Q`6UXX&X(hK$VRE!yQ@im&nTcH3+|Efe(Y(a&rv!SbgM z!CvZ>HowKVj4e?r>)ZG5-tqBCe8*Bse^i_;fA7y}{Qv&`*~;_(++L#ax!Z5T&*}63 z&f2p(b9Ictlwd9U&oj@Ti2wgJ{&wBF;`zq&g1OS~{asV?Rc5NZnFaU%j0Ke@CCB7~ z;&zwJwfXz%#ZSZPD*wJ)=|%tl{Vn?Q<70)w+WntaygG*q`qH{jHN4J#E-)?)Ud9VAE z)8%$L|CK*D-`r!XaeG(!>3Ux-qyLwm@7MkR@!I;++W9#h>^YB@-m8CqPp9@ziIwG_Zc&DK^+3?@^@3X%PL;B%-ChM zjV(pFeWtqfE9+c->+jqC|60AZY{p7kBm18_*Ps5Zo4;gEzxdMR=Ze-RLZ)voJJoc| z>?iy7_GK^s07{ z`TxN@e(KfrTP4r0EGgC4DV|pMZQt`JCr)0jWS=FKkTs#wu10U4?6z%R%V*3mo@^j| zKp8zSeBQhXOtzTWy>V^!ol6XAa%d%rtVbyf}58w zE4xo_oB8_BOlh;61?g%HjgVI5Gy`D<1@Av57u1cL+qX+HpNf&Od8HG~Y#?YUD`~Q8G9Xgv+^KI$%l&iD8vdql1xm)k|@3-ywQ zS|j1tb(bXq(q4av<~1t$yD3`gz!8_{drexW-28RsUH_d)TI}|vOU2XP-P>y|*Yi_0 ztuOiayKjHX^3SZRb$|QztNfd(`u~n_8egqpI(w1v6YKBqvn0~^7~~+86(W|?+RSY8 zc{DehZhKt1U8SM3d!0ufmu7L{1Gm((vtNQUvTpRY6DKF1%ep0%_R4hEuW~(G%?1S?hh?daho7_40Cm@AB_{RcTBT&;~BpYbvp^ zk)2TGKyY|0JJP_u-H4_ARFlfa~_RZ{L=Em?|@gmuE)RpM~-J ze|DR0depo2V{Y(_m2KzMna)nG{kynt_LZs6`(zcf0GJ<`dM;uz0AVX9=%;-T#j?ote)0)Hz(fo8f5_*cYWwvtz#;pRK%k z|Ni^$?jD~r^XbBK`Kz2ymu4}9HlOjGZH7@4)=9mXJhevrzogBUqq>|4kBUEC@=f-b z*&V*vIWKdvynX3Xw%GJ@b0)_BE84hc%P-f+{dIfyd_4C0+IjOQt>SSL_Evw7I_`h^ zXFN@$)yurgJXKEwU=PWgWiW_R{Ta`RnPIHu1|8oVz-& z2ZjbHsWnR4~_i*2>?zi%;rGk&?%YE4|ftnI5cChKg2-xjYmIv;udiY(75 z?e%+v{?~vm>dLHr8~Xe6#p1W=R=Kr)*Nn4wuDZ8)|NTleTle{n_2S=*BkfCfo4?%q zdh5OF{RI#A{Ed9I^H<%Z`JT>c?_=&>yMBIcO;}vbN7vswzx<4ftBb7rcS7m6jQSZC zhJNId;?mWrQ^WlE`0s(r1_|rxGoVVv*hJ^jhQl&JX|E@CPu&bkYeCI=yqDzaR`}U{ z?DRkJ{?EDhC+zN%ZOc z8BlK0-*-R&;_{G4dba3yiIL<$!tCMfBd@s=KS%`-|ODjZw+t!KF{<1a=Z5Mb#c#aCr?}caOroSU!Ny^?WtP( zapo6ezsmPj>pok){CzR~+Vxd()ter2{&fGZ@iFIBzg)!uK5I^6#vtS(4V3RQzoxu7 zqzP(S95&a^=5y4j4|{LiKIP^u$yIaqSAx@=l-@Pl9}8;!JeHqs|D*Z-^!lIr|IL4z z|9|#>LR*{q8v{RQOWrdfwR88pee1p5_S1~qS&x6+s@Uhq>v-I%{K>|g)21u0w>hb& zzT3WE_y3pq|Mbf`epc`QUj6BK{U80**VopTd8~Z?(s<@N)4g&lUsmq@bZpnT;KOJ7 zYv0U2_5T0A_qX>SuK(o!uhXXBe=SN=Z$?eb9p$2&-V^8Mcjx?Bc4FC1rQ7eyw|&o{6`YCC|H6{pt4oU(0IVB+E~>|FzhDN`L*I{wMzbuF9XjrW@UJz506PZDrZY zcM->Zo~!-8Vi4N={QlN?Zy#TK?_b|8wd>x;yYE50UWeCCYlVHmL-^ASgw^<)uerAK9$q^_RrdA zA%~wEzUS0^P7AxFtyjGK@00zB`d_R4 zPj=n^69ww575iqbSb2Cw=|8vXxRc!HCgjgO9{7HB1RaT^x`qH){EA?f0+5Q3V?bO$N`Vzw{_vMyd`H!vl{I{3x*g9?P`NglY z-_QIKxg|aMlkv+5<+Zk-W^S?34R0@(x4finC7*uEIP!Xy<^7-MVt#h7tk0`=n>X8R zU%qYfqnTUI95I&N82{Sf(=bm!S^SB_xW}2>&%kdl2-3)j-7ih zvh8Z}uQ~qqUzhy(UH`xS_udF3@PLBq86E94|2cYGUGw5L z{*(W(=)~8$iL2FJmE2v}zD2idrx!K`wv>0|{GTYDROk5Q?SApf)oOe5>+f+|Og`+( zYxZ(uBF=KJ z!*^yHZC&NHYE80fxtXWIBe~TIB^M`HzuyxK+O@H>>g%e#{)ac1->Zu&=+Be4s<^U*ovGF05>4^|y%gUl#qYzI^{-*6W{flb-t9 zYT91=d9HklgRsNJ`j`t=7em0~{hY??Qq9+0*2xH4?sk1rvHI$y-&;LCELWPne?G_E zOF!?0yO!Bz#eSbsf#vSP7x~i;{Cz7lb%M#=IDN;v<+d7Mjnhv3Dcvj78+rR$@%J@69$#~PuSFhp zbY|lJwrRg? zZh5cSyZ0_%a7o0y?Dc!o*5|G5b*Po&O{)8KdHyX%?~RqaZp#Fvz3yy06~U6R=Ks1Y z^E|@W#T1_RS+GCacHMP3>#~qOdHb}zc5?q$Ixb({#&9qD?b(@o?iW9)RPMK2CMUmh z?z@=tPaZ4BXRkl?!T~}-v(TMyLRW_gQvJ!wI^oRb_xXVxBmA1NZ7Zi7@;)%^KwNAJCNsV?&qE75Aw zzE-|#zq85}H6hDaZ&te;itW6=vF`e${mz*xXGA(-E3H z^3^+D1l*{8zt>zf;>v}f>h!Gqim27#H+~dV8H(!+ex8}2R+?}^yy!PCA zeP;H`g>%?K*u$f)>lB>2@!_XS#Ny|^YDw~H&rW=4(vY2IZ?xi!>tC5t(JycLEuOx7 ze|TQ_?`;tonx$C_-jaJ)R$ItrSGo!{xY-+&qq?MkW zrs;h*xs5jMGh2FR=64hC^9|J(k{7todhf|+vwCS+?Xph&!*nrC*T# zS)FHlbzIN;@G9M}c-&ht;~`VmQCPtvxW7f7bo_ee-U=V`==BzG838-JK>|!|%Je|39`zAb9?5?pj;Z9alcw zxqWTF^ZUP<@2{LZGp%~g_m%H=Ki+-qedX+*-}5IJ1g98WNe1Pjg7>@gJddj1ns`Th zv)in`9qh*zq`&;)@*yHr{q3?hneoj&{qv2zrTBOLzFfE>Fzx91^EU5OGeUhYK0mXk zmXkYWqH)vzDxmdZl&Y>y_Y-Z*va&@WzyW z{q%8`@#hOaXCIqj6n)>yX+~#9M@Pqu^(`|WAH28JZ^k3u{&%sdJbqk@zFAzZ&X}?E z`<1JEPtTp(zhnOWx95IeTKIk0_c)ExL^bOVzpK?9=DwJpY@@S%-sO4w4!mt$d08~0 zV7aaE-tS_TYDGnROW#eOQ@rPUx#I5$i`HD&8Mt@9Z0&xl{EScUt(seADmA+|gIvMR zpCTS5+Vo)361j=n%{;><=ayVkU$>IWV{@YFr+ZU37wK#dpOyFZ)S5|=+Qu;t*QLdM z_tPjf{p_$=WYzv<>logxRr_~X{$GIKJe!wtp8em}`0zPw=2e)w*uDSMp32W}UW@5Q zrSQjImz%b8+R}w9)}{UXbXxy(ueA9ah2Z<$(^hsfi_U(`{IhlDYd*m=B@lR~otU=L z^7B67WfithtL90F&Jc;2>wcMU)t#U6rz;{_k$KLA_YA#|%v)&=|DG%(cyX zZ{MBNP_i^RZ%$rMp@qLSzG-;c1^Vg@kLq0ApE-puERL|(BUhEF*{?_g%u`JQ+`r%^- zmT2VdKELz1b-d8cKeekvXJua7#(BbB@cGTX=kss>efB*{r+@FOJJ((r(x+JHT$Dz5o^h3qU zs=sckSMF-gm0j0vq`T+wB%!ofZ8O`KRBkJ``Mp#3ux_KvPS!VxFU9A+2MrT%yS2Hn zpx}b-q)vqnBt6P50Z2$G7RS%cmi=OMzxNP0Kb*rbo{`_K* z-LD#{lv(!U(b?QmHKkeZ0DKIeVqN)a&O$=ZmEtyW0A1f>s3VxL5st?>AF^yB`Z$4xf7- zx-Riq@9e{%#9zNTyGBe;%k*a3%r$9!9a9xn&s0_ti#v2Cs4#K4@Uj;H`Mkk3JcVa( z-xI$pa4vRl)dB}?&nZm@rIqHEt5#hrwN0t(UmEay#+CF9_TJ_fWZs{@#C_^Br;%-` zU4ZUkZWB`%z4Q{%`nN}4|9zXm?|b?8xkDwTsj62eZ+yF63e-ECYyEDT31cVQQoDV> zDz{!Kol$jhY1_=Nk>_p&zDrzoF7)TC)$5NPc8@*a{=L`r6W0qXnLo|^b_M-Y@@K5f zR+;H^DyP=SW5&ysyuSl9RHp04Z+mWk_RDt`hJe0|yUR-FTr2(6-xT$I<|`K$movVH z&IB!dxRlL#*M|J$ z{-DMopV#)MtmBQ^rrea0-e7l{(-YJVQDZuOd)AV0@%xglsckc@WbQ^Ec)o7;yHh{R z*B?G@o_|j!KE=b?m)G3(l?C@(#EvC4YCN1JbciJjQE_u2Eau77u5*dLxB z)#R*wS9+S{$I7s~@8_}oSoVGP(wDDJd96}!H;bz0Zku2fogA*WOVw9rQS4VEV>z>P zQ;yz!`OIw19)sZO$u_@UEdKbQ@b0cs$?A(QPd(zd`Or|iPxFt?bA!!=%Pp6jxwiV^ z$@|QOpk{rXr&J0PLx@%s`z2>#K|}k^+{XRuw`#|IIQj6ou1ed$f z{@>HBQ>#`#;D1w|ALEws;7#rKd%rhr&f5KQZ(8QAT}3GY#xo`Bj{n+i&@$zwm5A#cWJ+wJG^?RKHFU)CcLGb&<8MWTE_fsaOV& z>MXV3bBWK2s~3SX)8XrC2W^f>dW46+swuSEGDmJEL%_)l*%hUV#TiOUzsz?V1Uokr zy`5=#?*-Sh+56_ckSyDAQex7SD-(;AUOBl4&wR@tb^fR7^O@>i>(8X{|2g*8=I*_9 zQgPFpoX=Z+|LhcZ*UWZ)yuSRVe`WihvfaN{Tc%$K^4g8pU!$(xpZv1+nL^?+*V{qK z9y7Nd7G4v6Yo*ytdu2htu=APIF3hm>e%dlqDrH^VUMo3^EdSG&m;3K_zkK=XRZ;o4 zeJLk}c5gJBRK#Z-|LUav8J^f(FQ=JQdWMH*`7dc~Wc;qf&~WPMqodtl&bhdpIsE33 z53f~ES6gFqM?PW18lUdti5Zpd-|F>J&A2$R(KYgXr*mz;@ zX8Hd=j(^PNlQUT#7k@7C8F%$(ZsYSYEAPykxogq;udjtSl$$X)bY84~aT(S--^^{i zx4}(Zs_~6vUuVwV>%acoiEI5mPfoP2XZF3C{y2}Nw}mG@@L%(Mvz`50*&ACA{E1U* z3h%Q%{Qax(*S6Hx6W%8+xGYj~C-Ls_w}n3(a|@k|=5k+oJ-2ef#o7)dhyD4#&V0Ln z?(nnk(-ybP4BL8b-yw;hwAUBDc0^yYDBkNXmABrHrN{W0_w27%Px){jIuqg2bo%+J zGtqNCFTWS2?R$6W?>iN-vx3d@4tFN8rxHW$)5EEEyV%RlOneo3mqQtlZ0GYH@St ziFGxK-X%Y_wP{L8WGU1nK3`Fg62p6K_ruv=ci%1A{`LQ=#qCUe+UeGJ&!2x=TT!(4 zXRVLO*5~uT?YsKHhdHPATX*#x^I(TE{W52(@_A>B-_M@EJmA~D2mj_q>~H_JzBA^u z&6$!rI}5K>hUHhaKVCoceso{oRfqK=ZB}8RaY5Oxmb;UVO^ooE^<0H@W-!yGIiV9) zE@_+DIPJ|nf4#)CjL_b{KD@DtojffnJ9HGThMHaqX|mruarK1P{&&x*>D<{}zWz?( zaoKOB-D0|vyr=8!ba#7fe&$LwXtU0gwb9#eaSOlnzkBWP(nl}zialnoy?F5C{yCPV zstgWkka_u=Zw~vcXP>`r9?#8<$Fug_UU>O_^m=*YHs!}z^~;67RCHgLslQtLfwQUY z`Mu}c-`ahyo{>@Yd195V96%N*0a+hvwE>IvPxl*9vSgVdbR&79NGy7ceEnVkuN&1dG+)h1^0 zRV+-IdM0k}-QS#R=gr-bzTl>os;up$4=*Pwt}H(P@^samw~rPyK0PW8ZEG-!mfyaHWra2) zgAsK8K21C=?@yZ29LqhY*3~F7->UYt*I%DLgYTkwi}K@l`_$(?zkF}z?0w3g9-mxm^H6nsVbpWI%C(v`_JC*&e!tqTkH9E_OA!*&wh&^{@eTRerIXE zY?+N+~YDA>XKuyh(@^$t3PdQN{ z{m_}i+QH2|H`>csd6El~<_gg>!ZJ-$Q@@{EJID z9s4YO{)tzgUzZt=x|f zuB^IW`+cd*_2-hE>zdYfbNBLCmF^BnOnY*81}L1<%Z1aJ7#f@r`Mxo{$Y8Oy&*2*{ zxE{ux->}ykxsU1JyLhYl(`{`J{(biSQLxktrRLvj-Y-{Na^~8>#Vhx|@^W3F zYI}t`2{b zs5=+Vo}V)_?)~|XJjREwf8sU{|88(bhJir_mRS#-;XiL}d-~17XYoZALEB6aRcm&)%#4x2AO7>6~X5 z_iXT9@;m*UY21XHasrn3bN7XBwB3HSl1+b(t^KpAGmj5Hjr;!jQ_}~r&($9_c}rgV zfB#?x8VqP=-z+^X)_E=HmB7 z#Ti)|E8qJ&-*Wa1dtEd6^WRS$aV1WNnS0MweoKGIRxD)^zfJ1nvg#?P-c>z+AJrWB zH`dqWi}BU-KK9vj?G%fy{IRR?VvbGu@Iw#mMccP|o=X*PnJS$)Q*>~iPsYy9d(%RL zYJVralo51zWuIeu*U+@C@814(w$3v`4<$a^B~?*0;pUAS8uE1!vocfuw9FJvoKg9{ z_0OR*U)w>G_y-gr)6!?{&RjY6=QpFe%7qONPI#B8@+e>U>9RF_;q}RRi}(%w%f2U> zUnpTVf6Ex*<+u0ko$z+U+w<=}e)e3)y_8Y8)_>KvOJD4g{bSE8|Ge%>ZB-vzdsXkZ zwtZEXV#+k%-hS|}cgMo-TZ7-}=QDk{n3^uA{eJiL&1e3|eVV|wEA;4GyA&SKs)f}p zpVxld?v>!BRIp~%EG^^wxvHCsO8!*^w^$?@Dqde{eCKtVO8bmHV{z8CtBoe4F)ny8 zDQo4d%TXH)f)5`_pI50Sr?!e)Ovhv9b|dNds+X$2ttV*L9rEGTHr*n#lOZ5IeYsB5 z7LRqY+W!uv*gpTnZG7L>Z3a?A=Ow1qxIVt=_rb^{BmBsOBRBcyOh`ZceD3*GGHo-| zZx^=T{(P>A|9$P%+F(_O=3Tz`%igU{ynoD6i8(C$-j|-IuXer-^Zvp4+ICm*%2)P0 zFYb4K+jE8C+_kE;dxO5tn78?>+{EDgnWv@ox8HqkGymVZE!SVVI9B&p*W3TPIrny2 z-9MWpje15B+wQFf< z`C@Q^8275ksdt*MNM5t6jeIEP|EcNV6NAhBQXW|bGqvjuojG{WvGU!{=Sz0(wA^Pg z@!yr)yX%j4pY6N+Y=Tj=`Fxc#EDQ|okZEwU?FPnop0=>un^XCB_S-IrNjJ9Z2!`eU zdbh$L<GdXU3DVK?Pv2UYN(OUnM*EP@U;yBjW*883}m%QTI@a=z8%4eJBduRPw z5cZ^C^3Pu%!^BlCrC<4Q^`Cq1o$6H4*0=mt|1T^$zwPnyXX)pIrCwZHZ!H9>1*C4@ zdNeJBsY^Dk>dSYJ*Q=~%R=+;8Bq6xE@!Ygqs%M2{EdSWFZrGx~YU-|SM{EyeGY8o& zbrUOZQ85yj^uQ=~PsW!EpKCPQ4T76b^-7z2RaaNr?75=&)GB}3y!D5lY|p!^ReryA z`r2WoXLf znC0bj*|co^m$E3PF5bRtr+8HU&f~wNEuQZ?$uwCgW!LAFch$H4#7qvHdwJvitRva{ z?y+m07T1xV`b? zZv1Z5M(6b==T=Obskv8g@ukhH!m{!Tn=Y>0e%0&pyi+eOPVf1)C1z{ZR1;Ivqt&r| z{QTbK<=bt(TyQSqSY=-t{e61Vb^hn#Y42>kXUx?8R!jC{iWioa27T5{|G4&- zUitGDb)C1T#l4GJ>+UmZO@v1HcZ1J2R{KRfI6HIs;pT$}rIX(A7GIsW+SAb^QgTiC zy=L)I5BXqz3JuU3_sl^=N7Sp0yK z*@e?nA07ma9g4TFi!bwL*3h4KGnG$s^VbtcIWJA#dqQN>x-X(>^D-t$#eT?+3~*vj z?2IY@?r-3x|K{nx$HzX!l}j_I8AA)+cZtuYK7Qxqkawc=i|B46w(cj2&#bN|25|&U zdl&zXZC3qz7S~_DuUVRwie|?aRwwMYFEyOA&T6^M-#vRils{K~_WVHS^QX?|y#B`7 z7JQKW?rp&L;{GDpj@|X{>ept@-^n1hsWI*x&;Hw>$qLhJ(|1XkI#?E%TBY!46n{Ll z_0){)Jrhcot~azC@z?@JOV-c|nYohLA}KTPuE>eAQur@Z`UQD}SX%{At0?*r?XiAT**Iae|hcuS@SnLKq5qL%PVVVkC|&X>Z-=Da4c|m;q_$M1f%Hqd-0Z^6~s2J zGijR{yejpUY_E`w;KUCT!&c5te7k#bs8ZFTuN^M;R&HK5Q`h(Rn(Kn!i{&#W-_i^< z`aCb~<;(5g=D(P7bbGk>*EwmNHyIZTr-k{?RNFj#n^(GffYY_5=l-rvy>=+<<<@oG z_w$eW%q;z#lxZ(C&7;2G}ZJ#H6^C6$$cczmuR~a@x=bZR>z2)b$S?^k(K3utd?){f+<$QM!pWhwdAtWGM zP}Nqw*Qi|2$Ud1-7o1SO${V&#xoP!hTWCDf6v_2+XY|k1{I^J4Hhekn$---{NdFee_{BhCy`t0OWrRV;p zc}@-$J!W}HW%DY~q-6T$@=%AgyEkVUwd^Tg`1Osat?S&l`6myb2|j%0%=tM_pEEKD zPlA*t*Xn1iT&T%*}sn7!sT&L6%3SNv6${zPsUiYr`aQhMWJhcL%NW;4@Wq5{;G7EUNhO zywxI-_x*-{@Al7ovGVRe)9>AZE?eAJ+&dl38vp#giEVTvDD3Y@=EWz1dy7BxK#MK? zf81JNxZupu&!U@t^aq{TwaFlHS)!ho`0hPPEi*TEd|9))|NKfx^VbH(v(wAsh0~a( zs6xt-HUABQZ9mL*>DPHXv*PMZ_7#gRoPYQ?U!`r=t8}AJ#ZGhHwAH=-^LKjEzca?t zbE{u4rBt1txBSfc5Eky64*KOu*4MzD^*evJMGGA`lKwuj*kR^c|MGCZ^*&1{l%^&g z@>xFp@tUc7-)(b`hQ-LIjBv&kUwm81Gk)S;G{H_y+EoMCWT^E9`NZB_UgDcQ7hdDC3N8?M}&5qW;5=M2t(Y0!NKKU-(6 z()+h~;UUoM!x!0!FI;yQt>gHz_f>V@_fs*6|9S+pU1Jrz_H*C+B5d`f=6-O}`Uv|C z_vee3b;;z5-u%5Ic$vBR+Nz%uep+^=C`!M0>i6bMB4{u!*E{va;cUv3E#s+iGdue3aXq(cU$$K%!12$d zud&6=K64ibJNsS!+noV&*|`f_Pd}Sr#Qrw)BBPpxc^=>C$&R2w5?j$Nko1+1xJ>BX zl(wD5a{~%bnXHVwptJY--DFliV^y(9*H}%Ba=VJxAGqYlxvuP<_wwtPjczIT#+N<2 z!n5Y8wZyZNGM{I18Z(}n3CX!;{|$ogeiT+u*ifB*uJqjeH!rxJy+6P4-e&83`QlV1 zwLKfWQ+_j_D?iLsT00_&~b3(K8G_f7$JKBEYja{ndrY+Vy9qc4mZ$vsiC)OZlEO zvCmp4F=gGWM@iOoZ+H@~?>bhpUdWT#c500{<0a9H?a5O`KRNu|_%5K%`ONh4@M{9a zt-FL66kzRzX^GDwlMmau^6HpWTu5B{w(;cNO_e)Na~kd4X;d9=Qm@uwv2sb{yi*Sj zo;^0fNIbcg{Vr%R`b7TZOOPd=+P^uCwfC-LVK9L0i_<$}bN}s`%;kq2Ot9b}Gx+$zE}2iGc;jD-#I&W?r!p|8 zLG#q1)|so$Nw2gOEuF~6Yf`E>;pDM@@z(EspMSjkaQ!Xs%U>cD9xa*7R;6`VzEeEU#T?wZ;*T{&!u$pv@e+c(pA zG>Z+zUKM&Xr{3FB`6;~qYxt*Muh;LcKI8J;b6zKc^H)#a7W}R;I5(*Lx5>(F z-@@y)6nd-OzVp0^HH}^t<~w!g>pi9u7GJs@q;9%BB=TwK=`EJLmu7CVDSBG?@YVvu zIcJVO=b2xt>JZDxFaetCwrxmUR&^+=CiB8_=MAPmE*ET`kfBhyD>mhQ(8S*RCGRG# zS+`=HcF*hc6OUi{zF%+C+q&1kj=T2lc`{|GWBQX7#^;yom3_IWI`Lq(l5?|?`TDLo ze;rcp8$GXjJ8|WEf9BxJzuk{`YV&@%D3Y?G@&oU7lK{J)C;h$B)0bblc5PQRkBjxv zyi+AB^ZO(Aw{(=~&Cv_}d`m@1F>}{iFQ(O1s#3z7Yq>l?+XYSa_)a~#=_}L~Y5zV$ z?5vLR>+GdfUJkSPT~(@Vb!@fQyiSX_9+?`Vb^G_H_fvO%WJy#jRb^;^dGkZ!vRk`P z@tWNIX}tTDOPk#$?YDO%#delBJgHcHVx7C|z29C+Gp^j5ziZYGw&hj(OI{V)`Zs&L z4PAMCDYy0Ithwt~Z_Td!J@)zYj}Nu0Z8^98<) zoOs9BSZ8hVs+4P|;+}61;`F*a=~&mhxj#Ss`*Zlrg{7yYlhz^kl63uMto)`^++@!y zV9IkR$Mh82x0|BnZtLcWaBl5j=P{ue!;m9Y2n@1oB( z6Tk0&DC2KuA8Gw!kF8+YF=l`LFEWMpvb*xC_IFskT-unIR`7l|C}Xnszb)}zozvFd z{xTAz>H;!FD$M5XC*My)@lr1ACI-&eP^H;XUq+#LQ{ zK55^s)>*I4x%vgHTs{ALu7KqwolWmG z({GeLk5zgb@6DyJ`&RLBya%s{X~UhW^`&<&D=oioe7}0h^y;}gjs4dYi)meYe&Khf zzyJC>C9eW1_lD2EnqWS)w?kBsx5Rkk=HuX<5Zm@Yn-+G{W%0#|89&$N*Pgy>&1Q9^ z=AzHad6#t-3OXNhUd22*w#*9xN3g6yb`8j1?&0)#1yj|1d*UuH&Srqf+lj8F4FMiHddcLju zOS#p;)i0%7)>vdNSe~A7`SY;}M%lNgdA&L1x_!$jS7smmGa>gYlGprnSuErup0drt zF)jVbT|>2G9^>u*-tQNRybCJ80;|izw608LV3-MA{oO5}w(8d6kInKs7N%YLGxgVH zxkqzFqN=j;&pdFNn7m#PAUmyMswVKjNR8vZt1~TNJb2KQy@a0!@ z?KDojbK&8`WerB+@zy7typY3vGmr6E-fEX4UX$2<-QaOObgaQBoW1(iT_Hv<_w$y; z7Bg4x{$*d}y(R`_4nZ{S*t##0(%(8drY^eHY;wU^`1Y?ZPz&|p_W4sHp6rQMa>&fM zH@$xSnbd>*PhPn@=XFlyMjnUnNlc56pI>7kU}W}AP*B@-E&EnGldF?WE(r6;-3C?f zrJ#jLoeW3Q-{1W6{8Y$&t3v|Ctp>sj3dSunJ39WIl}MXsUmv&bh1S-#W4203OC>Dp zPp#wk{i?$IC8W)4e+G|6@u#)wDrGO{L@PNIf)bs2@yEi`|Foy>{m{=2+Ps#=VJs;4 zcede~J!NlaeVJ>e=^XAaE&ZdTV{+`;|9-80H@+@hIS*W;f4uvw!N{GxJiox3MMKW& z_)^U|XRh+PznuEg5wu}Y614i`e;SW*=-b|segBtioY>!Cag%qx<-~2_%eVB+nt$Wt#Fq6(?+7&_c}wd|$nW33-?G_uGb$!6UGa79 zwd$zIjK#YK-#u}XHuiS4Xp6)e0Y@%zK1*!`Kd%fH@@ur;#HTrgYmRgM|k zl$};LcAwQ1Tu>k*t#M|Nz5lx(9_Qkd*PTh>FlJ;ha-PxI@#6lRLq4I;doCZ_yYAKM z>xxQ#W?SnnSLxlAnW=m=JHe!Rppt5X9Y½+^ z7pp|62Zb{+^!P$rYO{+$OTSiLa`5-h@$NQUaOUcbZCmp?8Gdg5=#tnr-Cp#MhhE(B z@Mj(n7f8zpUfd6g_ew8jTifR4-=q(F+VjcXdNhse!F*+LZsR>MR|7=cf>#-Ae*VL9 z=l7RpJBx45cKdqcpK#*$9EOHY*4zRYm;XX(N=nZ(6QA8UvTdzl+hoh%*MyBK?4C^H znqY07eE3t6e_~at-QB+vV}4B#J>xq?>&%~fkJq1mJ(oT8`oy}$RX*o6bsSf&Uo~x4 z-xCFEowJQvoLim<&M7nZcpKFe-q2LM%J*6Qwy0IBmU*4MwBUug`HmH*Zuv#*Re3RI zw(7&ut($h-zI%fC)`O0Yf2wC(T+Rd^I$w#!i@u!lao0R_CRDoY>$Gg1p)dDO_?JlVJa=%(~*Vni@{M^)KQ#&2>cP$T0yC0+)8k~0W&sV?vPX*o% zX?11OLfB`iu6cOh_H*jBDOxN4bfoSEPyQ601naO|d9F6@YbBp=v?(Nf3_fG$p#<^qf?KjW&-&5{=_jPml7Zs-XhL3KC z&#-=8s{1JKcFU5N^VD9~Pd@p@wqW1+ud&y-FPB@gqlS&Kgq`N4ZC51xp1pe((a@%C6SMZ z&iL=XE}u0evfISUN6Yw9ro2C&vGVI|)eWaQla=0nEqgF&)B446S<}8otp9RzZTi&> z#q&eAecx5RvD!xG?B_kdx16kbZ+U2~QF+{K7oWss`+Dqme_e7qYstRDyEMcmehWM( zc;Lr^`}VwdvctaZs*d?)a-!yU^&FpN?;C^XecvnlHTE1MO7eUoVYBC|fX}bu6^|<= zca@#gl}yOFXA!eQVe?l3$22uB!L)NbUQ7Nwvse3VKydu*E6G)T`}AZ$d1QLRnO~Oc zLl0F;EeUJ3(Z6u{Kyu>p>UAFHFYUZ^XTC_ChUde0FMfUfGhdvX-hRz!pLuOx`(C5T z%&zSP-g8ZQe`GX!GhfR2zHN)dhIuEokN?XQ)VKb*HT0X(_j@PL3)auoJj3Jtdd?A> zmeA0vtTV#}7APE1y0p_^!k5@iuiuBiUb0-}b^eR(h4*((ywAO~Pi6scU&o91Lq0A? zY*f#jnHO2>|#8)U{)&1^?0%=N0)0!cr-He&G&J#EA?B9Gx!td8bK6l1T3%b9|aPr*G z=3oD08c%ZIDud#GpSGtwe)l-&^M!6r$7QR2f4TAJY3{eOhaATa``o;)aQSZH@{yHedpUIhPu6uUQ%xn8BJ&V(xiKa!RubHhKyn0p2t6Wo~ z%hqz4(|=z3&HEzj%|}b))v4G1=APTk(4)RQYU#J&m8)lJ+j>u~&Qt?c4-tIOqBSiK zTz#wkmAd^idV5L(%WXeVvlBIc4QkJ9S{jm=rZ>&{;=c*D`t#Y^OB1P_3#Mj@Io%;DY+uciw!b^FKeY^juSVtZ@bK#fO z%8k|4=cXOAWqHjo88zwNk=#}2+IBBKlSl2ZRYk()XsZw-_Cw~qjVX5TYz}|;6rj61_m!)eNruZ&%zWtKDm>HhqPvd$Vl(d^nYi-KB?~N z)C-E{UV>>%3{$MP926A%>pi2hW5#lm3xADoe4b}G<;?LLCvEdy26)cdX{6C>eYSB) z{MJ3)*CpfTX@m9~&s>N|j!PTg&3|~qSMpcFR*=4fA3VSF1#t54cCWb=eS@|BO6*@A z{qM}+_6kz*l)2!!``unpSR=MrvHYtl2DxWIsHS#cChjH z-sZhDn7LC*c9Q6BP{_vLlb_n3boiaDLO5nsyVrn6?u9|yq&nGG758){)r@yt+Mzk& z%j@FmMQ0|@RLAs6oJErL?Vf{6^l#4-kd!O(ZaaDE*S$l$hV{FCE5Ga(NO{JM=@Id1 zH@2BRE4b9|JaI#`mQPq+@BzhV;FFbFYZO2S9`GPV3h$Rg##>gNl<<3&m1A@G4FCGB zlD%4+e%Vbty1na8efJ6ee`f;YU)$L(d;VglaIaD2zx1-%%_n27K717*fyI9xc<%R{ z`e5xZ&3tN2ZT6hDne#1QwA4ubT028~QE~aa8>&UQ2P)-4VJ24f##!H+a`W^4T9yBa z&*WPocJ7$~X?dAhC@rezG!_*6JcX^Qu*3D-Z0A!tvaceZP7860`#tl-pOs4arWTFy z+g|Lf>Wy2S+9RI&s(H-r@c|oqz>dmjC zKXn_%3hh%KYp%vYfouCUuhg~?=m5h2a6|n9PxW++BP-Wa?eN0@2`Xk zww#{n9Mm2o&ZX;p`pTwm-<|gRkAB;Cr+t?B`|eW@E^LTq5)nG&!_aW%0-L+b7kyBN zV%{YOeY@scr!NUaEh~O;OZEFHrNw*sO}B8bD=rri&<#GVm&Sj*sJ>48tV;OXvvwD1 z?pb~R)Nc4aOTz?9B9TfG_di|mn=85ErR9#-xsrW%OV(>JTFu!t!B)zBQR-{kF4vv* z|2fR&pOMMfS8tVop5koS!Zr^Hv8$Qv*V}SICuZlgOj%J zB2}HlcU}Cc*xh??u|)T)DZf82e|Y48@67^}EPmzsUAF_{U+;T-%}~P*ixV4PT*!>w zX+8gF(I(gT3%_eUDR^5{UpE6=G_it`-*%}xy+RAVU#b@C!lkbDV1vo_vh`L{FqrHTq1UT*P0ox^QH*wU4G9x{!_)j?f0rqUlVVws+;ivOHyfF zcz9{|PNoX0FY{U@R?R*Bd40^zAkd92<=XCCU;h|9RK(0kT*)8TUi)A8uE^Z0<6iLl zx%!nh^>K-53+KtJ*(~KUuI`&Zxq5!yWb5gBGei2{+6$JQXJeQVfs`hM(!wOZ-w(R= zQ_U9ao0E@!e&#af23e;WI^*Zwx_B91%6vS@8$+CU9~D|+1bn~ z^IpIESF~((+^gMpt}{%Yu;Qz^o$Upkd-nM*-?n)DofDP$w!Hj_Lq+Ubop)vCpBj)d zn8D^l7g}@L+VAvky!b$0db88}mEX@zmzfv;{CLf$lj@JJUA=l!?fvO0zOzoY_C6I} zf@$;i6}Z~{|BxjqS5_`@A-Vqh18$*xC1>vjb0}S^a(9sW?mA~}U)9RUTgm(NWD_&_ zu6SR#E7kpAQG-dk@HK(7Ln&(4w0-&6RUWj?jO8_|4%}=KasTO>ee$kNsLoP2<8#w} z2B)&7>x`XQhxH{=C-rpwtiZ{o`xZ9R=*%k?97s3x%Y z9dx~ByXN!iRdXCJ@6P|4{@gn{$YAkv>*tn@z9s&P&+I#TYo3w!v-SKt(|5k8efRNH z=7a^Gf3$rUnf8oztI_|{w~R|yor%4r@!38)clyp9r}w_7>NnYSJbnF(!h7roEQBKC z)Bo0P^R5j#?{ieEm`5wHM z%)UZhFJJqu+`raw%l@<-OT%7SD=BU4*mBqSZmsOJYt~sy&sNQRxkK~JmcwG})6+iH z@|$`kL}uwJ?~HM`fjsb=Jk(1 z{=3&7UTbunx3YTL)g$+Ve>eSH`JGY2)bMqFedE7ew$1#;#(#R%+h>BB+(&-(9ocVg z>44nyh)mqTFk$ypWgUhP!zoH<<`iBx{xq{X{MN!{`^;7z{~}WxYhd!g`0JTBc@qv~ zt>3$W}8y!zRzdhGy_NDU}?-uvodiVFGQoq%fYc49JB`)85c~SbyJ3sd3 ztodEx=<~XX<%|E4pl1J~^HU-G!Jd~wSQ+pqV2PCEYixoDc$tDlL7f>#+7=eExc@OUenzoccx zD}JeKw~t@TyOo|V&c`dnTbw3UZJ2dnV*L!u({BxLZoc#S=hK#%bHDAqmGW=yTA^## zH+Hm44i4*nu;}Kc8n5`&cQY-u&Y0|5e#X)N{H6wz66;p?wd_eVEPLb~BiCG3QDOh* zX}cO!-JK4HwyD<`?1~eOG`xOSL$S(#Z~49_AOBd{r(G*f>01(To?m!gQ0vUk)Ay@N zm&;F|)gI@=X%u>Q`SEA^`J17DtIlCD^gAz_gtN@WMm?DMJz2bVhb$;g_i~Dyql&bjmhA#xy2i}^Xny#oFsc2!V zV%_}X+M{(j>!6jRy$_awk-be97he8;Ur8wD`8y4TsAbb)&pf>O^xq!?MQo-2^Ni`j zw?sF!FP^qEHS6ZlWuVZI!K?}S%HBK6`mO!!{A}}zzPQz?8IeohT#R^ko?n;?^{0(!?Tex7}>gzAIKHeGOx6|Igzr&_j*7cc!yc#SxNl-u*&25YQGc953eib^W^;-QoHu| zml^XGKld{M1^*q)wqprH0}B=(GE8Uy55R$2%)hOpI)f%EDgBzxX2sypRPsO86Evv= zsefM1gtYP*8HBL7h{1sgGf3DOJRHDdgh&xD^@4$c6Fopym1gLOZT^2)gkiy?i@PQu zTmD#2 zW~|Z%EyWZ8Lu$_45 z;fBBR%NZD^p4eIZ{L;5mA!x=mzIp$CyYy!rHWr4dE!QfezGup2tdh1xQ^j~S&U0bF ziFxLJ;)fDmSTgvynH;zR*(-hQ)x?*PJqDlO90*-mzb|&*w-ef*cCydg&TF&DO>FOm zz(dp5?M!E6@Uek=bmM_nHHEh}T(30QTIcFArzcBxd1>q}OC$GH*F`3Y-@mIne`op9 zGoXQq*VXf;TJDd?w*LGs{cY|4+P1)LSyXBWc>ZzGJ4S{LJLRUd-Ch=TfAh+q%_bU}_b1fy3Ps#}y{B)B=i4`F zoHwNtGv@~D&)vs%qGf62#S`y0gx=e8^xF2_78#(R2OVb!>d1F=M3iugrt$Ieo<1L* zzlquV>&59-PhR!*-&vfr|KH73-ya{JzV5El;lu9^?zw2N*-kFc#p>14w8;LiTH6hb zZ^fx^p7y#_Y@#2i_PnFZuz_PcOG;g|i;CF9Z>*-5N{-kraJjuSFK%{(``h=IzW877 zoTp{@dbi1P)3@IZc4#P^{rm2ihEHNzA-vz|;^LyStCh<*{>BDvxsZQv`@>H5zVN-d zpwX!we-@ib@zV8$!1R4nb&_7)W7%qC6^`X*Ppfg z&XMn1+@yAGxxc*be^%2vQbD8ByE{Om39f_?8FPK#Q(w|?E-*;TJ(^Ae=qPIPOTMWS=#nF5%TxTUo)RS{WM$t z*6+{GhkepJcG{l#v#sjo7G_WVGjArc-Ce%-h|=4j^8VY^YpOS%iNB@r`EIW2otqi# z)345-tADorj)AJd{g;VlceUPc{wj2Zp(NBn>RZI`U9XDyycteS-FVpN^$(%fKUT(N zMkUm~SIQ0AzQ$eYb@tMI!ToMawr_Sn^sApieOAghrRVeIg;LdO{gcBaxuaE=+~03m zdMU5||E<)0e{22tjG2WZmi|8=F!fvYoT4vv=j@(Wdx5GtD^P_jD7f*3n9`X&HD3=; z0SyhARsT1aim#3vp;<8uBCOWq90yr~XBHzG}s&d&FL{%N*a_wKj7 zA-`@fIVYDkkHc?D#fPFro=m5!mZV4K+T3BB@g{}KxU{13?kcsJ`Tn01YRkn;JTD7< zmuFVd+PgYC^_Ix@Y74D1TlTHzzr8YDC`|HW;*;aGxy3QMKkN^Y2-QCTO0SawMh(JQ}W;eqx7pdRoQXn3?N~KgPbF znpSRFJa_Z@ea^w6ynbn3zNhBQKdV^k_PPE0H4j!So;p?(rEDWOXNl0%bFDmv z>@y0E91<$pKYg!u)}5bLn-Q%Ma00VB6Y@(xZepwN+fQ5F<0hWB{jg^(pXvXbtG=t& z{G3%CwljgtxHNw2nW&^!Guap(cYTwaYwWw~t=xiKp-szMPsqV5Ll>7Li=ulBgc%mZ zEQD1%;Iav|Y7-O`?A8PIlbKL-f{*KjRL!uok6MF`8U>zd0);YhVRe+xIQ`Do9$f|p zq`ncT;OrJnJJ#0rg#EV zZm$)K1GDn8H|#TA+*xm)zjER(v-h`W|M|OW(l5R8?Y2VfrMcYMOA_aQjXLaOzGcckvz5Eo9<)q%nGp$Eevub7Nh)nuW0&ju zu31wq2rpRF`PJiaQ&?w&{^9KB-fustt=w<=v9?qA^^{+?rIv>8FBg1WZFTq;->ZKf zv#(U$KO5M?VZ8jzlwW$^cTX2etGXY$dPn)IC26VaH&0XrH&j8*!j6s(*X1zu8JXs;^UT0l zY^vNLp9YJIeN`*pd;GIgVV&@@Bl7;r>RWFZLi|im-;p#B&g3YcadFXE3TxMN3#PrQnQ341$=CYAnw8aoa&bS`?b-UbK=An)AK^5u zhkluRHoGnJo2mLax=Q%)naPLF6nGzyJZGhJCg)wh$8DR}&sR=NOiPk@bEaA%jqicL z#B0Cr78v?1GceXVsQUTs`v{ruzb@{b=iV|?H?ecg`l$zAUFa|{{`hyzwGERKpLKMo zPKKPE(w%2`$S1X;B7(2&-0jaP|L!*H-#GZJc7NUJd+`-+Ei>I`rGy1VWD0Y8-)1^& zP~2T&Yy9>@(D$?1lf#_PpIO1CVfpPB(~K<<_jmtXEfnl+(IcAXwlqC*tgJ_Yr(PHY ztEl*He!ax_`WBXiOM+9+wP!Cay)tFT%fn{^PRGo=JxQw0?bhq1`|i#ZQ<^<*rrEXc zHQ%PZx|q1^ug~VCn#Z;J;C)AMX{~(5M#L(geflne3CHIpr@yUr_m406E;8-KJU7mo zRZ&6V68&EJ-Mf=Ey^Fu{^33VwiOZ~cO#fy*>of>n&3V4+PAdNeqQETYF3%DMXNxR&xDBW-4SS4{NAhj-5Gh4HQVFuuYGE)7GX$8LJTuO3!Ya>4*BI@Z|ya_ z`fcvmK*cn!&Y|*;{>d%3$oPpJhpye`36FVUCdhz>mu6Hhj z9(oNvg~#>#t*zN*{!11vRNQUI&k)d=AzSh4iso)mN3)}2MkHvFi;Ig+=PhAP8NS=R zRSXSYPwwn2{&EVuk^y|Ai_XlyfB%*x&pX<{uwc>!EC1JaYngpZm6epzK(h!+N=nm0 z-cA+aYJF4rxI~esZ65Q16c=WnwU4)~yKL1j2RiX-A|x++uRABcoVR+ed+`5g5y3*E zRi$&Tm7W0|s$~K34(O;P!Hqg9XFw;(fbo${6CirQQm36D&IL>9aFe5nIsepu{_O{r W823NEn#;hzz~JfX=d#Wzp$Pyjb7nmN literal 0 HcmV?d00001 diff --git a/README.rst b/README.rst index 0dc98a379a3..be3e18af380 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ of a component, check the `Home Assistant help section Date: Tue, 30 Jan 2024 15:01:08 +0100 Subject: [PATCH 1159/1544] Flow rate unit conversions and device class (#106077) * Add volume flow rate conversions * Add missing translations * Adjust liter unit and add gallons per minute * Adjust to min instead of m for minutes * Add matching class for number * Add some tests for number and sensor platform * Add deprecated constants * Add explicit list of flow rate for check This reverts commit 105171af31e5b5d70cbc0b9c18fb8ef2d24caffa. --- homeassistant/components/number/const.py | 17 ++++- homeassistant/components/number/strings.json | 3 + .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 13 ++++ .../components/sensor/device_condition.py | 3 + .../components/sensor/device_trigger.py | 3 + homeassistant/components/sensor/strings.json | 5 ++ homeassistant/const.py | 4 +- homeassistant/util/unit_conversion.py | 25 ++++++++ tests/components/number/test_init.py | 17 +++++ tests/components/sensor/test_init.py | 17 +++++ tests/test_const.py | 8 ++- tests/util/test_unit_conversion.py | 64 +++++++++++++++++++ 14 files changed, 180 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index aa9988f8987..071f480f766 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.helpers.deprecation import ( @@ -42,7 +43,11 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter +from homeassistant.util.unit_conversion import ( + BaseUnitConverter, + TemperatureConverter, + VolumeFlowRateConverter, +) ATTR_VALUE = "value" ATTR_MIN = "min" @@ -372,6 +377,14 @@ class NumberDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + WATER = "water" """Water. @@ -464,6 +477,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), + NumberDeviceClass.VOLUME_FLOW_RATE: set(UnitOfVolumeFlowRate), NumberDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, @@ -477,6 +491,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = { NumberDeviceClass.TEMPERATURE: TemperatureConverter, + NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, } # These can be removed if no deprecated constant are in this module anymore diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 2d72cdbf203..ffddc0c2b3c 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -148,6 +148,9 @@ "volume_storage": { "name": "[%key:component::sensor::entity_component::volume_storage::name%]" }, + "volume_flow_rate": { + "name": "[%key:component::sensor::entity_component::volume_flow_rate::name%]" + }, "water": { "name": "[%key:component::sensor::entity_component::water::name%]" }, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 023f94ec88e..5786c9ee542 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -41,6 +41,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) from .const import ( @@ -139,6 +140,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS}, **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS}, **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, + **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS}, } DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 30c3bb31a47..11271d1e0cd 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -28,6 +28,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) from .models import StatisticPeriod @@ -67,6 +68,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), + vol.Optional("volume_flow_rate"): vol.In(VolumeFlowRateConverter.VALID_UNITS), } ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b1cb120e3fe..861338f257a 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.helpers.deprecation import ( @@ -57,6 +58,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) DOMAIN: Final = "sensor" @@ -394,6 +396,14 @@ class SensorDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + WATER = "water" """Water. @@ -489,6 +499,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, + SensorDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -555,6 +566,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), + SensorDeviceClass.VOLUME_FLOW_RATE: set(UnitOfVolumeFlowRate), SensorDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), SensorDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -621,6 +633,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL_INCREASING, }, SensorDeviceClass.VOLUME_STORAGE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLUME_FLOW_RATE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.WATER: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index b12cdb570eb..b7cf533d3da 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -77,6 +77,7 @@ CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" +CONF_IS_VOLUME_FLOW_RATE = "is_volume_flow_rate" CONF_IS_WATER = "is_water" CONF_IS_WEIGHT = "is_weight" CONF_IS_WIND_SPEED = "is_wind_speed" @@ -132,6 +133,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_IS_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], @@ -186,6 +188,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_IS_VOLTAGE, CONF_IS_VOLUME, + CONF_IS_VOLUME_FLOW_RATE, CONF_IS_WATER, CONF_IS_WEIGHT, CONF_IS_WIND_SPEED, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 1c0da89692b..c5c19a19d0b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -76,6 +76,7 @@ CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" +CONF_VOLUME_FLOW_RATE = "volume_flow_rate" CONF_WATER = "water" CONF_WEIGHT = "weight" CONF_WIND_SPEED = "wind_speed" @@ -131,6 +132,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], @@ -186,6 +188,7 @@ TRIGGER_SCHEMA = vol.All( CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_VOLTAGE, CONF_VOLUME, + CONF_VOLUME_FLOW_RATE, CONF_WATER, CONF_WEIGHT, CONF_WIND_SPEED, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 1db5e4c8cfd..fad1086c034 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -45,6 +45,7 @@ "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", + "is_volume_flow_rate": "Current {entity_name} volume flow rate", "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight", "is_wind_speed": "Current {entity_name} wind speed" @@ -93,6 +94,7 @@ "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", + "volume_flow_rate": "{entity_name} volume flow rate changes", "water": "{entity_name} water changes", "weight": "{entity_name} weight changes", "wind_speed": "{entity_name} wind speed changes" @@ -260,6 +262,9 @@ "volume": { "name": "Volume" }, + "volume_flow_rate": { + "name": "Volume flow rate" + }, "volume_storage": { "name": "Stored volume" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 35cd8a5e23a..8db9be36902 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1042,7 +1042,9 @@ class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" CUBIC_METERS_PER_HOUR = "m³/h" - CUBIC_FEET_PER_MINUTE = "ft³/m" + CUBIC_FEET_PER_MINUTE = "ft³/min" + LITERS_PER_MINUTE = "L/min" + GALLONS_PER_MINUTE = "gal/min" _DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum( diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5ce31b072cf..15912fa2f6e 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -21,6 +21,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -39,6 +40,7 @@ _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Duration conversion constants _HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +_HRS_TO_MINUTES = 60 # 1 hr = 60 minutes _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds # Mass conversion constants @@ -516,3 +518,26 @@ class VolumeConverter(BaseUnitConverter): UnitOfVolume.CUBIC_FEET, UnitOfVolume.CENTUM_CUBIC_FEET, } + + +class VolumeFlowRateConverter(BaseUnitConverter): + """Utility to convert volume values.""" + + UNIT_CLASS = "volume_flow_rate" + NORMALIZED_UNIT = UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + # Units in terms of m³/h + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _CUBIC_FOOT_TO_CUBIC_METER), + UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _L_TO_CUBIC_METER), + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1 + / (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER), + } + VALID_UNITS = { + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + } diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 4de47b9b844..9c66b45df25 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -32,6 +32,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_PLATFORM, UnitOfTemperature, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er @@ -686,6 +687,22 @@ async def test_restore_number_restore_state( 100, 38.0, ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + 50.0, + "13.2", + ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 13.0, + "49.2", + ), ], ) async def test_custom_unit( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 8e28a4fe382..3172759520d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -36,6 +36,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, State @@ -580,6 +581,22 @@ async def test_restore_sensor_restore_state( -0.00001, "0", ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + 50.0, + "13.2", + ), + ( + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 13.0, + "49.2", + ), ], ) async def test_custom_unit( diff --git a/tests/test_const.py b/tests/test_const.py index 4b9be4f27f1..7ca4812ca8e 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -103,7 +103,13 @@ def test_all() -> None: ], "VOLUME_", ) - + _create_tuples(const.UnitOfVolumeFlowRate, "VOLUME_FLOW_RATE_") + + _create_tuples( + [ + const.UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + const.UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ], + "VOLUME_FLOW_RATE_", + ) + _create_tuples( [ const.UnitOfMass.GRAMS, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index e7affecfaf4..08d362072d4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -22,6 +22,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError @@ -41,6 +42,7 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) INVALID_SYMBOL = "bob" @@ -65,6 +67,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { TemperatureConverter, UnitlessRatioConverter, VolumeConverter, + VolumeFlowRateConverter, ) } @@ -103,6 +106,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo ), UnitlessRatioConverter: (PERCENTAGE, None, 100), VolumeConverter: (UnitOfVolume.GALLONS, UnitOfVolume.LITERS, 0.264172), + VolumeFlowRateConverter: ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.06, + ), } # Dict containing a conversion test for every known unit. @@ -413,6 +421,62 @@ _CONVERTED_VALUE: dict[ (5, UnitOfVolume.CENTUM_CUBIC_FEET, 3740.26, UnitOfVolume.GALLONS), (5, UnitOfVolume.CENTUM_CUBIC_FEET, 14158.42, UnitOfVolume.LITERS), ], + VolumeFlowRateConverter: [ + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 16.6666667, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 0.58857777, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 4.40286754, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.06, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.03531466, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + 0.264172052, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 1.69901079, + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 28.3168465, + UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + ( + 1, + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + 7.48051948, + UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + ), + ], } From bc720b48b476d2fa1ad754166d6aca72e0d180cf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 15:07:47 +0100 Subject: [PATCH 1160/1544] Add TURN_OFF and TURN_ON to ClimateEntityFeature (#101673) * Add ClimateEntityFeature.TURN_OFF * Fixes * Fixes * wording * Change to services * Fixing * Fixing * Last bits * Review comments * Add hvac_modes checks * Fixes * Add tests * Review comments * Update snapshots * balboa * coolmaster * ecobee * mqtt * nest * plugwise * smarttub * whirlpool * zwave_js * fix test climate * test climate * zwave * nexia * nuheat * venstar * tado * smartthings * self.hvac_modes not None * more tests * homekit_controller * homekit controller snapshot --- homeassistant/components/climate/__init__.py | 143 ++++++++-- homeassistant/components/climate/const.py | 2 + .../components/climate/services.yaml | 4 + .../advantage_air/snapshots/test_climate.ambr | 2 +- tests/components/balboa/test_climate.py | 12 +- .../ccm15/snapshots/test_climate.ambr | 16 +- tests/components/climate/test_init.py | 260 +++++++++++++++++- tests/components/coolmaster/test_climate.py | 7 +- .../snapshots/test_climate.ambr | 4 +- tests/components/ecobee/test_climate.py | 4 + .../flexit_bacnet/snapshots/test_climate.ambr | 4 +- .../gree/snapshots/test_climate.ambr | 4 +- .../snapshots/test_init.ambr | 36 +-- .../specific_devices/test_ecobee3.py | 2 + ...est_heater_cooler_that_changes_features.py | 7 +- .../honeywell/snapshots/test_climate.ambr | 2 +- .../maxcube/test_maxcube_climate.py | 5 +- tests/components/mqtt/test_climate.py | 4 + tests/components/nest/test_climate.py | 12 + .../netatmo/snapshots/test_climate.ambr | 20 +- tests/components/nexia/test_climate.py | 4 +- .../nibe_heatpump/snapshots/test_climate.ambr | 40 +-- tests/components/nuheat/test_climate.py | 8 +- tests/components/plugwise/test_climate.py | 6 +- .../sensibo/snapshots/test_climate.ambr | 2 +- tests/components/smartthings/test_climate.py | 2 + tests/components/smarttub/test_climate.py | 4 +- tests/components/tado/test_climate.py | 6 +- tests/components/venstar/test_climate.py | 2 + tests/components/whirlpool/test_climate.py | 2 + tests/components/zwave_js/test_climate.py | 18 +- 31 files changed, 534 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index c315765925f..889ff8cddbd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import functools as ft import logging @@ -34,6 +35,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter @@ -152,8 +154,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service( + SERVICE_TURN_ON, + {}, + "async_turn_on", + [ClimateEntityFeature.TURN_ON], + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, + {}, + "async_turn_off", + [ClimateEntityFeature.TURN_OFF], + ) component.async_register_entity_service( SERVICE_SET_HVAC_MODE, {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, @@ -288,6 +300,102 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_target_temperature: float | None = None _attr_temperature_unit: str + __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) + + def __getattribute__(self, __name: str) -> Any: + """Get attribute. + + Modify return of `supported_features` to + include `_mod_supported_features` if attribute is set. + """ + if __name != "supported_features": + return super().__getattribute__(__name) + + # Convert the supported features to ClimateEntityFeature. + # Remove this compatibility shim in 2025.1 or later. + _supported_features = super().__getattribute__(__name) + if type(_supported_features) is int: # noqa: E721 + new_features = ClimateEntityFeature(_supported_features) + self._report_deprecated_supported_features_values(new_features) + + # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to + # supported features and return it + return _supported_features | super().__getattribute__( + "_ClimateEntity__mod_supported_features" + ) + + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + + def _report_turn_on_off(feature: str, method: str) -> None: + """Log warning not implemented turn on/off feature.""" + report_issue = self._suggest_report_issue() + if feature.startswith("TURN"): + message = ( + "Entity %s (%s) does not set ClimateEntityFeature.%s" + " but implements the %s method. Please %s" + ) + else: + message = ( + "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly" + " supports the %s service without setting the proper" + " ClimateEntityFeature. Please %s" + ) + _LOGGER.warning( + message, + self.entity_id, + type(self), + feature, + feature.lower(), + report_issue, + ) + + # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented + # This should be removed in 2025.1. + if not self.supported_features & ClimateEntityFeature.TURN_OFF: + if ( + type(self).async_turn_off is not ClimateEntity.async_turn_off + or type(self).turn_off is not ClimateEntity.turn_off + ): + # turn_off implicitly supported by implementing turn_off method + _report_turn_on_off("TURN_OFF", "turn_off") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_OFF + ) + elif self.hvac_modes and HVACMode.OFF in self.hvac_modes: + # turn_off implicitly supported by including HVACMode.OFF + _report_turn_on_off("off", "turn_off") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_OFF + ) + + if not self.supported_features & ClimateEntityFeature.TURN_ON: + if ( + type(self).async_turn_on is not ClimateEntity.async_turn_on + or type(self).turn_on is not ClimateEntity.turn_on + ): + # turn_on implicitly supported by implementing turn_on method + _report_turn_on_off("TURN_ON", "turn_on") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_ON + ) + elif self.hvac_modes and any( + _mode != HVACMode.OFF and _mode is not None for _mode in self.hvac_modes + ): + # turn_on implicitly supported by including any other HVACMode than HVACMode.OFF + _modes = [_mode for _mode in self.hvac_modes if _mode != HVACMode.OFF] + _report_turn_on_off(", ".join(_modes or []), "turn_on") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_ON + ) + @final @property def state(self) -> str | None: @@ -312,7 +420,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features_compat + supported_features = self.supported_features temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -345,7 +453,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features_compat + supported_features = self.supported_features temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -625,9 +733,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Turn auxiliary heater off.""" await self.hass.async_add_executor_job(self.turn_aux_heat_off) + def turn_on(self) -> None: + """Turn the entity on.""" + raise NotImplementedError + async def async_turn_on(self) -> None: """Turn the entity on.""" - if hasattr(self, "turn_on"): + # Forward to self.turn_on if it's been overridden. + if type(self).turn_on is not ClimateEntity.turn_on: await self.hass.async_add_executor_job(self.turn_on) return @@ -646,9 +759,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): await self.async_set_hvac_mode(mode) break + def turn_off(self) -> None: + """Turn the entity off.""" + raise NotImplementedError + async def async_turn_off(self) -> None: """Turn the entity off.""" - if hasattr(self, "turn_off"): + # Forward to self.turn_on if it's been overridden. + if type(self).turn_off is not ClimateEntity.turn_off: await self.hass.async_add_executor_job(self.turn_off) return @@ -661,19 +779,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the list of supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> ClimateEntityFeature: - """Return the supported features as ClimateEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: # noqa: E721 - new_features = ClimateEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 9c9153d9f63..c790b8596a9 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -163,6 +163,8 @@ class ClimateEntityFeature(IntFlag): PRESET_MODE = 16 SWING_MODE = 32 AUX_HEAT = 64 + TURN_OFF = 128 + TURN_ON = 256 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 405bb735b66..62952c5aae3 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -139,8 +139,12 @@ turn_on: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_ON turn_off: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_OFF diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr index 9e21d0ede17..28addf01ecd 100644 --- a/tests/components/advantage_air/snapshots/test_climate.ambr +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -40,7 +40,7 @@ ]), 'max_temp': 32, 'min_temp': 16, - 'supported_features': , + 'supported_features': , 'target_temp_high': 24, 'target_temp_low': 20, 'target_temp_step': 1, diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 6ba0661ae55..1ec85f60b5d 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -51,7 +51,10 @@ async def test_spa_defaults( assert state assert ( state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 @@ -71,7 +74,10 @@ async def test_spa_defaults_fake_tscale( assert state assert ( state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 @@ -174,6 +180,8 @@ async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None: == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_MIN_TEMP] == 10.0 diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 0d4ce32fb8b..05ae0ef5f70 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -45,7 +45,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', 'unit_of_measurement': None, @@ -97,7 +97,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', 'unit_of_measurement': None, @@ -125,7 +125,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -163,7 +163,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -225,7 +225,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.0', 'unit_of_measurement': None, @@ -277,7 +277,7 @@ 'original_name': None, 'platform': 'ccm15', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1.1.1.1.1', 'unit_of_measurement': None, @@ -302,7 +302,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_modes': list([ 'off', 'on', @@ -335,7 +335,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_modes': list([ 'off', 'on', diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 89826c98086..03d571f8529 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -25,7 +25,7 @@ from homeassistant.components.climate.const import ( ClimateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -154,7 +154,8 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: result = [] for enum in enum: - result.append((enum, constant_prefix)) + if enum not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF]: + result.append((enum, constant_prefix)) return result @@ -355,11 +356,262 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> return 1 entity = MockClimateEntity() - assert entity.supported_features_compat is ClimateEntityFeature(1) + assert entity.supported_features is ClimateEntityFeature(1) 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_compat is ClimateEntityFeature(1) + assert entity.supported_features is ClimateEntityFeature(1) assert "is using deprecated supported features values" not in caplog.text + + +async def test_warning_not_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test adding feature flag and warn if missing when methods are set.""" + + called = [] + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + def turn_on(self) -> None: + """Turn on.""" + called.append("turn_on") + + def turn_off(self) -> None: + """Turn off.""" + called.append("turn_off") + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities( + [MockClimateEntityTest(name="test", entity_id="climate.test")] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + " Please report it to the author of the 'test' custom integration" + in caplog.text + ) + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + " Please report it to the author of the 'test' custom integration" + in caplog.text + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + { + "entity_id": "climate.test", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + { + "entity_id": "climate.test", + }, + blocking=True, + ) + + assert len(called) == 2 + assert "turn_on" in called + assert "turn_off" in called + + +async def test_implicit_warning_not_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test adding feature flag and warn if missing when methods are not set. + + (implicit by hvac mode) + """ + + class MockClimateEntityTest(MockEntity, ClimateEntity): + """Mock Climate device.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVACMode.*. + """ + return HVACMode.HEAT + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVACMode.OFF, HVACMode.HEAT] + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities( + [MockClimateEntityTest(name="test", entity_id="climate.test")] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " implements HVACMode(s): off and therefore implicitly supports the off service without setting" + " the proper ClimateEntityFeature. Please report it to the author of the 'test' custom integration" + in caplog.text + ) + assert ( + "Entity climate.test (.MockClimateEntityTest'>)" + " implements HVACMode(s): heat and therefore implicitly supports the heat service without setting" + " the proper ClimateEntityFeature. Please report it to the author of the 'test' custom integration" + in caplog.text + ) + + +async def test_no_warning_implemented_turn_on_off_feature( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test no warning when feature flags are set.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities( + [MockClimateEntityTest(name="test", entity_id="climate.test")] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + not in caplog.text + ) + assert ( + "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + not in caplog.text + ) + assert ( + "implements HVACMode.off and therefore implicitly implements the off method without setting" + not in caplog.text + ) + assert ( + "implements HVACMode.heat and therefore implicitly implements the heat method without setting" + not in caplog.text + ) diff --git a/tests/components/coolmaster/test_climate.py b/tests/components/coolmaster/test_climate.py index 5f98082e822..0e306faa8ab 100644 --- a/tests/components/coolmaster/test_climate.py +++ b/tests/components/coolmaster/test_climate.py @@ -60,12 +60,17 @@ async def test_climate_supported_features( ) -> None: """Test the Coolmaster climate supported features.""" assert hass.states.get("climate.l1_100").attributes[ATTR_SUPPORTED_FEATURES] == ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert hass.states.get("climate.l1_101").attributes[ATTR_SUPPORTED_FEATURES] == ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index 0e7c5ba547e..26effb7cac6 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -9,7 +9,7 @@ ]), 'max_temp': 24, 'min_temp': 4, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 20, }), @@ -52,7 +52,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'Test', 'unit_of_measurement': None, diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 8572764ce4d..642e4830016 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -98,6 +98,8 @@ async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -115,6 +117,8 @@ async def test_aux_heat_supported_with_heat_pump(hass: HomeAssistant) -> None: | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr index cc9c38e370c..f363c99f8f2 100644 --- a/tests/components/flexit_bacnet/snapshots/test_climate.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -16,7 +16,7 @@ 'home', 'boost', ]), - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22.0, }), @@ -65,7 +65,7 @@ 'original_name': None, 'platform': 'flexit_bacnet', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '0000-0001', 'unit_of_measurement': None, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index e28582ca2e9..fa35b5c1111 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -32,7 +32,7 @@ 'none', 'sleep', ]), - 'supported_features': , + 'supported_features': , 'swing_mode': 'off', 'swing_modes': list([ 'off', @@ -110,7 +110,7 @@ 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aabbcc112233', 'unit_of_measurement': None, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 476ab390217..29b71d18422 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -3067,7 +3067,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -3089,7 +3089,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -3775,7 +3775,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -3797,7 +3797,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -4188,7 +4188,7 @@ 'original_name': 'HomeW', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -4210,7 +4210,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': 22.2, @@ -4853,7 +4853,7 @@ 'original_name': 'My ecobee', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', 'unit_of_measurement': None, @@ -4880,7 +4880,7 @@ 'max_temp': 33.3, 'min_humidity': 20, 'min_temp': 7.2, - 'supported_features': , + 'supported_features': , 'target_temp_high': 25.6, 'target_temp_low': 7.2, 'temperature': None, @@ -7004,7 +7004,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': 0, + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169', 'unit_of_measurement': None, @@ -7022,7 +7022,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'target_temp_step': 1.0, }), 'entity_id': 'climate.89_living_room', @@ -8431,7 +8431,7 @@ 'original_name': '89 Living Room', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1233851541_169', 'unit_of_measurement': None, @@ -8449,7 +8449,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'swing_mode': 'vertical', 'swing_modes': list([ 'off', @@ -9509,7 +9509,7 @@ 'original_name': 'Air Conditioner SlaveID 1', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', 'unit_of_measurement': None, @@ -9534,7 +9534,7 @@ ]), 'max_temp': 32, 'min_temp': 18, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 24.5, }), @@ -12059,7 +12059,7 @@ 'original_name': 'Lennox', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100', 'unit_of_measurement': None, @@ -12078,7 +12078,7 @@ ]), 'max_temp': 37, 'min_temp': 4.5, - 'supported_features': , + 'supported_features': , 'target_temp_high': 29.5, 'target_temp_low': 21, 'temperature': None, @@ -13027,7 +13027,7 @@ 'original_name': 'Mysa-85dda9 Thermostat', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20', 'unit_of_measurement': None, @@ -13046,7 +13046,7 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'supported_features': , + 'supported_features': , 'temperature': None, }), 'entity_id': 'climate.mysa_85dda9_thermostat', diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 723881ac182..a4bcf7e962e 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -107,6 +107,8 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ), capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], diff --git a/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py index 79b07512c67..5d0f63b07ff 100644 --- a/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_heater_cooler_that_changes_features.py @@ -28,7 +28,10 @@ async def test_cover_add_feature_at_runtime( assert climate.unique_id == "00:00:00:00:00:00_1233851541_169" climate_state = hass.states.get("climate.89_living_room") - assert climate_state.attributes[ATTR_SUPPORTED_FEATURES] is ClimateEntityFeature(0) + assert ( + climate_state.attributes[ATTR_SUPPORTED_FEATURES] + is ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) assert ATTR_SWING_MODES not in climate_state.attributes climate = entity_registry.async_get("climate.89_living_room") @@ -44,5 +47,7 @@ async def test_cover_add_feature_at_runtime( assert ( climate_state.attributes[ATTR_SUPPORTED_FEATURES] is ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert climate_state.attributes[ATTR_SWING_MODES] == ["off", "vertical"] diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index d589cbfbc9e..d1faf9af9a0 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -30,7 +30,7 @@ 'away', 'hold', ]), - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': None, diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 3f2b325330e..76ab214f3ac 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -89,7 +89,10 @@ async def test_setup_thermostat( assert state.attributes.get(ATTR_PRESET_MODE) == PRESET_NONE assert ( state.attributes.get(ATTR_SUPPORTED_FEATURES) - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get(ATTR_MAX_TEMP) == MAX_TEMPERATURE assert state.attributes.get(ATTR_MIN_TEMP) == 5.0 diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9bb5c8b2585..b1c6cf5ddd8 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -226,6 +226,8 @@ async def test_supported_features( | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get("supported_features") == support @@ -1327,6 +1329,8 @@ async def test_set_aux( | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes.get("supported_features") == support diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index e1c3cc187db..a3698cf0e82 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -909,6 +909,8 @@ async def test_thermostat_fan_off( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -956,6 +958,8 @@ async def test_thermostat_fan_on( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -999,6 +1003,8 @@ async def test_thermostat_cool_with_fan( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @@ -1036,6 +1042,8 @@ async def test_thermostat_set_fan( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Turn off fan mode @@ -1098,6 +1106,8 @@ async def test_thermostat_set_fan_when_off( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Fan cannot be turned on when HVAC is off @@ -1143,6 +1153,8 @@ async def test_thermostat_fan_empty( assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 0e7d81a9edb..db02a4300cd 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -38,7 +38,7 @@ 'original_name': 'Bureau', 'platform': 'netatmo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '222452125-DeviceType.OTM', 'unit_of_measurement': None, @@ -61,7 +61,7 @@ 'Frost Guard', 'Schedule', ]), - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, }), 'context': , @@ -110,7 +110,7 @@ 'original_name': 'Cocina', 'platform': 'netatmo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '2940411577-DeviceType.NRV', 'unit_of_measurement': None, @@ -138,7 +138,7 @@ 'Schedule', ]), 'selected_schedule': 'Default', - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, }), @@ -188,7 +188,7 @@ 'original_name': 'Corridor', 'platform': 'netatmo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '1002003001-DeviceType.BNS', 'unit_of_measurement': None, @@ -215,7 +215,7 @@ 'Schedule', ]), 'selected_schedule': 'Default', - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22, }), @@ -265,7 +265,7 @@ 'original_name': 'Entrada', 'platform': 'netatmo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '2833524037-DeviceType.NRV', 'unit_of_measurement': None, @@ -293,7 +293,7 @@ 'Schedule', ]), 'selected_schedule': 'Default', - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, }), @@ -344,7 +344,7 @@ 'original_name': 'Livingroom', 'platform': 'netatmo', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '2746182631-DeviceType.NATherm1', 'unit_of_measurement': None, @@ -372,7 +372,7 @@ 'Schedule', ]), 'selected_schedule': 'Default', - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 12, }), diff --git a/tests/components/nexia/test_climate.py b/tests/components/nexia/test_climate.py index 6601e49f597..5553965b418 100644 --- a/tests/components/nexia/test_climate.py +++ b/tests/components/nexia/test_climate.py @@ -29,7 +29,7 @@ async def test_climate_zones(hass: HomeAssistant) -> None: "min_temp": 12.8, "preset_mode": "None", "preset_modes": ["None", "Home", "Away", "Sleep"], - "supported_features": 31, + "supported_features": 415, "target_temp_high": 26.1, "target_temp_low": 17.2, "target_temp_step": 1.0, @@ -61,7 +61,7 @@ async def test_climate_zones(hass: HomeAssistant) -> None: "min_temp": 12.8, "preset_mode": "None", "preset_modes": ["None", "Home", "Away", "Sleep"], - "supported_features": 31, + "supported_features": 415, "target_temp_high": 26.1, "target_temp_low": 17.2, "target_temp_step": 1.0, diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr index f19fd69c47d..d7fced91e68 100644 --- a/tests/components/nibe_heatpump/snapshots/test_climate.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -12,7 +12,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -36,7 +36,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, }), 'context': , @@ -59,7 +59,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -83,7 +83,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, }), 'context': , @@ -112,7 +112,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -138,7 +138,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -164,7 +164,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -190,7 +190,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -216,7 +216,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -242,7 +242,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -268,7 +268,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -294,7 +294,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -320,7 +320,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -346,7 +346,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -372,7 +372,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -398,7 +398,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -424,7 +424,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -450,7 +450,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -476,7 +476,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -502,7 +502,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index 7a0e21485c8..73a07efcc3d 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -44,7 +44,7 @@ async def test_climate_thermostat_run(hass: HomeAssistant) -> None: "min_temp": 5.0, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 17, + "supported_features": 273, "temperature": 22.2, } # Only test for a subset of attributes in case @@ -77,7 +77,7 @@ async def test_climate_thermostat_schedule_hold_unavailable( "max_temp": 180.6, "min_temp": -6.1, "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 17, + "supported_features": 273, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -110,7 +110,7 @@ async def test_climate_thermostat_schedule_hold_available(hass: HomeAssistant) - "min_temp": -6.1, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 17, + "supported_features": 273, "temperature": 26.1, } # Only test for a subset of attributes in case @@ -144,7 +144,7 @@ async def test_climate_thermostat_schedule_temporary_hold(hass: HomeAssistant) - "min_temp": -0.6, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 17, + "supported_features": 273, "temperature": 37.2, } # Only test for a subset of attributes in case diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c5ab3a209c2..1be4cc2a34f 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -34,7 +34,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["current_temperature"] == 20.9 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 17 + assert state.attributes["supported_features"] == 273 assert state.attributes["temperature"] == 21.5 assert state.attributes["min_temp"] == 0.0 assert state.attributes["max_temp"] == 35.0 @@ -303,7 +303,7 @@ async def test_anna_climate_entity_attributes( assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 18 + assert state.attributes["supported_features"] == 274 assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 assert state.attributes["min_temp"] == 4 @@ -325,7 +325,7 @@ async def test_anna_2_climate_entity_attributes( HVACMode.AUTO, HVACMode.HEAT_COOL, ] - assert state.attributes["supported_features"] == 18 + assert state.attributes["supported_features"] == 274 assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 0a5a9d78b1b..1e02ee63a9a 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -20,7 +20,7 @@ ]), 'max_temp': 20, 'min_temp': 10, - 'supported_features': , + 'supported_features': , 'swing_mode': 'stopped', 'swing_modes': list([ 'stopped', diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index e74d69f04c9..ae6e5734e75 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -320,6 +320,8 @@ async def test_air_conditioner_entity_state( | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ HVACMode.COOL, diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index 40e3c05b509..cafb156d113 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -52,7 +52,9 @@ async def test_thermostat_update( assert state.state == HVACMode.HEAT assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + == ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 38 assert state.attributes[ATTR_TEMPERATURE] == 39 diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index fd4ae87ac64..91bc1af191e 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -24,7 +24,7 @@ async def test_air_con(hass: HomeAssistant) -> None: "min_temp": 16.0, "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], - "supported_features": 25, + "supported_features": 409, "target_temp_step": 1, "temperature": 17.8, } @@ -51,7 +51,7 @@ async def test_heater(hass: HomeAssistant) -> None: "min_temp": 16.0, "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], - "supported_features": 17, + "supported_features": 401, "target_temp_step": 1, "temperature": 20.5, } @@ -81,7 +81,7 @@ async def test_smartac_with_swing(hass: HomeAssistant) -> None: "preset_mode": "auto", "preset_modes": ["away", "home", "auto"], "swing_modes": ["on", "off"], - "supported_features": 57, + "supported_features": 441, "target_temp_step": 1.0, "temperature": 20.0, } diff --git a/tests/components/venstar/test_climate.py b/tests/components/venstar/test_climate.py index 8aa3065e3c4..d7c28b953cc 100644 --- a/tests/components/venstar/test_climate.py +++ b/tests/components/venstar/test_climate.py @@ -10,6 +10,8 @@ EXPECTED_BASE_SUPPORTED_FEATURES = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 8607a49b42c..0cc58e80f0d 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -97,6 +97,8 @@ async def test_static_attributes( == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index b2e7c313916..5f75f7c8307 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -83,6 +83,8 @@ async def test_thermostat_v2( == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) client.async_send_command.reset_mock() @@ -330,7 +332,7 @@ async def test_setpoint_thermostat( assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE + == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON ) client.async_send_command_no_wait.reset_mock() @@ -432,7 +434,10 @@ async def test_thermostat_heatit_z_trm6( assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 5 assert state.attributes[ATTR_MAX_TEMP] == 40 @@ -513,6 +518,8 @@ async def test_thermostat_heatit_z_trm3( assert ( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 5 assert state.attributes[ATTR_MAX_TEMP] == 35 @@ -582,7 +589,10 @@ async def test_thermostat_heatit_z_trm2fx( assert state.attributes[ATTR_TEMPERATURE] == 29 assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_MIN_TEMP] == 7 assert state.attributes[ATTR_MAX_TEMP] == 35 @@ -627,7 +637,7 @@ async def test_thermostat_srt321_hrt4_zw( HVACMode.HEAT, ] assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 384 async def test_preset_and_no_setpoint( From f768dd876181160e2e0bf6a0ad54d4df9442e660 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 15:44:31 +0100 Subject: [PATCH 1161/1544] Add TURN_ON/OFF ClimateEntityFeature for Shelly (#108967) --- homeassistant/components/shelly/climate.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 64129131d0a..9c43c0b57b8 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -160,7 +160,10 @@ class BlockSleepingClimate( _attr_max_temp = SHTRV_01_TEMPERATURE_SETTINGS["max"] _attr_min_temp = SHTRV_01_TEMPERATURE_SETTINGS["min"] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -438,7 +441,11 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): _attr_icon = "mdi:thermostat" _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS From d752ab3aa4634ac0e6e8931ee84a1cff97ee44b6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 16:54:41 +0100 Subject: [PATCH 1162/1544] Update climate snapshots to fix CI (#109141) --- tests/components/teslemetry/snapshots/test_climate.ambr | 4 ++-- tests/components/tessie/snapshots/test_climate.ambr | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index f0f6f1b0140..cba5b05eff2 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -37,7 +37,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'VINVINVIN-driver_temp', 'unit_of_measurement': None, @@ -61,7 +61,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr index 9afc3d4e903..0205df15705 100644 --- a/tests/components/tessie/snapshots/test_climate.ambr +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -37,7 +37,7 @@ 'original_name': 'Climate', 'platform': 'tessie', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'primary', 'unique_id': 'VINVINVIN-primary', 'unit_of_measurement': None, @@ -61,7 +61,7 @@ , , ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.5, }), 'context': , From 360697836f90da2f24771073854d59d87e34edc7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Jan 2024 17:52:28 +0100 Subject: [PATCH 1163/1544] Add support for custom integrations in Analytics Insights (#109110) --- .../analytics_insights/config_flow.py | 23 ++++++++- .../components/analytics_insights/const.py | 1 + .../analytics_insights/coordinator.py | 30 ++++++++++-- .../components/analytics_insights/icons.json | 3 ++ .../components/analytics_insights/sensor.py | 32 +++++++++++-- .../analytics_insights/strings.json | 7 +++ .../components/analytics_insights/conftest.py | 19 ++++++-- .../fixtures/custom_integrations.json | 10 ++++ .../snapshots/test_sensor.ambr | 47 +++++++++++++++++++ .../analytics_insights/test_config_flow.py | 18 +++++-- 10 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/components/analytics_insights/fixtures/custom_integrations.json diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index eb6d0f87079..b409a9c0fb9 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -25,7 +25,12 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN, LOGGER +from .const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, + DOMAIN, + LOGGER, +) INTEGRATION_TYPES_WITHOUT_ANALYTICS = ( IntegrationType.BRAND, @@ -58,6 +63,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) try: integrations = await client.get_integrations() + custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") return self.async_abort(reason="cannot_connect") @@ -81,6 +87,13 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): sort=True, ) ), + vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=list(custom_integrations), + multiple=True, + sort=True, + ) + ), } ), ) @@ -101,6 +114,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): ) try: integrations = await client.get_integrations() + custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") return self.async_abort(reason="cannot_connect") @@ -125,6 +139,13 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): sort=True, ) ), + vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + SelectSelectorConfig( + options=list(custom_integrations), + multiple=True, + sort=True, + ) + ), }, ), self.options, diff --git a/homeassistant/components/analytics_insights/const.py b/homeassistant/components/analytics_insights/const.py index 3b9bf01d11e..745c05302a1 100644 --- a/homeassistant/components/analytics_insights/const.py +++ b/homeassistant/components/analytics_insights/const.py @@ -4,5 +4,6 @@ import logging DOMAIN = "analytics_insights" CONF_TRACKED_INTEGRATIONS = "tracked_integrations" +CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index 0c2a0f16aa9..c646288cbe0 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import timedelta from python_homeassistant_analytics import ( + CustomIntegration, HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, HomeassistantAnalyticsNotModifiedError, @@ -14,14 +15,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN, LOGGER +from .const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, + DOMAIN, + LOGGER, +) -@dataclass(frozen=True, kw_only=True) +@dataclass(frozen=True) class AnalyticsData: """Analytics data class.""" core_integrations: dict[str, int] + custom_integrations: dict[str, int] class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[AnalyticsData]): @@ -43,10 +50,14 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic self._tracked_integrations = self.config_entry.options[ CONF_TRACKED_INTEGRATIONS ] + self._tracked_custom_integrations = self.config_entry.options[ + CONF_TRACKED_CUSTOM_INTEGRATIONS + ] async def _async_update_data(self) -> AnalyticsData: try: data = await self._client.get_current_analytics() + custom_data = await self._client.get_custom_integrations() except HomeassistantAnalyticsConnectionError as err: raise UpdateFailed( "Error communicating with Homeassistant Analytics" @@ -57,4 +68,17 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic integration: data.integrations.get(integration, 0) for integration in self._tracked_integrations } - return AnalyticsData(core_integrations=core_integrations) + custom_integrations = { + integration: get_custom_integration_value(custom_data, integration) + for integration in self._tracked_custom_integrations + } + return AnalyticsData(core_integrations, custom_integrations) + + +def get_custom_integration_value( + data: dict[str, CustomIntegration], domain: str +) -> int: + """Get custom integration value.""" + if domain in data: + return data[domain].total + return 0 diff --git a/homeassistant/components/analytics_insights/icons.json b/homeassistant/components/analytics_insights/icons.json index b1358e478b4..705578dbc6b 100644 --- a/homeassistant/components/analytics_insights/icons.json +++ b/homeassistant/components/analytics_insights/icons.json @@ -3,6 +3,9 @@ "sensor": { "core_integrations": { "default": "mdi:puzzle" + }, + "custom_integrations": { + "default": "mdi:puzzle-edit" } } } diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index ae24abd8b07..e0fe2c79413 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -42,6 +42,20 @@ def get_core_integration_entity_description( ) +def get_custom_integration_entity_description( + domain: str, +) -> AnalyticsSensorEntityDescription: + """Get custom integration entity description.""" + return AnalyticsSensorEntityDescription( + key=f"custom_{domain}_active_installations", + translation_key="custom_integrations", + translation_placeholders={"custom_integration_domain": domain}, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="active installations", + value_fn=lambda data: data.custom_integrations.get(domain), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -50,15 +64,27 @@ async def async_setup_entry( """Initialize the entries.""" analytics_data: AnalyticsInsightsData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + coordinator: HomeassistantAnalyticsDataUpdateCoordinator = ( + analytics_data.coordinator + ) + entities: list[HomeassistantAnalyticsSensor] = [] + entities.extend( HomeassistantAnalyticsSensor( - analytics_data.coordinator, + coordinator, get_core_integration_entity_description( integration_domain, analytics_data.names[integration_domain] ), ) - for integration_domain in analytics_data.coordinator.data.core_integrations + for integration_domain in coordinator.data.core_integrations ) + entities.extend( + HomeassistantAnalyticsSensor( + coordinator, + get_custom_integration_entity_description(integration_domain), + ) + for integration_domain in coordinator.data.custom_integrations + ) + async_add_entities(entities) class HomeassistantAnalyticsSensor( diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 5c249a1cd5a..96ec59f299b 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -23,5 +23,12 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "custom_integrations": { + "name": "{custom_integration_domain} (custom)" + } + } } } diff --git a/tests/components/analytics_insights/conftest.py b/tests/components/analytics_insights/conftest.py index a1a32cb3f74..6ca98d294e6 100644 --- a/tests/components/analytics_insights/conftest.py +++ b/tests/components/analytics_insights/conftest.py @@ -4,10 +4,13 @@ from unittest.mock import AsyncMock, patch import pytest from python_homeassistant_analytics import CurrentAnalytics -from python_homeassistant_analytics.models import Integration +from python_homeassistant_analytics.models import CustomIntegration, Integration from homeassistant.components.analytics_insights import DOMAIN -from homeassistant.components.analytics_insights.const import CONF_TRACKED_INTEGRATIONS +from homeassistant.components.analytics_insights.const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, + CONF_TRACKED_INTEGRATIONS, +) from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture @@ -40,6 +43,13 @@ def mock_analytics_client() -> Generator[AsyncMock, None, None]: client.get_integrations.return_value = { key: Integration.from_dict(value) for key, value in integrations.items() } + custom_integrations = load_json_object_fixture( + "analytics_insights/custom_integrations.json" + ) + client.get_custom_integrations.return_value = { + key: CustomIntegration.from_dict(value) + for key, value in custom_integrations.items() + } yield client @@ -50,5 +60,8 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Homeassistant Analytics", data={}, - options={CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify", "myq"]}, + options={ + CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify", "myq"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, ) diff --git a/tests/components/analytics_insights/fixtures/custom_integrations.json b/tests/components/analytics_insights/fixtures/custom_integrations.json new file mode 100644 index 00000000000..5777c8f1d06 --- /dev/null +++ b/tests/components/analytics_insights/fixtures/custom_integrations.json @@ -0,0 +1,10 @@ +{ + "hacs": { + "total": 157481, + "versions": { + "1.33.0": 123794, + "1.30.1": 1684, + "1.14.1": 23 + } + } +} diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 404850baa4e..474263f68e9 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-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.homeassistant_analytics_hacs_custom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'hacs (custom)', + 'platform': 'analytics_insights', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'custom_integrations', + 'unique_id': 'custom_hacs_active_installations', + 'unit_of_measurement': 'active installations', + }) +# --- +# name: test_all_entities[sensor.homeassistant_analytics_hacs_custom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Homeassistant Analytics hacs (custom)', + 'state_class': , + 'unit_of_measurement': 'active installations', + }), + 'context': , + 'entity_id': 'sensor.homeassistant_analytics_hacs_custom', + 'last_changed': , + 'last_updated': , + 'state': '157481', + }) +# --- # name: test_all_entities[sensor.homeassistant_analytics_myq-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index a93290745f2..8cefa29ee7b 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -5,6 +5,7 @@ from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError from homeassistant import config_entries from homeassistant.components.analytics_insights.const import ( + CONF_TRACKED_CUSTOM_INTEGRATIONS, CONF_TRACKED_INTEGRATIONS, DOMAIN, ) @@ -26,14 +27,20 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_TRACKED_INTEGRATIONS: ["youtube"]}, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant Analytics Insights" assert result["data"] == {} - assert result["options"] == {CONF_TRACKED_INTEGRATIONS: ["youtube"]} + assert result["options"] == { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + } assert len(mock_setup_entry.mock_calls) == 1 @@ -60,7 +67,10 @@ async def test_form_already_configured( entry = MockConfigEntry( domain=DOMAIN, data={}, - options={CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify"]}, + options={ + CONF_TRACKED_INTEGRATIONS: ["youtube", "spotify"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, ) entry.add_to_hass(hass) @@ -87,6 +97,7 @@ async def test_options_flow( result["flow_id"], user_input={ CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], }, ) await hass.async_block_till_done() @@ -94,6 +105,7 @@ async def test_options_flow( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], } await hass.async_block_till_done() mock_analytics_client.get_integrations.assert_called_once() From cac0d075491af82c5a5da43147946e9527b37baa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 17:56:52 +0100 Subject: [PATCH 1164/1544] Add TURN_ON/OFF ClimateEntityFeature for smartthings (#108979) --- homeassistant/components/smartthings/climate.py | 7 ++++++- tests/components/smartthings/test_climate.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f07c293939a..656a198f42b 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -173,6 +173,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._device.get_capability( Capability.thermostat_fan_mode, Capability.thermostat @@ -353,7 +355,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_supported_features(self) -> ClimateEntityFeature: features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._device.get_capability(Capability.fan_oscillation_mode): features |= ClimateEntityFeature.SWING_MODE diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index ae6e5734e75..475a8f09e03 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -213,6 +213,8 @@ async def test_legacy_thermostat_entity_state( == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -240,6 +242,8 @@ async def test_basic_thermostat_entity_state( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert ATTR_HVAC_ACTION not in state.attributes assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -261,6 +265,8 @@ async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: == ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ @@ -288,6 +294,8 @@ async def test_buggy_thermostat_entity_state( state.attributes[ATTR_SUPPORTED_FEATURES] == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert state.state is STATE_UNKNOWN assert state.attributes[ATTR_TEMPERATURE] is None From b4c0e52ebd251e4e360ccbc7115ae19bb03dbfc2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 17:57:11 +0100 Subject: [PATCH 1165/1544] Add TURN_ON/OFF ClimateEntityFeature for ZHA (#108978) --- homeassistant/components/zha/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 95abaf1c83e..40da264d695 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -148,7 +148,11 @@ class Thermostat(ZhaEntity, ClimateEntity): self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) self._preset = PRESET_NONE self._presets = [] - self._supported_flags = ClimateEntityFeature.TARGET_TEMPERATURE + self._supported_flags = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) @property From 7827f7bbaae2f94c6aa9a893dc123c62394f7f67 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 17:58:46 +0100 Subject: [PATCH 1166/1544] Add TURN_ON/OFF ClimateEntityFeature for Balboa (#109139) --- homeassistant/components/balboa/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index d213a8fd2e8..0ca8b1a3acc 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -56,7 +56,10 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): _attr_icon = "mdi:hot-tub" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN _attr_name = None From 73f670e793fa206b6e050592651be8224eded86f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 17:59:58 +0100 Subject: [PATCH 1167/1544] Add TURN_ON/OFF ClimateEntityFeature for Vicare (#109135) --- homeassistant/components/vicare/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 2bb0a19924e..cc3f7e9f047 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -142,7 +142,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = VICARE_TEMP_HEATING_MIN From cf1adfbf24016a5692cf3569687812c4e400cee4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:01:06 +0100 Subject: [PATCH 1168/1544] Add TURN_ON/OFF ClimateEntityFeature for TOLO Sauna (#108965) --- homeassistant/components/tolo/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 74f2a5a6f55..05afce41ff3 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -53,6 +53,8 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS From c51a67589346b58ac6e7b8c380927e65ef3cd1ca Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:01:53 +0100 Subject: [PATCH 1169/1544] Add TURN_ON/OFF ClimateEntityFeature for Overkiz (#109132) --- .../climate_entities/atlantic_electrical_heater.py | 6 +++++- ...trical_heater_with_adjustable_temperature_setpoint.py | 5 ++++- .../climate_entities/atlantic_electrical_towel_dryer.py | 6 +++++- .../atlantic_heat_recovery_ventilation.py | 5 ++++- .../climate_entities/atlantic_pass_apc_heating_zone.py | 5 ++++- .../climate_entities/atlantic_pass_apc_zone_control.py | 9 ++++++++- .../hitachi_air_to_air_heat_pump_hlrrwifi.py | 2 ++ .../somfy_heating_temperature_interface.py | 5 ++++- .../overkiz/climate_entities/somfy_thermostat.py | 5 ++++- .../valve_heating_temperature_interface.py | 5 ++++- 10 files changed, 44 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index 46a330c97cc..867e977276d 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -46,7 +46,11 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 5807ccecd74..14237b4601b 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -69,7 +69,10 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index 0c378d088c5..b053611de9b 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -55,7 +55,11 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): TEMPERATURE_SENSOR_DEVICE_INDEX ) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py index 1da7c48f9eb..115a30a7c36 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -48,7 +48,10 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): _attr_preset_modes = [PRESET_AUTO, PRESET_PROG, PRESET_MANUAL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index 7722269a48b..90bc3e40404 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -76,7 +76,10 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index 74f7637b997..1ef0f9bf400 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -3,7 +3,11 @@ from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState -from homeassistant.components.climate import ClimateEntity, HVACMode +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) from homeassistant.const import UnitOfTemperature from ..entity import OverkizEntity @@ -23,6 +27,9 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py index 7a9e50d7130..162b9b4fce6 100644 --- a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -101,6 +101,8 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self.device.states.get(SWING_STATE): diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py index 6c3ee3454ce..cc470dee032 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py @@ -71,7 +71,10 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 4059f8521b8..9a81b6d5bd3 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -56,7 +56,10 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index 3d883738de2..b58c29a6121 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -51,7 +51,10 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN From 9d664c0fdda25f5041ea83dc75275db9622b5d4b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:03:46 +0100 Subject: [PATCH 1170/1544] Add TURN_ON/OFF ClimateEntityFeature for Sensibo (#108962) --- homeassistant/components/sensibo/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 89e1fafa213..a718cac88fb 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -207,7 +207,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): def get_features(self) -> ClimateEntityFeature: """Get supported features.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON for key in self.device_data.full_features: if key in FIELD_TO_FLAG: features |= FIELD_TO_FLAG[key] From 63594bac89f54fcba93c86ea3512dc36b0f79eee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:05:03 +0100 Subject: [PATCH 1171/1544] Add TURN_ON/OFF ClimateEntityFeature for IntesisHome (#109134) --- homeassistant/components/intesishome/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 16cf62627f1..285be2c9cea 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -175,6 +175,10 @@ class IntesisAC(ClimateEntity): self._power_consumption_heat = None self._power_consumption_cool = None + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + # Setpoint support if controller.has_setpoint_control(ih_device_id): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE From 96ee8ba9a8705b22d692d069a67c4b70aa3a8ade Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:11:08 +0100 Subject: [PATCH 1172/1544] Add TURN_ON/OFF ClimateEntityFeature for Fritzbox (#108964) --- homeassistant/components/fritzbox/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index f648d4b3966..b76e0fda18a 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -74,7 +74,10 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): _attr_precision = PRECISION_HALVES _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS From 1acc9007d4ab4e13a0c750269c4b0c1fb9abf85d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:11:42 +0100 Subject: [PATCH 1173/1544] Add TURN_ON/OFF ClimateEntityFeature for Adax (#108966) --- homeassistant/components/adax/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 34812f9e449..2ce8adc30d6 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -67,7 +67,11 @@ class AdaxDevice(ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = 35 _attr_min_temp = 5 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS From 822d5b3ce8b3c9559eb9cf805b26f410639d7a8b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:12:15 +0100 Subject: [PATCH 1174/1544] Add TURN_ON/OFF ClimateEntityFeature for Vera (#108969) --- homeassistant/components/vera/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index f58ae083f72..85c1851b20e 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -48,7 +48,10 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): _attr_hvac_modes = SUPPORT_HVAC _attr_fan_modes = FAN_OPERATION_LIST _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) def __init__( From 8f9969131bab4767627d81c03f25584cd3b00d7e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:13:08 +0100 Subject: [PATCH 1175/1544] Add TURN_ON/OFF ClimateEntityFeature for Nexia (#108970) Co-authored-by: Franck Nijhof --- homeassistant/components/nexia/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index e331108f6ba..32ac8b5320a 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -107,6 +107,8 @@ NEXIA_SUPPORTED = ( | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) From 8395992dbe38eca3994539d1e3842722981b0ce4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:13:30 +0100 Subject: [PATCH 1176/1544] Add TURN_ON/OFF ClimateEntityFeature for Advantage Air (#108971) --- homeassistant/components/advantage_air/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a488ba8b362..d4f3c05902c 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -88,7 +88,11 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._attr_hvac_modes = [ HVACMode.OFF, HVACMode.COOL, From 6d43a5627aa92366db5b1201cefd7e4568170bb0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:14:16 +0100 Subject: [PATCH 1177/1544] Add TURN_ON/OFF ClimateEntityFeature for CoolMasterNet (#108972) --- homeassistant/components/coolmaster/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index c9f5cff4339..de0a7029ac6 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -65,7 +65,10 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self.swing_mode: supported_features |= ClimateEntityFeature.SWING_MODE From 36e3ba4834195339ea0fd789353e9d1f6c5b0bbc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:15:11 +0100 Subject: [PATCH 1178/1544] Add TURN_ON/OFF ClimateEntityFeature for Netatmo (#108973) --- homeassistant/components/netatmo/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 9f5476718b7..721e453e834 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -65,7 +65,10 @@ PRESET_SCHEDULE = "Schedule" PRESET_MANUAL = "Manual" SUPPORT_FLAGS = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] From 2b1d1340b71a8795ca00a03cb07f9a7daf822057 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 18:16:51 +0100 Subject: [PATCH 1179/1544] Add TURN_ON/OFF ClimateEntityFeature for Mill (#108977) --- homeassistant/components/mill/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index a5e59b4f8ec..d0b15f5d8ff 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -92,7 +92,11 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS From c363edad4af1c6ea0200d2d56191577055ba0433 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 18:55:37 +0100 Subject: [PATCH 1180/1544] Update Ecovacs binary sensor keys (#109145) --- homeassistant/components/ecovacs/binary_sensor.py | 4 ++-- homeassistant/components/ecovacs/icons.json | 2 +- homeassistant/components/ecovacs/strings.json | 2 +- .../components/ecovacs/snapshots/test_binary_sensor.ambr | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index e0c7e89d7c2..95e87a04b18 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -36,8 +36,8 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( EcovacsBinarySensorEntityDescription[WaterInfoEvent]( capability_fn=lambda caps: caps.water, value_fn=lambda e: e.mop_attached, - key="mop_attached", - translation_key="mop_attached", + key="water_mop_attached", + translation_key="water_mop_attached", entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index f29dd1bb1b1..ca55d090ccf 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -1,7 +1,7 @@ { "entity": { "binary_sensor": { - "mop_attached": { + "water_mop_attached": { "default": "mdi:water-off", "state": { "on": "mdi:water" diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 016c43ceb09..0ee72a942bd 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -20,7 +20,7 @@ }, "entity": { "binary_sensor": { - "mop_attached": { + "water_mop_attached": { "name": "Mop attached" } }, diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr index 0ddf8c00a1f..b42aeda6fcc 100644 --- a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -25,8 +25,8 @@ 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mop_attached', - 'unique_id': 'E1234567890000000001_mop_attached', + 'translation_key': 'water_mop_attached', + 'unique_id': 'E1234567890000000001_water_mop_attached', 'unit_of_measurement': None, }) # --- @@ -56,8 +56,8 @@ 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mop_attached', - 'unique_id': 'E1234567890000000001_mop_attached', + 'translation_key': 'water_mop_attached', + 'unique_id': 'E1234567890000000001_water_mop_attached', 'unit_of_measurement': None, }) # --- From 7d2c6a1bb6cc294800bbf0e7bfeb9659e6ef3422 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 18:55:59 +0100 Subject: [PATCH 1181/1544] Add check for valid initial_suggested_unit (#108902) Co-authored-by: Erik Montnemery --- homeassistant/components/sensor/__init__.py | 40 +++++- .../ecovacs/snapshots/test_sensor.ambr | 10 +- tests/components/sensor/test_init.py | 119 ++++++++++++++++++ .../withings/snapshots/test_sensor.ambr | 35 ++---- 4 files changed, 165 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 45d8c8b3c06..05fec64608f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -219,6 +219,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _last_reset_reported = False _sensor_option_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED + _invalid_suggested_unit_of_measurement_reported = False @callback def add_to_platform_start( @@ -376,6 +377,34 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return None + def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool: + """Validate the suggested unit. + + Validate that a unit converter exists for the sensor's device class and that the + unit converter supports both the native and the suggested units of measurement. + """ + # Make sure we can convert the units + if ( + (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None + or self.native_unit_of_measurement not in unit_converter.VALID_UNITS + or suggested_unit_of_measurement not in unit_converter.VALID_UNITS + ): + if not self._invalid_suggested_unit_of_measurement_reported: + self._invalid_suggested_unit_of_measurement_reported = True + report_issue = self._suggest_report_issue() + # This should raise in Home Assistant Core 2024.5 + _LOGGER.warning( + ( + "%s sets an invalid suggested_unit_of_measurement. Please %s. " + "This warning will become an error in Home Assistant Core 2024.5" + ), + type(self), + report_issue, + ) + return False + + return True + def _get_initial_suggested_unit(self) -> str | UndefinedType: """Return the initial unit.""" # Unit suggested by the integration @@ -390,6 +419,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if suggested_unit_of_measurement is None: return UNDEFINED + # Make sure we can convert the units + if not self._is_valid_suggested_unit(suggested_unit_of_measurement): + return UNDEFINED + return suggested_unit_of_measurement def get_initial_entity_options(self) -> er.EntityOptionsType | None: @@ -486,16 +519,17 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self._sensor_option_unit_of_measurement is not UNDEFINED: return self._sensor_option_unit_of_measurement + native_unit_of_measurement = self.native_unit_of_measurement + # Second priority, for non registered entities: unit suggested by integration if not self.registry_entry and ( suggested_unit_of_measurement := self.suggested_unit_of_measurement ): - return suggested_unit_of_measurement + if self._is_valid_suggested_unit(suggested_unit_of_measurement): + return suggested_unit_of_measurement # Third priority: Legacy temperature conversion, which applies # to both registered and non registered entities - native_unit_of_measurement = self.native_unit_of_measurement - if ( native_unit_of_measurement in TEMPERATURE_UNITS and self.device_class is SensorDeviceClass.TEMPERATURE diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index ab0de50ea09..5b072b6c232 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -326,9 +326,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': None, 'original_icon': None, @@ -338,7 +335,7 @@ 'supported_features': 0, 'translation_key': 'stats_time', 'unique_id': 'E1234567890000000001_stats_time', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:state] @@ -468,9 +465,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': None, 'original_icon': None, @@ -480,7 +474,7 @@ 'supported_features': 0, 'translation_key': 'total_stats_time', 'unique_id': 'E1234567890000000001_total_stats_time', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:state] diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 3172759520d..98b3f2423cc 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +import logging from types import ModuleType from typing import Any @@ -29,6 +30,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfDataRate, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -2604,3 +2606,120 @@ def test_deprecated_constants_sensor_device_class( import_and_test_deprecated_constant_enum( caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" ) + + +@pytest.mark.parametrize( + ("device_class", "native_unit"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS), + (SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND), + ], +) +async def test_suggested_unit_guard_invalid_unit( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class: SensorDeviceClass, + native_unit: str, +) -> None: + """Test suggested_unit_of_measurement guard. + + An invalid suggested unit creates a log entry and the suggested unit will be ignored. + """ + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + state_value = 10 + invalid_suggested_unit = "invalid_unit" + + entity = platform.ENTITIES["0"] = platform.MockSensor( + name="Invalid", + device_class=device_class, + native_unit_of_measurement=native_unit, + suggested_unit_of_measurement=invalid_suggested_unit, + native_value=str(state_value), + unique_id="invalid", + ) + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Unit of measurement should be native one + state = hass.states.get(entity.entity_id) + assert int(state.state) == state_value + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + + # Assert the suggested unit is ignored and not stored in the entity registry + entry = entity_registry.async_get(entity.entity_id) + assert entry.unit_of_measurement == native_unit + assert entry.options == {} + assert ( + "homeassistant.components.sensor", + logging.WARNING, + ( + " sets an" + " invalid suggested_unit_of_measurement. Please report it to the author" + " of the 'test' custom integration. This warning will become an error in" + " Home Assistant Core 2024.5" + ), + ) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("device_class", "native_unit", "native_value", "suggested_unit", "expect_value"), + [ + ( + SensorDeviceClass.TEMPERATURE, + UnitOfTemperature.CELSIUS, + 10, + UnitOfTemperature.KELVIN, + 283, + ), + ( + SensorDeviceClass.DATA_RATE, + UnitOfDataRate.KILOBITS_PER_SECOND, + 10, + UnitOfDataRate.BITS_PER_SECOND, + 10000, + ), + ], +) +async def test_suggested_unit_guard_valid_unit( + hass: HomeAssistant, + device_class: SensorDeviceClass, + native_unit: str, + native_value: int, + suggested_unit: str, + expect_value: float | int, +) -> None: + """Test suggested_unit_of_measurement guard. + + Suggested unit is valid and therefore should be used for unit conversion and stored + in the entity registry. + """ + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + entity = platform.ENTITIES["0"] = platform.MockSensor( + name="Valid", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="valid", + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Unit of measurement should set to the suggested unit of measurement + state = hass.states.get(entity.entity_id) + assert float(state.state) == expect_value + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + + # Assert the suggested unit of measurement is stored in the registry + entry = entity_registry.async_get(entity.entity_id) + assert entry.unit_of_measurement == suggested_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": suggested_unit}, + } diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 29b3dafb910..8e3866a7561 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -71,9 +71,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -83,7 +80,7 @@ 'supported_features': 0, 'translation_key': 'activity_active_duration_today', 'unique_id': 'withings_12345_activity_active_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_active_time_today-state] @@ -1176,9 +1173,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -1188,7 +1182,7 @@ 'supported_features': 0, 'translation_key': 'activity_intense_duration_today', 'unique_id': 'withings_12345_activity_intense_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_intense_activity_today-state] @@ -1274,9 +1268,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -1286,7 +1277,7 @@ 'supported_features': 0, 'translation_key': 'workout_duration', 'unique_id': 'withings_12345_workout_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_last_workout_duration-state] @@ -1750,9 +1741,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -1762,7 +1750,7 @@ 'supported_features': 0, 'translation_key': 'activity_moderate_duration_today', 'unique_id': 'withings_12345_activity_moderate_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_moderate_activity_today-state] @@ -1851,9 +1839,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -1863,7 +1848,7 @@ 'supported_features': 0, 'translation_key': 'workout_pause_duration', 'unique_id': 'withings_12345_workout_pause_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_pause_during_last_workout-state] @@ -2045,9 +2030,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -2057,7 +2039,7 @@ 'supported_features': 0, 'translation_key': 'sleep_goal', 'unique_id': 'withings_12345_sleep_goal', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_sleep_goal-state] @@ -2235,9 +2217,6 @@ 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), 'original_device_class': , 'original_icon': None, @@ -2247,7 +2226,7 @@ 'supported_features': 0, 'translation_key': 'activity_soft_duration_today', 'unique_id': 'withings_12345_activity_soft_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_soft_activity_today-state] From 6023980c2ee141ef1a4381bbc5b90a3ec35a3fed Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 30 Jan 2024 19:35:46 +0100 Subject: [PATCH 1182/1544] Set TURN_ON and TURN_OFF feature on MQTT climate entities (#109146) --- homeassistant/components/mqtt/climate.py | 2 +- tests/components/mqtt/test_climate.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index c3e3448da0a..3df9db0d5d0 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -703,7 +703,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): config.get(key), entity=self ).async_render - support = ClimateEntityFeature(0) + support = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or ( self._topic[CONF_TEMP_COMMAND_TOPIC] is not None ): diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index b1c6cf5ddd8..6fcb219f6b6 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -121,6 +121,17 @@ async def test_setup_params( assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP assert state.attributes.get("min_humidity") == DEFAULT_MIN_HUMIDITY assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY + assert ( + state.attributes.get("supported_features") + == ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) @pytest.mark.parametrize( From 2b534af9606a2f22e6570b14dca7a44ae26413c9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 30 Jan 2024 19:36:57 +0100 Subject: [PATCH 1183/1544] Update reload icons for automation and person service (#109147) --- homeassistant/components/automation/icons.json | 2 +- homeassistant/components/person/icons.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/automation/icons.json b/homeassistant/components/automation/icons.json index 7a95b4070d5..9b68825ffd1 100644 --- a/homeassistant/components/automation/icons.json +++ b/homeassistant/components/automation/icons.json @@ -13,6 +13,6 @@ "turn_off": "mdi:robot-off", "toggle": "mdi:robot", "trigger": "mdi:robot", - "reload": "mdi:robot" + "reload": "mdi:reload" } } diff --git a/homeassistant/components/person/icons.json b/homeassistant/components/person/icons.json index 130819bf7f6..fbfd5be75d2 100644 --- a/homeassistant/components/person/icons.json +++ b/homeassistant/components/person/icons.json @@ -8,6 +8,6 @@ } }, "services": { - "reload": "mdi:account-sync" + "reload": "mdi:reload" } } From 70ee6a16ee20ce986e4f4bbedf46183c27c67693 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 30 Jan 2024 19:42:56 +0100 Subject: [PATCH 1184/1544] Add event entity to Xiaomi-BLE integration (#108811) --- .../components/xiaomi_ble/__init__.py | 55 ++-- homeassistant/components/xiaomi_ble/const.py | 16 +- .../components/xiaomi_ble/coordinator.py | 4 +- .../components/xiaomi_ble/device_trigger.py | 106 ++++-- homeassistant/components/xiaomi_ble/event.py | 130 ++++++++ .../components/xiaomi_ble/manifest.json | 2 +- .../components/xiaomi_ble/strings.json | 33 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../xiaomi_ble/test_device_trigger.py | 311 +++++++++++++----- tests/components/xiaomi_ble/test_event.py | 126 +++++++ 11 files changed, 651 insertions(+), 136 deletions(-) create mode 100644 homeassistant/components/xiaomi_ble/event.py create mode 100644 tests/components/xiaomi_ble/test_event.py diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 456838d1ee1..3adafc6d05e 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast from xiaomi_ble import EncryptionScheme, SensorUpdate, XiaomiBluetoothDeviceData @@ -20,6 +21,7 @@ from homeassistant.helpers.device_registry import ( DeviceRegistry, async_get, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_DISCOVERED_EVENT_CLASSES, @@ -30,7 +32,7 @@ from .const import ( ) from .coordinator import XiaomiActiveBluetoothProcessorCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -47,7 +49,7 @@ def process_service_info( coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - discovered_device_classes = coordinator.discovered_device_classes + discovered_event_classes = coordinator.discovered_event_classes if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: hass.config_entries.async_update_entry( entry, @@ -67,28 +69,35 @@ def process_service_info( sw_version=sensor_device_info.sw_version, hw_version=sensor_device_info.hw_version, ) + # event_class may be postfixed with a number, ie 'button_2' + # but if there is only one button then it will be 'button' event_class = event.device_key.key event_type = event.event_type - if event_class not in discovered_device_classes: - discovered_device_classes.add(event_class) + ble_event = XiaomiBleEvent( + device_id=device.id, + address=address, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' + event_properties=event.event_properties, + ) + + if event_class not in discovered_event_classes: + discovered_event_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_event_classes)}, + ) + async_dispatcher_send( + hass, format_discovered_event_class(address), event_class, ble_event ) - hass.bus.async_fire( - XIAOMI_BLE_EVENT, - dict( - XiaomiBleEvent( - device_id=device.id, - address=address, - event_class=event_class, # ie 'button' - event_type=event_type, # ie 'press' - event_properties=event.event_properties, - ) - ), + hass.bus.async_fire(XIAOMI_BLE_EVENT, cast(dict, ble_event)) + async_dispatcher_send( + hass, + format_event_dispatcher_name(address, event_class), + ble_event, ) # If device isn't pending we know it has seen at least one broadcast with a payload @@ -103,6 +112,16 @@ def process_service_info( return update +def format_event_dispatcher_name(address: str, event_class: str) -> str: + """Format an event dispatcher name.""" + return f"{DOMAIN}_event_{address}_{event_class}" + + +def format_discovered_event_class(address: str) -> str: + """Format a discovered event class.""" + return f"{DOMAIN}_discovered_event_class_{address}" + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Xiaomi BLE device from a config entry.""" address = entry.unique_id @@ -160,9 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), needs_poll_method=_needs_poll, device_data=data, - discovered_device_classes=set( - entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) - ), + discovered_event_classes=set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])), poll_method=_async_poll, # We will take advertisements from non-connectable devices # since we will trade the BLEDevice for a connectable one diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 346d8a61318..b6a6369e258 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -7,12 +7,24 @@ DOMAIN = "xiaomi_ble" CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" -CONF_SLEEPY_DEVICE: Final = "sleepy_device" CONF_EVENT_PROPERTIES: Final = "event_properties" -EVENT_PROPERTIES: Final = "event_properties" +CONF_EVENT_CLASS: Final = "event_class" +CONF_SLEEPY_DEVICE: Final = "sleepy_device" +CONF_SUBTYPE: Final = "subtype" + +EVENT_CLASS: Final = "event_class" EVENT_TYPE: Final = "event_type" +EVENT_SUBTYPE: Final = "event_subtype" +EVENT_PROPERTIES: Final = "event_properties" XIAOMI_BLE_EVENT: Final = "xiaomi_ble_event" +EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_MOTION: Final = "motion" + +BUTTON_PRESS: Final = "button_press" +BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" +MOTION_DEVICE: Final = "motion_device" + class XiaomiBleEvent(TypedDict): """Xiaomi BLE event data.""" diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 94e70ca9835..a935f3ea199 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -35,7 +35,7 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina update_method: Callable[[BluetoothServiceInfoBleak], Any], needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], device_data: XiaomiBluetoothDeviceData, - discovered_device_classes: set[str], + discovered_event_classes: set[str], poll_method: Callable[ [BluetoothServiceInfoBleak], Coroutine[Any, Any, Any], @@ -57,7 +57,7 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina poll_debouncer=poll_debouncer, connectable=connectable, ) - self.discovered_device_classes = discovered_device_classes + self.discovered_event_classes = discovered_event_classes self.device_data = device_data self.entry = entry diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 91d7132d65f..a2373da89b4 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -21,41 +21,83 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_EVENT_PROPERTIES, + BUTTON_PRESS, + BUTTON_PRESS_DOUBLE_LONG, + CONF_SUBTYPE, DOMAIN, - EVENT_PROPERTIES, + EVENT_CLASS, + EVENT_CLASS_BUTTON, + EVENT_CLASS_MOTION, EVENT_TYPE, + MOTION_DEVICE, XIAOMI_BLE_EVENT, ) -MOTION_DEVICE_TRIGGERS = [ - {CONF_TYPE: "motion_detected", CONF_EVENT_PROPERTIES: None}, -] - -MOTION_DEVICE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In( - [trigger[CONF_TYPE] for trigger in MOTION_DEVICE_TRIGGERS] - ), - vol.Optional(CONF_EVENT_PROPERTIES): vol.In( - [trigger[CONF_EVENT_PROPERTIES] for trigger in MOTION_DEVICE_TRIGGERS] - ), - } -) +TRIGGERS_BY_TYPE = { + BUTTON_PRESS: ["press"], + BUTTON_PRESS_DOUBLE_LONG: ["press", "double_press", "long_press"], + MOTION_DEVICE: ["motion_detected"], +} @dataclass class TriggerModelData: """Data class for trigger model data.""" - triggers: list[dict[str, Any]] schema: vol.Schema + event_class: str + triggers: list[str] + + +TRIGGER_MODEL_DATA = { + BUTTON_PRESS: TriggerModelData( + schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), + vol.Required(CONF_SUBTYPE): vol.In(TRIGGERS_BY_TYPE[BUTTON_PRESS]), + } + ), + event_class=EVENT_CLASS_BUTTON, + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS], + ), + BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), + vol.Required(CONF_SUBTYPE): vol.In( + TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG] + ), + } + ), + event_class=EVENT_CLASS_BUTTON, + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + MOTION_DEVICE: TriggerModelData( + schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_MOTION]), + vol.Required(CONF_SUBTYPE): vol.In(TRIGGERS_BY_TYPE[MOTION_DEVICE]), + } + ), + event_class=EVENT_CLASS_MOTION, + triggers=TRIGGERS_BY_TYPE[MOTION_DEVICE], + ), +} MODEL_DATA = { - "MUE4094RT": TriggerModelData( - triggers=MOTION_DEVICE_TRIGGERS, schema=MOTION_DEVICE_SCHEMA - ) + "JTYJGD03MI": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "MS1BB(MI)": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "RTCGQ02LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "SJWS01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], + "K9B-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "K9B-2BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "K9B-3BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "K9BB-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "YLAI003": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "XMWXKG01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "XMWXKG01YL": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "MUE4094RT": TRIGGER_MODEL_DATA[MOTION_DEVICE], } @@ -77,14 +119,20 @@ async def async_get_triggers( # Check if device is a model supporting device triggers. if not (model_data := _async_trigger_model_data(hass, device_id)): return [] + + event_type = model_data.event_class + event_subtypes = model_data.triggers return [ { + # Required fields of TRIGGER_BASE_SCHEMA CONF_PLATFORM: "device", - CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - **trigger, + CONF_DOMAIN: DOMAIN, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: event_type, + CONF_SUBTYPE: event_subtype, } - for trigger in model_data.triggers + for event_subtype in event_subtypes ] @@ -95,19 +143,17 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - - event_data = { - CONF_DEVICE_ID: config[CONF_DEVICE_ID], - EVENT_TYPE: config[CONF_TYPE], - EVENT_PROPERTIES: config[CONF_EVENT_PROPERTIES], - } return await event_trigger.async_attach_trigger( hass, event_trigger.TRIGGER_SCHEMA( { event_trigger.CONF_PLATFORM: CONF_EVENT, event_trigger.CONF_EVENT_TYPE: XIAOMI_BLE_EVENT, - event_trigger.CONF_EVENT_DATA: event_data, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + EVENT_CLASS: config[CONF_TYPE], + EVENT_TYPE: config[CONF_SUBTYPE], + }, } ), action, diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py new file mode 100644 index 00000000000..1d5b08fb8f9 --- /dev/null +++ b/homeassistant/components/xiaomi_ble/event.py @@ -0,0 +1,130 @@ +"""Support for Xiaomi event entities.""" +from __future__ import annotations + +from dataclasses import replace + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import format_discovered_event_class, format_event_dispatcher_name +from .const import ( + DOMAIN, + EVENT_CLASS_BUTTON, + EVENT_CLASS_MOTION, + EVENT_PROPERTIES, + EVENT_TYPE, + XiaomiBleEvent, +) +from .coordinator import XiaomiActiveBluetoothProcessorCoordinator + +DESCRIPTIONS_BY_EVENT_CLASS = { + EVENT_CLASS_BUTTON: EventEntityDescription( + key=EVENT_CLASS_BUTTON, + translation_key="button", + event_types=[ + "press", + "double_press", + "long_press", + ], + device_class=EventDeviceClass.BUTTON, + ), + EVENT_CLASS_MOTION: EventEntityDescription( + key=EVENT_CLASS_MOTION, + translation_key="motion", + event_types=["motion_detected"], + ), +} + + +class XiaomiEventEntity(EventEntity): + """Representation of a Xiaomi event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + address: str, + event_class: str, + event: XiaomiBleEvent | None, + ) -> None: + """Initialise a Xiaomi event entity.""" + self._update_signal = format_event_dispatcher_name(address, event_class) + # event_class is something like "button" or "motion" + # and it maybe postfixed with "_1", "_2", "_3", etc + # If there is only one button then it will be "button" + base_event_class, _, postfix = event_class.partition("_") + base_description = DESCRIPTIONS_BY_EVENT_CLASS[base_event_class] + self.entity_description = replace(base_description, key=event_class) + postfix_name = f" {postfix}" if postfix else "" + self._attr_name = f"{base_event_class.title()}{postfix_name}" + # Matches logic in PassiveBluetoothProcessorEntity + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + self._attr_unique_id = f"{address}-{event_class}" + # If the event is provided then we can set the initial state + # since the event itself is likely what triggered the creation + # of this entity. We have to do this at creation time since + # entities are created dynamically and would otherwise miss + # the initial state. + if event: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._update_signal, + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: XiaomiBleEvent) -> None: + self._trigger_event(event[EVENT_TYPE], event[EVENT_PROPERTIES]) + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Xiaomi event.""" + coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + address = coordinator.address + ent_reg = er.async_get(hass) + async_add_entities( + # Matches logic in PassiveBluetoothProcessorEntity + XiaomiEventEntity(address_event_class[0], address_event_class[2], None) + for ent_reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + if ent_reg_entry.domain == "event" + and (address_event_class := ent_reg_entry.unique_id.partition("-")) + ) + + @callback + def _async_discovered_event_class(event_class: str, event: XiaomiBleEvent) -> None: + """Handle a newly discovered event class with or without a postfix.""" + async_add_entities([XiaomiEventEntity(address, event_class, event)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, + format_discovered_event_class(address), + _async_discovered_event_class, + ) + ) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 04398051035..f11b2426f96 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.21.2"] + "requirements": ["xiaomi-ble==0.23.1"] } diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index d1bc6fa9a48..2017ee674bb 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -40,8 +40,39 @@ } }, "device_automation": { + "trigger_subtype": { + "press": "Press", + "double_press": "Double Press", + "long_press": "Long Press", + "motion_detected": "Motion Detected" + }, "trigger_type": { - "motion_detected": "Motion detected" + "button": "Button \"{subtype}\"", + "motion": "{subtype}" + } + }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "double_press": "Double press", + "long_press": "Long press" + } + } + } + }, + "motion": { + "state_attributes": { + "event_type": { + "state": { + "motion_detected": "Motion Detected" + } + } + } + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index cf4a88de366..191b5296410 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2846,7 +2846,7 @@ wyoming==1.5.2 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.2 +xiaomi-ble==0.23.1 # homeassistant.components.knx xknx==2.11.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbc566c616f..894c5a8735e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2172,7 +2172,7 @@ wyoming==1.5.2 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.21.2 +xiaomi-ble==0.23.1 # homeassistant.components.knx xknx==2.11.2 diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index eba850e61e9..5c86173ca01 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -4,27 +4,19 @@ import pytest from homeassistant.components import automation from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.xiaomi_ble.const import ( - CONF_EVENT_PROPERTIES, - DOMAIN, - EVENT_PROPERTIES, - EVENT_TYPE, - XIAOMI_BLE_EVENT, -) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_PLATFORM, - CONF_TYPE, -) +from homeassistant.components.xiaomi_ble.const import CONF_SUBTYPE, DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + async_get as async_get_dev_reg, +) from homeassistant.setup import async_setup_component from . import make_advertisement from tests.common import ( + Any, MockConfigEntry, async_capture_events, async_get_device_automations, @@ -45,11 +37,8 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def _async_setup_xiaomi_device(hass, mac: str): - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=mac, - ) +async def _async_setup_xiaomi_device(hass, mac: str, data: Any | None = None): + config_entry = MockConfigEntry(domain=DOMAIN, unique_id=mac, data=data) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -58,6 +47,33 @@ async def _async_setup_xiaomi_device(hass, mac: str): return config_entry +async def test_event_button_press(hass: HomeAssistant) -> None: + """Make sure that a button press event is fired.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data["address"] == "54:EF:44:E3:9C:BC" + assert events[0].data["event_type"] == "press" + assert events[0].data["event_properties"] is None + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_event_motion_detected(hass: HomeAssistant) -> None: """Make sure that a motion detected event is fired.""" mac = "DE:70:E8:B2:39:0C" @@ -81,9 +97,87 @@ async def test_event_motion_detected(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: +async def test_get_triggers_button(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a Xiaomi BLE button sensor.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button", + CONF_SUBTYPE: "press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_double_button(hass: HomeAssistant) -> None: + """Test that we get the expected triggers from a Xiaomi BLE switch with 2 buttons.""" + mac = "DC:ED:83:87:12:73" + data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + events = async_capture_events(hass, "xiaomi_ble_event") + + # Emit button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Os\x12\x87\x83\xed\xdc\x0b48\n\x02\x00\x00\x8dI\xae(", + ), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 1 + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + assert device + expected_trigger = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button", + CONF_SUBTYPE: "press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_get_triggers_motion(hass: HomeAssistant) -> None: """Test that we get the expected triggers from a Xiaomi BLE motion sensor.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -99,14 +193,15 @@ async def test_get_triggers( await hass.async_block_till_done() assert len(events) == 1 - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device.id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", "metadata": {}, } triggers = await async_get_device_automations( @@ -118,25 +213,24 @@ async def test_get_triggers( await hass.async_block_till_done() -async def test_get_triggers_for_invalid_xiami_ble_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we don't get triggers for an invalid device.""" - mac = "DE:70:E8:B2:39:0C" +async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> None: + """Test that we don't get triggers for an device that does not emit events.""" + mac = "C4:7C:8D:6A:3E:7A" entry = await _async_setup_xiaomi_device(hass, mac) events = async_capture_events(hass, "xiaomi_ble_event") - # Emit motion detected event so it creates the device in the registry + # Creates the device in the registry but no events inject_bluetooth_service_info_bleak( hass, - make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + make_advertisement(mac, b"q \x5d\x01iz>j\x8d|\xc4\r\x10\x10\x02\xf4\x00"), ) - # wait for the event + # wait to make sure there are no events await hass.async_block_till_done() - assert len(events) == 1 + assert len(events) == 0 - invalid_device = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + invalid_device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -150,9 +244,7 @@ async def test_get_triggers_for_invalid_xiami_ble_device( await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: +async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: """Test that we don't get triggers when using an invalid device_id.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -166,9 +258,11 @@ async def test_get_triggers_for_invalid_device_id( # wait for the event await hass.async_block_till_done() - invalid_device = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + + invalid_device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -180,23 +274,26 @@ async def test_get_triggers_for_invalid_device_id( await hass.async_block_till_done() -async def test_if_fires_on_motion_detected( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry -) -> None: - """Test for motion event trigger firing.""" - mac = "DE:70:E8:B2:39:0C" - entry = await _async_setup_xiaomi_device(hass, mac) +async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: + """Test for button press event trigger firing.""" + mac = "54:EF:44:E3:9C:BC" + data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} + entry = await _async_setup_xiaomi_device(hass, mac, data) - # Emit motion detected event so it creates the device in the registry + # Creates the device in the registry inject_bluetooth_service_info_bleak( hass, - make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + make_advertisement( + mac, + b"XY\x97\tf\xbc\x9c\xe3D\xefT\x01" b"\x08\x12\x05\x00\x00\x00q^\xbe\x90", + ), ) - # wait for the event + # wait for the device being created await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -209,8 +306,64 @@ async def test_if_fires_on_motion_detected( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "button", + CONF_SUBTYPE: "press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_button_press"}, + }, + }, + ] + }, + ) + # Emit button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_button_press" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: + """Test for motion event trigger firing.""" + mac = "DE:70:E8:B2:39:0C" + entry = await _async_setup_xiaomi_device(hass, mac) + + # Creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement(mac, b"@0\xdd\x03$\x0A\x10\x01\x64"), + ) + + # wait for the device being created + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device_id = device.id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", }, "action": { "service": "test.automation", @@ -220,15 +373,11 @@ async def test_if_fires_on_motion_detected( ] }, ) - - message = { - CONF_DEVICE_ID: device_id, - CONF_ADDRESS: "DE:70:E8:B2:39:0C", - EVENT_TYPE: "motion_detected", - EVENT_PROPERTIES: None, - } - - hass.bus.async_fire(XIAOMI_BLE_EVENT, message) + # Emit motion detected event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement(mac, b"@0\xdd\x03$\x03\x00\x01\x01"), + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -241,7 +390,6 @@ async def test_if_fires_on_motion_detected( async def test_automation_with_invalid_trigger_type( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger type.""" mac = "DE:70:E8:B2:39:0C" @@ -256,7 +404,8 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -270,7 +419,7 @@ async def test_automation_with_invalid_trigger_type( CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, CONF_TYPE: "invalid", - CONF_EVENT_PROPERTIES: None, + CONF_SUBTYPE: None, }, "action": { "service": "test.automation", @@ -290,7 +439,6 @@ async def test_automation_with_invalid_trigger_type( async def test_automation_with_invalid_trigger_event_property( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger event property.""" mac = "DE:70:E8:B2:39:0C" @@ -305,7 +453,8 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -318,27 +467,28 @@ async def test_automation_with_invalid_trigger_event_property( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: "invalid_property", + CONF_TYPE: "motion", + CONF_SUBTYPE: "invalid_subtype", }, "action": { "service": "test.automation", - "data_template": {"some": "test_trigger_motion_detected"}, + "data_template": { + "some": "test_trigger_motion_motion_detected" + }, }, }, ] }, ) - # Logs should return message to make sure event property is of one [None] for motion event - assert str([None]) in caplog.text + await hass.async_block_till_done() + # Logs should return message to make sure subtype is of one 'motion_detected' for motion event + assert "value must be one of ['motion_detected']" in caplog.text assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() -async def test_triggers_for_invalid__model( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry -) -> None: +async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: """Test invalid model doesn't return triggers.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -353,7 +503,8 @@ async def test_triggers_for_invalid__model( await hass.async_block_till_done() # modify model to invalid model - invalid_model = device_registry.async_get_or_create( + dev_reg = async_get_dev_reg(hass) + invalid_model = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, mac)}, model="invalid model", @@ -371,12 +522,14 @@ async def test_triggers_for_invalid__model( CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: invalid_model_id, - CONF_TYPE: "motion_detected", - CONF_EVENT_PROPERTIES: None, + CONF_TYPE: "motion", + CONF_SUBTYPE: "motion_detected", }, "action": { "service": "test.automation", - "data_template": {"some": "test_trigger_motion_detected"}, + "data_template": { + "some": "test_trigger_motion_motion_detected" + }, }, }, ] diff --git a/tests/components/xiaomi_ble/test_event.py b/tests/components/xiaomi_ble/test_event.py new file mode 100644 index 00000000000..1d2cf5fb3fc --- /dev/null +++ b/tests/components/xiaomi_ble/test_event.py @@ -0,0 +1,126 @@ +"""Test the Xiaomi BLE events.""" +import pytest + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.xiaomi_ble.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import make_advertisement + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + BluetoothServiceInfoBleak, + inject_bluetooth_service_info, +) + + +@pytest.mark.parametrize( + ("mac_address", "advertisement", "bind_key", "result"), + [ + ( + "54:EF:44:E3:9C:BC", + make_advertisement( + "54:EF:44:E3:9C:BC", + b'XY\x97\td\xbc\x9c\xe3D\xefT" `' + b"\x88\xfd\x00\x00\x00\x00:\x14\x8f\xb3", + ), + "5b51a7c91cde6707c9ef18dfda143a58", + [ + { + "entity": "event.smoke_detector_9cbc_button", + ATTR_FRIENDLY_NAME: "Smoke Detector 9CBC Button", + ATTR_EVENT_TYPE: "press", + } + ], + ), + ( + "DC:ED:83:87:12:73", + make_advertisement( + "DC:ED:83:87:12:73", + b"XYI\x19Os\x12\x87\x83\xed\xdc\x0b48\n\x02\x00\x00\x8dI\xae(", + ), + "b93eb3787eabda352edd94b667f5d5a9", + [ + { + "entity": "event.switch_double_button_1273_button_right", + ATTR_FRIENDLY_NAME: "Switch (double button) 1273 Button right", + ATTR_EVENT_TYPE: "press", + } + ], + ), + ( + "DE:70:E8:B2:39:0C", + make_advertisement( + "DE:70:E8:B2:39:0C", + b"@0\xdd\x03$\x03\x00\x01\x01", + ), + None, + [ + { + "entity": "event.nightlight_390c_motion", + ATTR_FRIENDLY_NAME: "Nightlight 390C Motion", + ATTR_EVENT_TYPE: "motion_detected", + } + ], + ), + ], +) +async def test_events( + hass: HomeAssistant, + mac_address: str, + advertisement: BluetoothServiceInfoBleak, + bind_key: str | None, + result: list[dict[str, str]], +) -> None: + """Test the different Xiaomi BLE events.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Ensure entities are restored + for meas in result: + state = hass.states.get(meas["entity"]) + assert state != STATE_UNAVAILABLE + + # Now inject again + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + state = hass.states.get(meas["entity"]) + attributes = state.attributes + assert attributes[ATTR_FRIENDLY_NAME] == meas[ATTR_FRIENDLY_NAME] + assert attributes[ATTR_EVENT_TYPE] == meas[ATTR_EVENT_TYPE] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 066a0ccc6d3d95a8b61087d2044f3a8b7244b780 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Jan 2024 19:57:41 +0100 Subject: [PATCH 1185/1544] Add TURN_ON/OFF ClimateEntityFeature for HomeKit Device (#109137) --- homeassistant/components/homekit_controller/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 1548c23a543..8cc4ec569dd 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -180,7 +180,7 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON if self.service.has(CharacteristicsTypes.FAN_STATE_TARGET): features |= ClimateEntityFeature.FAN_MODE From 04f0128a1c9f75468b2c9ef96ba939bd1b6e3e31 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 30 Jan 2024 20:50:39 +0100 Subject: [PATCH 1186/1544] Simplify MQTT device triggers in automations (#108309) * Simplify MQTT device trigger * Add test non unique trigger_id * Adjust deprecation warning * Make discovery_id optional * refactor double if * Improve validation, add tests and deprecation comments * Avoid breaking change * Inmprove error message * Match on discovery_id instead of discovery_info * Revert an unrelated change * follow up comments * Add comment and test on device update with non unique trigger * Update homeassistant/components/mqtt/device_trigger.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/device_trigger.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- .../components/mqtt/device_trigger.py | 112 +++++-- tests/components/mqtt/test_device_trigger.py | 286 ++++++++++++++++-- 2 files changed, 349 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index fc7528743fa..b6d505d7c98 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -34,7 +34,7 @@ from .const import ( CONF_TOPIC, DOMAIN, ) -from .discovery import MQTTDiscoveryPayload +from .discovery import MQTTDiscoveryPayload, clear_discovery_hash from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, MqttDiscoveryDeviceUpdate, @@ -62,10 +62,13 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( vol.Required(CONF_PLATFORM): DEVICE, vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_DISCOVERY_ID): str, + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # By default, a MQTT device trigger now will be referenced by + # device_id, type and subtype instead. + vol.Optional(CONF_DISCOVERY_ID): str, vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_SUBTYPE): cv.string, - } + }, ) TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( @@ -123,6 +126,7 @@ class Trigger: device_id: str = attr.ib() discovery_data: DiscoveryInfoType | None = attr.ib() + discovery_id: str | None = attr.ib() hass: HomeAssistant = attr.ib() payload: str | None = attr.ib() qos: int | None = attr.ib() @@ -202,6 +206,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): self.discovery_data = discovery_data self.hass = hass self._mqtt_data = get_mqtt_data(hass) + self.trigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" MqttDiscoveryDeviceUpdate.__init__( self, @@ -216,11 +221,19 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): """Initialize the device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] discovery_id = discovery_hash[1] - if discovery_id not in self._mqtt_data.device_triggers: - self._mqtt_data.device_triggers[discovery_id] = Trigger( + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # To make sure old automation keep working we determine the trigger_id + # based on the discovery_id if it is set. + for trigger_id, trigger in self._mqtt_data.device_triggers.items(): + if trigger.discovery_id == discovery_id: + self.trigger_id = trigger_id + break + if self.trigger_id not in self._mqtt_data.device_triggers: + self._mqtt_data.device_triggers[self.trigger_id] = Trigger( hass=self.hass, device_id=self.device_id, discovery_data=self.discovery_data, + discovery_id=discovery_id, type=self._config[CONF_TYPE], subtype=self._config[CONF_SUBTYPE], topic=self._config[CONF_TOPIC], @@ -229,7 +242,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): value_template=self._config[CONF_VALUE_TEMPLATE], ) else: - await self._mqtt_data.device_triggers[discovery_id].update_trigger( + await self._mqtt_data.device_triggers[self.trigger_id].update_trigger( self._config ) debug_info.add_trigger_discovery_data( @@ -239,22 +252,39 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None: """Handle MQTT device trigger discovery updates.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] - discovery_id = discovery_hash[1] debug_info.update_trigger_discovery_data( self.hass, discovery_hash, discovery_data ) config = TRIGGER_DISCOVERY_SCHEMA(discovery_data) + new_trigger_id = f"{self.device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" + if new_trigger_id != self.trigger_id: + mqtt_data = get_mqtt_data(self.hass) + if new_trigger_id in mqtt_data.device_triggers: + _LOGGER.error( + "Cannot update device trigger %s due to an existing duplicate " + "device trigger with the same device_id, " + "type and subtype. Got: %s", + discovery_hash, + config, + ) + return + # Update trigger_id based index after update of type or subtype + mqtt_data.device_triggers[new_trigger_id] = mqtt_data.device_triggers.pop( + self.trigger_id + ) + self.trigger_id = new_trigger_id + update_device(self.hass, self._config_entry, config) - device_trigger: Trigger = self._mqtt_data.device_triggers[discovery_id] + device_trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] await device_trigger.update_trigger(config) async def async_tear_down(self) -> None: """Cleanup device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] - discovery_id = discovery_hash[1] - if discovery_id in self._mqtt_data.device_triggers: + if self.trigger_id in self._mqtt_data.device_triggers: _LOGGER.info("Removing trigger: %s", discovery_hash) - trigger: Trigger = self._mqtt_data.device_triggers[discovery_id] + trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] + trigger.discovery_data = None trigger.detach_trigger() debug_info.remove_trigger_discovery_data(self.hass, discovery_hash) @@ -267,7 +297,30 @@ async def async_setup_trigger( ) -> None: """Set up the MQTT device trigger.""" config = TRIGGER_DISCOVERY_SCHEMA(config) + + # We update the device based on the trigger config to obtain the device_id. + # In all cases the setup will lead to device entry to be created or updated. + # If the trigger is a duplicate, trigger creation will be cancelled but we allow + # the device data to be updated to not add additional complexity to the code. device_id = update_device(hass, config_entry, config) + discovery_id = discovery_data[ATTR_DISCOVERY_HASH][1] + trigger_type = config[CONF_TYPE] + trigger_subtype = config[CONF_SUBTYPE] + trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" + mqtt_data = get_mqtt_data(hass) + if ( + trigger_id in mqtt_data.device_triggers + and mqtt_data.device_triggers[trigger_id].discovery_data is not None + ): + _LOGGER.error( + "Config for device trigger %s conflicts with existing " + "device trigger, cannot set up trigger, got: %s", + discovery_id, + config, + ) + send_discovery_done(hass, discovery_data) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) + return None if TYPE_CHECKING: assert isinstance(device_id, str) @@ -283,8 +336,9 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None mqtt_data = get_mqtt_data(hass) triggers = await async_get_triggers(hass, device_id) for trig in triggers: - device_trigger: Trigger = mqtt_data.device_triggers.pop(trig[CONF_DISCOVERY_ID]) - if device_trigger: + trigger_id = f"{device_id}_{trig[CONF_TYPE]}_{trig[CONF_SUBTYPE]}" + if trigger_id in mqtt_data.device_triggers: + device_trigger = mqtt_data.device_triggers.pop(trigger_id) device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data if TYPE_CHECKING: @@ -303,7 +357,7 @@ async def async_get_triggers( if not mqtt_data.device_triggers: return triggers - for discovery_id, trig in mqtt_data.device_triggers.items(): + for trig in mqtt_data.device_triggers.values(): if trig.device_id != device_id or trig.topic is None: continue @@ -312,7 +366,6 @@ async def async_get_triggers( "device_id": device_id, "type": trig.type, "subtype": trig.subtype, - "discovery_id": discovery_id, } triggers.append(trigger) @@ -326,15 +379,33 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" + trigger_id: str | None = None mqtt_data = get_mqtt_data(hass) device_id = config[CONF_DEVICE_ID] - discovery_id = config[CONF_DISCOVERY_ID] - if discovery_id not in mqtt_data.device_triggers: - mqtt_data.device_triggers[discovery_id] = Trigger( + # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + # In case CONF_DISCOVERY_ID is still used in an automation, + # we reference the device trigger by discovery_id instead of + # referencing it by device_id, type and subtype, which is the default. + discovery_id: str | None = config.get(CONF_DISCOVERY_ID) + if discovery_id is not None: + for trig_id, trig in mqtt_data.device_triggers.items(): + if trig.discovery_id == discovery_id: + trigger_id = trig_id + break + + # Reference the device trigger by device_id, type and subtype. + if trigger_id is None: + trigger_type = config[CONF_TYPE] + trigger_subtype = config[CONF_SUBTYPE] + trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" + + if trigger_id not in mqtt_data.device_triggers: + mqtt_data.device_triggers[trigger_id] = Trigger( hass=hass, device_id=device_id, discovery_data=None, + discovery_id=discovery_id, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=None, @@ -342,6 +413,5 @@ async def async_attach_trigger( qos=None, value_template=None, ) - return await mqtt_data.device_triggers[discovery_id].add_trigger( - action, trigger_info - ) + + return await mqtt_data.device_triggers[trigger_id].add_trigger(action, trigger_info) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index fffb9e57f84..ade28ac2c1d 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -70,7 +70,6 @@ async def test_get_triggers( "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -191,7 +190,6 @@ async def test_discover_bad_triggers( "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -207,12 +205,13 @@ async def test_update_remove_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test triggers can be updated and removed.""" await mqtt_mock_entry() config1 = { "automation_type": "trigger", - "device": {"identifiers": ["0AFFD2"]}, + "device": {"identifiers": ["0AFFD2"], "name": "milk"}, "payload": "short_press", "topic": "foobar/triggers/button1", "type": "button_short_press", @@ -223,25 +222,36 @@ async def test_update_remove_triggers( config2 = { "automation_type": "trigger", - "device": {"identifiers": ["0AFFD2"]}, + "device": {"identifiers": ["0AFFD2"], "name": "beer"}, + "payload": "short_press", + "topic": "foobar/triggers/button1", + "type": "button_short_press", + "subtype": "button_1", + } + config2["topic"] = "foobar/tag_scanned2" + data2 = json.dumps(config2) + + config3 = { + "automation_type": "trigger", + "device": {"identifiers": ["0AFFD2"], "name": "beer"}, "payload": "short_press", "topic": "foobar/triggers/button1", "type": "button_short_press", "subtype": "button_2", } - config2["topic"] = "foobar/tag_scanned2" - data2 = json.dumps(config2) + config3["topic"] = "foobar/tag_scanned2" + data3 = json.dumps(config3) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "milk" expected_triggers1 = [ { "platform": "device", "domain": DOMAIN, "device_id": device_entry.id, - "discovery_id": "bla", "type": "button_short_press", "subtype": "button_1", "metadata": {}, @@ -254,11 +264,21 @@ async def test_update_remove_triggers( hass, DeviceAutomationType.TRIGGER, device_entry.id ) assert triggers == unordered(expected_triggers1) + assert device_entry.name == "milk" - # Update trigger + # Update trigger topic async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2) await hass.async_block_till_done() + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert triggers == unordered(expected_triggers1) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "beer" + # Update trigger type / subtype + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data3) + await hass.async_block_till_done() triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -275,7 +295,7 @@ async def test_update_remove_triggers( async def test_if_fires_on_mqtt_message( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" @@ -351,10 +371,202 @@ async def test_if_fires_on_mqtt_message( assert calls[1].data["some"] == "long_press" +async def test_if_discovery_id_is_prefered( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test if discovery is preferred over referencing by type/subtype. + + The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. + By default, a MQTT device trigger now will be referenced by + device_id, type and subtype instead. + If discovery_id is found an an automation it will have a higher + priority and than type and subtype. + """ + await mqtt_mock_entry() + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + # type and subtype of data 2 do not match with the type and subtype + # in the automation, because discovery_id matches, the trigger will fire + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_long_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla2", + "type": "completely_different_type", + "subtype": "completely_different_sub_type", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("long_press")}, + }, + }, + ] + }, + ) + + # Fake short press, matching on type and subtype + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "short_press" + + # Fake long press, matching on discovery_id + calls.clear() + async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "long_press" + + +async def test_non_unique_triggers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test non unique triggers.""" + await mqtt_mock_entry() + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"], "name": "milk"},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "press",' + ' "subtype": "button" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"], "name": "beer"},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button2",' + ' "type": "press",' + ' "subtype": "button" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + assert device_entry.name == "milk" + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) + # The device entry was updated, but the trigger was not unique + # and therefore it was not set up. + assert device_entry.name == "beer" + assert ( + "Config for device trigger bla2 conflicts with existing device trigger, cannot set up trigger" + in caplog.text + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "press", + "subtype": "button", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("press1")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "press", + "subtype": "button", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("press2")}, + }, + }, + ] + }, + ) + + # Try to trigger first config. + # and triggers both attached instances. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "press1" + assert calls[1].data["some"] == "press2" + + # Trigger second config references to same trigger + # and triggers both attached instances. + async_fire_mqtt_message(hass, "foobar/triggers/button2", "long_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "press1" + assert calls[1].data["some"] == "press2" + + # Removing the first trigger will clean up + calls.clear() + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "") + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "Device trigger ('device_automation', 'bla1') has been removed" in caplog.text + ) + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + assert len(calls) == 0 + + async def test_if_fires_on_mqtt_message_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing.""" @@ -435,7 +647,7 @@ async def test_if_fires_on_mqtt_message_template( async def test_if_fires_on_mqtt_message_late_discover( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers firing of MQTT device triggers discovered after setup.""" @@ -522,8 +734,9 @@ async def test_if_fires_on_mqtt_message_late_discover( async def test_if_fires_on_mqtt_message_after_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test triggers firing after update.""" await mqtt_mock_entry() @@ -537,11 +750,19 @@ async def test_if_fires_on_mqtt_message_after_update( data2 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' - ' "topic": "foobar/triggers/buttonOne",' - ' "type": "button_long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' ' "subtype": "button_2" }' ) + data3 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/buttonOne",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) @@ -574,29 +795,38 @@ async def test_if_fires_on_mqtt_message_after_update( await hass.async_block_till_done() assert len(calls) == 1 + # Update the trigger with existing type/subtype change + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data1) + await hass.async_block_till_done() + assert "Cannot update device trigger ('device_automation', 'bla2')" in caplog.text + # Update the trigger with different topic - async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data3) await hass.async_block_till_done() + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/button1", "") await hass.async_block_till_done() + assert len(calls) == 0 + + calls.clear() + async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") + await hass.async_block_till_done() assert len(calls) == 1 - async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") - await hass.async_block_till_done() - assert len(calls) == 2 - # Update the trigger with same topic - async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data3) await hass.async_block_till_done() + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/button1", "") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(calls) == 0 + calls.clear() async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(calls) == 1 async def test_no_resubscribe_same_topic( @@ -649,7 +879,7 @@ async def test_no_resubscribe_same_topic( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" @@ -715,7 +945,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - calls, + calls: list[ServiceCall], mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test triggers not firing after removal.""" @@ -1411,9 +1641,9 @@ async def test_trigger_debug_info( config1 = { "platform": "mqtt", "automation_type": "trigger", - "topic": "test-topic", + "topic": "test-topic1", "type": "foo", - "subtype": "bar", + "subtype": "bar1", "device": { "connections": [[dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12"]], "manufacturer": "Whatever", @@ -1427,7 +1657,7 @@ async def test_trigger_debug_info( "automation_type": "trigger", "topic": "test-topic2", "type": "foo", - "subtype": "bar", + "subtype": "bar2", "device": { "connections": [[dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12"]], }, @@ -1477,7 +1707,7 @@ async def test_trigger_debug_info( async def test_unload_entry( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, ) -> None: From bcb9a10d5adbac44814df2ef680bf8784ae6ea06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Jan 2024 09:57:08 -1000 Subject: [PATCH 1187/1544] Speed up listing issues via the repairs websocket api (#109149) --- .../components/repairs/websocket_api.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 0c6230e4c35..73ef4d624ec 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -1,7 +1,6 @@ """The repairs websocket API.""" from __future__ import annotations -import dataclasses from http import HTTPStatus from typing import Any @@ -65,21 +64,25 @@ def ws_list_issues( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of issues.""" - - def ws_dict(kv_pairs: list[tuple[Any, Any]]) -> dict[Any, Any]: - excluded_keys = ("active", "data", "is_persistent") - result = {k: v for k, v in kv_pairs if k not in excluded_keys} - result["ignored"] = result["dismissed_version"] is not None - result["created"] = result["created"].isoformat() - return result - issue_registry = async_get_issue_registry(hass) issues = [ - dataclasses.asdict(issue, dict_factory=ws_dict) + { + "breaks_in_ha_version": issue.breaks_in_ha_version, + "created": issue.created, + "dismissed_version": issue.dismissed_version, + "ignored": issue.dismissed_version is not None, + "domain": issue.domain, + "is_fixable": issue.is_fixable, + "issue_domain": issue.issue_domain, + "issue_id": issue.issue_id, + "learn_more_url": issue.learn_more_url, + "severity": issue.severity, + "translation_key": issue.translation_key, + "translation_placeholders": issue.translation_placeholders, + } for issue in issue_registry.issues.values() if issue.active ] - connection.send_result(msg["id"], {"issues": issues}) From 4ec3a17ed010f9208fda49997604a9bd2c008b0a Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Tue, 30 Jan 2024 20:09:15 +0000 Subject: [PATCH 1188/1544] Add missing ZHA metering device types (#109126) * Update smartenergy.py metering_device_type enums * Added missing enum 127 * Enum 127 is also electric metering type * Meter type constants and status enums in smartenergy cluster handler Addresses https://github.com/home-assistant/core/pull/109126#discussion_r1471383887 Whilst I have the code open I've also added status handlers for the non-electrical meter types. * New tests for different metering device type statuses --- .../zha/core/cluster_handlers/smartenergy.py | 101 +++++++++++++++++- tests/components/zha/test_sensor.py | 28 ++++- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 4d3c1759cdc..577da25332d 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -129,24 +129,67 @@ class MeteringClusterHandler(ClusterHandler): Metering.AttributeDefs.unit_of_measure.name: True, } + METERING_DEVICE_TYPES_ELECTRIC = { + 0, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 127, + 134, + 135, + 136, + 137, + 138, + 140, + 141, + 142, + } + METERING_DEVICE_TYPES_GAS = {1, 128} + METERING_DEVICE_TYPES_WATER = {2, 129} + METERING_DEVICE_TYPES_HEATING_COOLING = {3, 5, 6, 130, 132, 133} + metering_device_type = { 0: "Electric Metering", 1: "Gas Metering", 2: "Water Metering", - 3: "Thermal Metering", + 3: "Thermal Metering", # depreciated 4: "Pressure Metering", 5: "Heat Metering", 6: "Cooling Metering", + 7: "End Use Measurement Device (EUMD) for metering electric vehicle charging", + 8: "PV Generation Metering", + 9: "Wind Turbine Generation Metering", + 10: "Water Turbine Generation Metering", + 11: "Micro Generation Metering", + 12: "Solar Hot Water Generation Metering", + 13: "Electric Metering Element/Phase 1", + 14: "Electric Metering Element/Phase 2", + 15: "Electric Metering Element/Phase 3", + 127: "Mirrored Electric Metering", 128: "Mirrored Gas Metering", 129: "Mirrored Water Metering", - 130: "Mirrored Thermal Metering", + 130: "Mirrored Thermal Metering", # depreciated 131: "Mirrored Pressure Metering", 132: "Mirrored Heat Metering", 133: "Mirrored Cooling Metering", + 134: "Mirrored End Use Measurement Device (EUMD) for metering electric vehicle charging", + 135: "Mirrored PV Generation Metering", + 136: "Mirrored Wind Turbine Generation Metering", + 137: "Mirrored Water Turbine Generation Metering", + 138: "Mirrored Micro Generation Metering", + 139: "Mirrored Solar Hot Water Generation Metering", + 140: "Mirrored Electric Metering Element/Phase 1", + 141: "Mirrored Electric Metering Element/Phase 2", + 142: "Mirrored Electric Metering Element/Phase 3", } class DeviceStatusElectric(enum.IntFlag): - """Metering Device Status.""" + """Electric Metering Device Status.""" NO_ALARMS = 0 CHECK_METER = 1 @@ -158,6 +201,45 @@ class MeteringClusterHandler(ClusterHandler): SERVICE_DISCONNECT = 64 RESERVED = 128 + class DeviceStatusGas(enum.IntFlag): + """Gas Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + NOT_DEFINED = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusWater(enum.IntFlag): + """Water Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + PIPE_EMPTY = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusHeatingCooling(enum.IntFlag): + """Heating and Cooling Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + TEMPERATURE_SENSOR = 8 + BURST_DETECT = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + class DeviceStatusDefault(enum.IntFlag): """Metering Device Status.""" @@ -198,9 +280,18 @@ class MeteringClusterHandler(ClusterHandler): """Return metering device status.""" if (status := self.cluster.get(Metering.AttributeDefs.status.name)) is None: return None - if self.cluster.get(Metering.AttributeDefs.metering_device_type.name) == 0: - # Electric metering device type + + metering_device_type = self.cluster.get( + Metering.AttributeDefs.metering_device_type.name + ) + if metering_device_type in self.METERING_DEVICE_TYPES_ELECTRIC: return self.DeviceStatusElectric(status) + if metering_device_type in self.METERING_DEVICE_TYPES_GAS: + return self.DeviceStatusGas(status) + if metering_device_type in self.METERING_DEVICE_TYPES_WATER: + return self.DeviceStatusWater(status) + if metering_device_type in self.METERING_DEVICE_TYPES_HEATING_COOLING: + return self.DeviceStatusHeatingCooling(status) return self.DeviceStatusDefault(status) @property diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index c5940a7b689..48651df082d 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -155,9 +155,33 @@ async def async_test_metering(hass: HomeAssistant, cluster, entity_id): ) await send_attributes_report( - hass, cluster, {"status": 32, "metering_device_type": 1} + hass, cluster, {"status": 64 + 8, "metering_device_type": 1} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|NOT_DEFINED", + "NOT_DEFINED|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + hass, cluster, {"status": 64 + 8, "metering_device_type": 2} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|PIPE_EMPTY", + "PIPE_EMPTY|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + hass, cluster, {"status": 64 + 8, "metering_device_type": 5} + ) + assert hass.states.get(entity_id).attributes["status"] in ( + "SERVICE_DISCONNECT|TEMPERATURE_SENSOR", + "TEMPERATURE_SENSOR|SERVICE_DISCONNECT", + ) + + # Status for other meter types + await send_attributes_report( + hass, cluster, {"status": 32, "metering_device_type": 4} ) - # currently only statuses for electric meters are supported assert hass.states.get(entity_id).attributes["status"] in ("", "32") From 7fbfd446369b8ff4526a92a2018eb00da278e383 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jan 2024 15:14:03 -0500 Subject: [PATCH 1189/1544] Filter ZHA light group color modes (#108861) Ensure ZHA light color modes have proper defaults and are filtered --- homeassistant/components/zha/light.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 486b043b450..84399f3da32 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -8,7 +8,7 @@ import functools import itertools import logging import random -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -1183,7 +1183,9 @@ class LightGroup(BaseLight, ZhaGroupEntity): if self._zha_config_group_members_assume_state: self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY self._zha_config_enhanced_light_transition = False - self._attr_color_mode = None + + self._attr_color_mode = ColorMode.UNKNOWN + self._attr_supported_color_modes = set() # remove this when all ZHA platforms and base entities are updated @property @@ -1283,7 +1285,6 @@ class LightGroup(BaseLight, ZhaGroupEntity): effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] - self._attr_color_mode = None all_color_modes = list( helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) ) @@ -1301,14 +1302,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): ): # switch to XY if all members do not support HS self._attr_color_mode = ColorMode.XY - self._attr_supported_color_modes = None - all_supported_color_modes = list( + all_supported_color_modes: list[set[ColorMode]] = list( helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. - self._attr_supported_color_modes = cast( - set[str], set().union(*all_supported_color_modes) + self._attr_supported_color_modes = filter_supported_color_modes( + set().union(*all_supported_color_modes) ) self._attr_supported_features = LightEntityFeature(0) From 6174aa4e5934e7ebd18e6ef8c4210863719c0328 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 21:18:54 +0100 Subject: [PATCH 1190/1544] Remove Shelly RSSI sensor if Wi-FI is not configured (#108390) * Remove Shelly RSSI sensor if Wi-FI is not configured * fix tests --- homeassistant/components/shelly/sensor.py | 3 +++ tests/components/shelly/conftest.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 7ae709ae84f..57e60c8fc48 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -907,6 +907,9 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + removal_condition=lambda config, _status, key: ( + config[key]["sta"]["enable"] is False + ), entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index af373f33c23..9d7bb9404f8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -159,6 +159,7 @@ MOCK_CONFIG = { "ui_data": {}, "device": {"name": "Test name"}, }, + "wifi": {"sta": {"enable": True}}, } MOCK_SHELLY_COAP = { From a22244707b57cf41347d244d8d7c82cf35987d72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Jan 2024 10:23:58 -1000 Subject: [PATCH 1191/1544] Create an issue when database backups fail because the system runs out of resources (#109020) --- homeassistant/components/recorder/core.py | 8 +++++++- .../components/recorder/strings.json | 4 ++++ homeassistant/components/recorder/util.py | 18 ++++++++++++++++++ tests/components/recorder/test_init.py | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index ad05cad3d54..07591c468b8 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -119,6 +119,7 @@ from .tasks import ( WaitTask, ) from .util import ( + async_create_backup_failure_issue, build_mysqldb_conv, dburl_to_path, end_incomplete_runs, @@ -1006,9 +1007,11 @@ class Recorder(threading.Thread): def _async_set_database_locked(task: DatabaseLockTask) -> None: task.database_locked.set() + local_start_time = dt_util.now() + hass = self.hass with write_lock_db_sqlite(self): # Notify that lock is being held, wait until database can be used again. - self.hass.add_job(_async_set_database_locked, task) + hass.add_job(_async_set_database_locked, task) while not task.database_unlock.wait(timeout=DB_LOCK_QUEUE_CHECK_TIMEOUT): if self._reached_max_backlog_percentage(90): _LOGGER.warning( @@ -1020,6 +1023,9 @@ class Recorder(threading.Thread): self.backlog, ) task.queue_overflow = True + hass.add_job( + async_create_backup_failure_issue, self.hass, local_start_time + ) break _LOGGER.info( "Database queue backlog reached %d entries during backup", diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index eb162628727..74b248354d7 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -12,6 +12,10 @@ "maria_db_range_index_regression": { "title": "Update MariaDB to {min_version} or later resolve a significant performance issue", "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version." + }, + "backup_failed_out_of_resources": { + "title": "Database backup failed due to lack of resources", + "description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter." } }, "services": { diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4a1bf940b24..f684160f86f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -470,6 +470,24 @@ def _async_create_mariadb_range_index_regression_issue( ) +@callback +def async_create_backup_failure_issue( + hass: HomeAssistant, + local_start_time: datetime, +) -> None: + """Create an issue when the backup fails because we run out of resources.""" + ir.async_create_issue( + hass, + DOMAIN, + "backup_failed_out_of_resources", + is_fixable=False, + severity=ir.IssueSeverity.CRITICAL, + learn_more_url="https://www.home-assistant.io/integrations/recorder", + translation_key="backup_failed_out_of_resources", + translation_placeholders={"start_time": local_start_time.strftime("%H:%M:%S")}, + ) + + def setup_connection_for_dialect( instance: Recorder, dialect_name: str, diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index f8aa219fdb4..78af9a64257 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -73,6 +73,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er, recorder as recorder_helper +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry from homeassistant.setup import async_setup_component, setup_component from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads @@ -1832,6 +1833,15 @@ async def test_database_lock_and_overflow( assert "Database queue backlog reached more than" in caplog.text assert not instance.unlock_database() + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + assert issue is not None + assert "start_time" in issue.translation_placeholders + start_time = issue.translation_placeholders["start_time"] + assert start_time is not None + # Should be in H:M:S format + assert start_time.count(":") == 2 + async def test_database_lock_and_overflow_checks_available_memory( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -1910,6 +1920,15 @@ async def test_database_lock_and_overflow_checks_available_memory( db_events = await instance.async_add_executor_job(_get_db_events) assert len(db_events) >= 2 + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + assert issue is not None + assert "start_time" in issue.translation_placeholders + start_time = issue.translation_placeholders["start_time"] + assert start_time is not None + # Should be in H:M:S format + assert start_time.count(":") == 2 + async def test_database_lock_timeout( recorder_mock: Recorder, hass: HomeAssistant, recorder_db_url: str From ef4e72f218f0f32bc55d277e25c83a6d98a9b434 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 30 Jan 2024 15:32:53 -0500 Subject: [PATCH 1192/1544] Fix precipitation typo in icons (#109156) --- homeassistant/components/number/icons.json | 4 ++-- homeassistant/components/sensor/icons.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index e6f9f6aa7c1..2ce22fcaa4a 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -93,10 +93,10 @@ "power_factor": { "default": "mdi:angle-acute" }, - "preciptation": { + "precipitation": { "default": "mdi:weather-rainy" }, - "preciptation_intensity": { + "precipitation_intensity": { "default": "mdi:weather-pouring" }, "pressure": { diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 7a709228d3f..24245d9bf03 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -96,10 +96,10 @@ "power_factor": { "default": "mdi:angle-acute" }, - "preciptation": { + "precipitation": { "default": "mdi:weather-rainy" }, - "preciptation_intensity": { + "precipitation_intensity": { "default": "mdi:weather-pouring" }, "pressure": { From 758e7489f1f2f90f9447d97b9c95dad9b902bb6a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Jan 2024 15:51:38 -0500 Subject: [PATCH 1193/1544] Fix ZHA cover inversion handling missing attributes (#109151) * Allow `window_covering_type` to be `None` * Create a `window_covering_mode` attribute and simplify inversion switch * Revert "Create a `window_covering_mode` attribute and simplify inversion switch" This reverts commit 048d649b4dff20aff2a0baa81cfdb9c7d3ce71c6. * check both config status and mode * coverage --------- Co-authored-by: David Mulcahey --- .../zha/core/cluster_handlers/closures.py | 6 ++-- homeassistant/components/zha/switch.py | 36 +++++++++++++++++++ tests/components/zha/test_switch.py | 30 ++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 13ca6f92aaf..879765aec3c 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -267,8 +267,6 @@ class WindowCoveringClusterHandler(ClusterHandler): ) @property - def window_covering_type(self) -> WindowCovering.WindowCoveringType: + def window_covering_type(self) -> WindowCovering.WindowCoveringType | None: """Return the window covering type.""" - return WindowCovering.WindowCoveringType( - self.cluster.get(WindowCovering.AttributeDefs.window_covering_type.name) - ) + return self.cluster.get(WindowCovering.AttributeDefs.window_covering_type.name) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 57b84bd1aa1..fe2a43f7334 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -604,6 +604,42 @@ class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): _attr_translation_key = "inverted" _attr_icon: str = "mdi:arrow-up-down" + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + window_covering_mode_attr = ( + WindowCovering.AttributeDefs.window_covering_mode.name + ) + # this entity needs 2 attributes to function + if ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + or window_covering_mode_attr + in cluster_handler.cluster.unsupported_attributes + or window_covering_mode_attr + not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(window_covering_mode_attr) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index cd25d17f84f..6bfd7e051f1 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -684,3 +684,33 @@ async def test_cover_inversion_switch( state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF + + +async def test_cover_inversion_switch_not_created( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await zha_device_joined_restored(zigpy_cover_device) + + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + # entity should not be created when mode or config status aren't present + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is None From e1576d59981ceb7e92db315e678f762fe24a95f8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 30 Jan 2024 21:58:16 +0100 Subject: [PATCH 1194/1544] Handle deprecated cloud tts voice (#109124) * Handle deprecated cloud tts voice * Add test * Fix test logic * Add breaks in ha version * Adjust translation string --- homeassistant/components/cloud/strings.json | 11 ++ homeassistant/components/cloud/tts.py | 41 +++++++- tests/components/cloud/test_tts.py | 111 ++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 56fb3c0f5c9..6f1e3c80bf7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -24,6 +24,17 @@ } }, "issues": { + "deprecated_voice": { + "title": "A deprecated voice was used", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::cloud::issues::deprecated_voice::title%]", + "description": "The '{deprecated_voice}' voice is deprecated and will be removed.\nPlease update your automations and scripts to replace the '{deprecated_voice}' with another voice like eg. '{replacement_voice}'." + } + } + } + }, "legacy_subscription": { "title": "Legacy subscription detected", "fix_flow": { diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 2626c01e66f..ba34ac7a9b0 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .assist_pipeline import async_migrate_cloud_pipeline_engine @@ -32,6 +33,7 @@ from .prefs import CloudPreferences ATTR_GENDER = "gender" +DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"} SUPPORT_LANGUAGES = list(TTS_VOICES) _LOGGER = logging.getLogger(__name__) @@ -158,13 +160,15 @@ class CloudTTSEntity(TextToSpeechEntity): self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" + original_voice: str | None = options.get(ATTR_VOICE) + voice = handle_deprecated_voice(self.hass, original_voice) # Process TTS try: data = await self.cloud.voice.process_tts( text=message, language=language, gender=options.get(ATTR_GENDER), - voice=options.get(ATTR_VOICE), + voice=voice, output=options[ATTR_AUDIO_OUTPUT], ) except VoiceError as err: @@ -230,13 +234,16 @@ class CloudProvider(Provider): self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from Home Assistant Cloud.""" + original_voice: str | None = options.get(ATTR_VOICE) + assert self.hass is not None + voice = handle_deprecated_voice(self.hass, original_voice) # Process TTS try: data = await self.cloud.voice.process_tts( text=message, language=language, gender=options.get(ATTR_GENDER), - voice=options.get(ATTR_VOICE), + voice=voice, output=options[ATTR_AUDIO_OUTPUT], ) except VoiceError as err: @@ -244,3 +251,33 @@ class CloudProvider(Provider): return (None, None) return (str(options[ATTR_AUDIO_OUTPUT].value), data) + + +@callback +def handle_deprecated_voice( + hass: HomeAssistant, + original_voice: str | None, +) -> str | None: + """Handle deprecated voice.""" + voice = original_voice + if ( + original_voice + and voice + and (voice := DEPRECATED_VOICES.get(original_voice, original_voice)) + != original_voice + ): + async_create_issue( + hass, + DOMAIN, + f"deprecated_voice_{original_voice}", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + breaks_in_ha_version="2024.8.0", + translation_key="deprecated_voice", + translation_placeholders={ + "deprecated_voice": original_voice, + "replacement_voice": voice, + }, + ) + return voice diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index b75d2361070..92a9cb10992 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -17,6 +17,7 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.issue_registry import IssueRegistry, IssueSeverity from homeassistant.setup import async_setup_component from . import PIPELINE_DATA @@ -408,3 +409,113 @@ async def test_migrating_pipelines( assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_id"] is None assert hass_storage[STORAGE_KEY]["data"]["items"][1] == PIPELINE_DATA["items"][1] assert hass_storage[STORAGE_KEY]["data"]["items"][2] == PIPELINE_DATA["items"][2] + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ({"engine_id": "tts.home_assistant_cloud"}, "tts.home_assistant_cloud"), + ], +) +async def test_deprecated_voice( + hass: HomeAssistant, + issue_registry: IssueRegistry, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Test we create an issue when a deprecated voice is used for text-to-speech.""" + language = "zh-CN" + deprecated_voice = "XiaoxuanNeural" + replacement_voice = "XiaozhenNeural" + mock_process_tts = AsyncMock( + return_value=b"", + ) + cloud.voice.process_tts = mock_process_tts + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + client = await hass_client() + + # Test with non deprecated voice. + url = "/api/tts_get_url" + data |= { + "message": "There is someone at the door.", + "language": language, + "options": {"voice": replacement_voice}, + } + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_1c4ec2f170_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( + "cloud", f"deprecated_voice_{replacement_voice}" + ) + assert issue is None + mock_process_tts.reset_mock() + + # Test with deprecated voice. + data["options"] = {"voice": deprecated_voice} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_{language.lower()}_a1c3b0ac0e_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( + "cloud", f"deprecated_voice_{deprecated_voice}" + ) + assert issue is not None + assert issue.breaks_in_ha_version == "2024.8.0" + assert issue.is_fixable is True + assert issue.is_persistent is True + assert issue.severity == IssueSeverity.WARNING + assert issue.translation_key == "deprecated_voice" + assert issue.translation_placeholders == { + "deprecated_voice": deprecated_voice, + "replacement_voice": replacement_voice, + } From d4c91bd0b790e5bdcf13dcb9697f16d9d5c8068f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 30 Jan 2024 21:59:33 +0100 Subject: [PATCH 1195/1544] Add a repair issue for Shelly devices with unsupported firmware (#109076) Co-authored-by: J. Nick Koston --- homeassistant/components/shelly/__init__.py | 15 ++++++ homeassistant/components/shelly/const.py | 2 + homeassistant/components/shelly/strings.json | 4 ++ homeassistant/components/shelly/utils.py | 23 ++++++++- tests/components/shelly/test_coordinator.py | 50 +++++++++++++++++++- tests/components/shelly/test_init.py | 13 +++-- 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 6b8d100ea8f..142b5f9c521 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -9,6 +9,7 @@ from aioshelly.common import ConnectionOptions from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -37,6 +38,7 @@ from .const import ( DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, LOGGER, MODELS_WITH_WRONG_SLEEP_PERIOD, PUSH_UPDATE_ISSUE_ID, @@ -50,6 +52,7 @@ from .coordinator import ( get_entry_data, ) from .utils import ( + async_create_issue_unsupported_firmware, get_block_device_sleep_period, get_coap_context, get_device_entry_gen, @@ -216,6 +219,9 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err await _async_block_device_setup() elif sleep_period is None or device_entry is None: @@ -230,6 +236,9 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b LOGGER.debug("Setting up offline block device %s", entry.title) await _async_block_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True @@ -296,6 +305,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo LOGGER.debug("Setting up online RPC device %s", entry.title) try: await device.initialize() + except FirmwareUnsupported as err: + async_create_issue_unsupported_firmware(hass, entry) + raise ConfigEntryNotReady from err except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: @@ -314,6 +326,9 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo LOGGER.debug("Setting up offline block device %s", entry.title) await _async_rpc_device_setup() + ir.async_delete_issue( + hass, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + ) return True diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 6cc513015d3..827a6c00a30 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -212,6 +212,8 @@ PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" +FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" + GAS_VALVE_OPEN_STATES = ("opening", "opened") OTA_BEGIN = "ota_begin" diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c1f9b799444..9676c24f883 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -168,6 +168,10 @@ "deprecated_valve_switch_entity": { "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." + }, + "unsupported_firmware": { + "title": "Unsupported firmware for device {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d40b22ca50a..f5196504fe6 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -22,7 +22,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import singleton +from homeassistant.helpers import issue_registry as ir, singleton from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, async_get as dr_async_get, @@ -38,6 +38,7 @@ from .const import ( DEFAULT_COAP_PORT, DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID, GEN1_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, @@ -426,3 +427,23 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL + + +@callback +def async_create_issue_unsupported_firmware( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Create a repair issue if the device runs an unsupported firmware.""" + ir.async_create_issue( + hass, + DOMAIN, + FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_firmware", + translation_placeholders={ + "device_name": entry.title, + "ip_address": entry.data["host"], + }, + ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8e288ba1687..f17d8491782 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,7 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from aioshelly.const import MODEL_BULB, MODEL_BUTTON1 -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import ( + DeviceConnectionError, + FirmwareUnsupported, + InvalidAuthError, +) from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -186,6 +190,27 @@ async def test_block_rest_update_auth_error( assert flow["context"].get("entry_id") == entry.entry_id +async def test_block_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch +) -> None: + """Test block device polling authentication error.""" + monkeypatch.setattr( + mock_block_device, + "update", + AsyncMock(side_effect=FirmwareUnsupported), + ) + entry = await init_integration(hass, 1) + + assert entry.state is ConfigEntryState.LOADED + + # Move time to generate polling + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + async def test_block_polling_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: @@ -504,7 +529,28 @@ async def test_rpc_sleeping_device_no_periodic_updates( async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + + +async def test_rpc_firmware_unsupported( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch +) -> None: + """Test RPC update entry unsupported firmware.""" + entry = await init_integration(hass, 2) + register_entity( + hass, + SENSOR_DOMAIN, + "test_name_temperature", + "temperature:0-temperature_0", + entry, + ) + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED async def test_rpc_reconnect_auth_error( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index bc0ba045a55..0cd206e33a2 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from aioshelly.exceptions import ( DeviceConnectionError, + FirmwareUnsupported, InvalidAuthError, MacAddressMismatchError, ) @@ -79,15 +80,21 @@ async def test_setup_entry_not_shelly( @pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize("side_effect", [DeviceConnectionError, FirmwareUnsupported]) async def test_device_connection_error( - hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch + hass: HomeAssistant, + gen, + side_effect, + mock_block_device, + mock_rpc_device, + monkeypatch, ) -> None: """Test device connection error.""" monkeypatch.setattr( - mock_block_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_block_device, "initialize", AsyncMock(side_effect=side_effect) ) monkeypatch.setattr( - mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + mock_rpc_device, "initialize", AsyncMock(side_effect=side_effect) ) entry = await init_integration(hass, gen) From 0b09ffbcde723e3ad64d7d5f1a7bae49db56675c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 30 Jan 2024 22:39:59 +0100 Subject: [PATCH 1196/1544] Bump zha-quirks to 0.0.110 (#109161) * Bump zha-quirks to 0.0.110 * Reflect removal of `IasWd` cluster for Heiman sensor in tests --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/zha_devices_list.py | 25 ---------------------- 4 files changed, 3 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 024fea9227a..ead1087b8c8 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.37.6", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.109", + "zha-quirks==0.0.110", "zigpy-deconz==0.22.4", "zigpy==0.60.7", "zigpy-xbee==0.20.1", diff --git a/requirements_all.txt b/requirements_all.txt index 191b5296410..66a6aeb5305 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2906,7 +2906,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.109 +zha-quirks==0.0.110 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 894c5a8735e..218dacf9195 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2223,7 +2223,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.109 +zha-quirks==0.0.110 # homeassistant.components.zha zigpy-deconz==0.22.4 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 84a7b6443a1..8078b9e13bd 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -798,31 +798,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_lqi", }, - ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_tone", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_siren_level", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe_level", - }, - ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", - DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_default_strobe", - }, - ("siren", "00:11:22:33:44:55:66:77-1-1282"): { - DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], - DEV_SIG_ENT_MAP_CLASS: "ZHASiren", - DEV_SIG_ENT_MAP_ID: "siren.heiman_smokesensor_em_siren", - }, }, }, { From e7d5ae7ef67eb66f1b668c8131198b5a8252ce99 Mon Sep 17 00:00:00 2001 From: escoand Date: Tue, 30 Jan 2024 23:01:26 +0100 Subject: [PATCH 1197/1544] Add Nextcloud update entity (#106690) * add nextcloud update entity * don't init update entity on older nextcloud versions * ruff * pass skipUpdate to module * bump deps * bump requirements * Update homeassistant/components/nextcloud/update.py Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * Update homeassistant/components/nextcloud/update.py Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * bump requirements * Update homeassistant/components/nextcloud/update.py Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * Update homeassistant/components/nextcloud/update.py Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .coveragerc | 1 + .../components/nextcloud/__init__.py | 5 +- .../components/nextcloud/manifest.json | 2 +- homeassistant/components/nextcloud/update.py | 51 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/nextcloud/update.py diff --git a/.coveragerc b/.coveragerc index fed57af6ad7..5d0db972ca9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -844,6 +844,7 @@ omit = homeassistant/components/nextcloud/coordinator.py homeassistant/components/nextcloud/entity.py homeassistant/components/nextcloud/sensor.py + homeassistant/components/nextcloud/update.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 9cfe4aa7f70..11d2a85d851 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator -PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) +PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -52,7 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + skip_update=False, ) try: diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index fe4366c334d..64fda8c18ba 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", - "requirements": ["nextcloudmonitor==1.4.0"] + "requirements": ["nextcloudmonitor==1.5.0"] } diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py new file mode 100644 index 00000000000..5d52ac2a48f --- /dev/null +++ b/homeassistant/components/nextcloud/update.py @@ -0,0 +1,51 @@ +"""Update data from Nextcoud.""" +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity, UpdateEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator +from .entity import NextcloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Nextcloud update entity.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator.data.get("update_available") is None: + return + async_add_entities( + [ + NextcloudUpdateSensor( + coordinator, entry, UpdateEntityDescription(key="update") + ) + ] + ) + + +class NextcloudUpdateSensor(NextcloudEntity, UpdateEntity): + """Represents a Nextcloud update entity.""" + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.coordinator.data.get("system_version") + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self.coordinator.data.get( + "update_available_version", self.installed_version + ) + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version: + ver = "-".join(self.latest_version.split(".")[:3]) + return f"https://nextcloud.com/changelog/#{ver}" + return None diff --git a/requirements_all.txt b/requirements_all.txt index 66a6aeb5305..8e7ac7a1203 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1352,7 +1352,7 @@ neurio==0.3.1 nexia==2.0.8 # homeassistant.components.nextcloud -nextcloudmonitor==1.4.0 +nextcloudmonitor==1.5.0 # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 218dacf9195..559931fb0a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1076,7 +1076,7 @@ nettigo-air-monitor==2.2.2 nexia==2.0.8 # homeassistant.components.nextcloud -nextcloudmonitor==1.4.0 +nextcloudmonitor==1.5.0 # homeassistant.components.discord nextcord==2.0.0a8 From b409933d19f188730928619231cb8f8a1f903d5b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Jan 2024 23:08:12 +0100 Subject: [PATCH 1198/1544] Add DurationConverter (#108865) * Add DurationConverter * Update withings snapshots * Add sensor test * Fix tests * Update snapshots after #108902 was merged --- .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 2 + homeassistant/util/unit_conversion.py | 29 ++++++++- tests/components/sensor/test_init.py | 17 +++++ .../withings/snapshots/test_sensor.ambr | 63 ++++++++++++------- tests/util/test_unit_conversion.py | 48 ++++++++++++++ 7 files changed, 141 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 5786c9ee542..5abe395a8d7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,6 +30,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -126,6 +127,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, + **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, **{ unit: ElectricPotentialConverter diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 11271d1e0cd..39821cb9699 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -17,6 +17,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -57,6 +58,7 @@ UNIT_SCHEMA = vol.Schema( { vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS), vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 861338f257a..aad882821d6 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -47,6 +47,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -485,6 +486,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, + SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 15912fa2f6e..be356a8ad5f 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -20,6 +20,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -39,8 +40,9 @@ _MILE_TO_M = _YARD_TO_M * 1760 # 1760 yard = 1 mile (1609.344 m) _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Duration conversion constants -_HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +_MIN_TO_SEC = 60 # 1 min = 60 seconds _HRS_TO_MINUTES = 60 # 1 hr = 60 minutes +_HRS_TO_SECS = _HRS_TO_MINUTES * _MIN_TO_SEC # 1 hr = 60 minutes = 3600 seconds _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds # Mass conversion constants @@ -541,3 +543,28 @@ class VolumeFlowRateConverter(BaseUnitConverter): UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, } + + +class DurationConverter(BaseUnitConverter): + """Utility to convert duration values.""" + + UNIT_CLASS = "duration" + NORMALIZED_UNIT = UnitOfTime.SECONDS + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfTime.MICROSECONDS: 1000000, + UnitOfTime.MILLISECONDS: 1000, + UnitOfTime.SECONDS: 1, + UnitOfTime.MINUTES: 1 / _MIN_TO_SEC, + UnitOfTime.HOURS: 1 / _HRS_TO_SECS, + UnitOfTime.DAYS: 1 / _DAYS_TO_SECS, + UnitOfTime.WEEKS: 1 / (7 * _DAYS_TO_SECS), + } + VALID_UNITS = { + UnitOfTime.MICROSECONDS, + UnitOfTime.MILLISECONDS, + UnitOfTime.SECONDS, + UnitOfTime.MINUTES, + UnitOfTime.HOURS, + UnitOfTime.DAYS, + UnitOfTime.WEEKS, + } diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98b3f2423cc..a120ad8db78 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -37,6 +37,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -599,6 +600,22 @@ async def test_restore_sensor_restore_state( 13.0, "49.2", ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.SECONDS, + UnitOfTime.HOURS, + UnitOfTime.HOURS, + 5400.0, + "1.5000", + ), + ( + SensorDeviceClass.DURATION, + UnitOfTime.DAYS, + UnitOfTime.MINUTES, + UnitOfTime.MINUTES, + 0.5, + "720.0", + ), ], ) async def test_custom_unit( diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 8e3866a7561..f84fe05bb78 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -71,6 +71,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -80,7 +83,7 @@ 'supported_features': 0, 'translation_key': 'activity_active_duration_today', 'unique_id': 'withings_12345_activity_active_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_active_time_today-state] @@ -90,13 +93,13 @@ 'friendly_name': 'henk Active time today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_active_time_today', 'last_changed': , 'last_updated': , - 'state': '1907', + 'state': '0.530', }) # --- # name: test_all_entities[sensor.henk_average_heart_rate-entry] @@ -1173,6 +1176,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1182,7 +1188,7 @@ 'supported_features': 0, 'translation_key': 'activity_intense_duration_today', 'unique_id': 'withings_12345_activity_intense_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_intense_activity_today-state] @@ -1192,13 +1198,13 @@ 'friendly_name': 'henk Intense activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_intense_activity_today', 'last_changed': , 'last_updated': , - 'state': '420', + 'state': '7.0', }) # --- # name: test_all_entities[sensor.henk_intracellular_water-entry] @@ -1268,6 +1274,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1277,7 +1286,7 @@ 'supported_features': 0, 'translation_key': 'workout_duration', 'unique_id': 'withings_12345_workout_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_last_workout_duration-state] @@ -1285,13 +1294,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Last workout duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_last_workout_duration', 'last_changed': , 'last_updated': , - 'state': '255.0', + 'state': '4.25', }) # --- # name: test_all_entities[sensor.henk_last_workout_intensity-entry] @@ -1741,6 +1750,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1750,7 +1762,7 @@ 'supported_features': 0, 'translation_key': 'activity_moderate_duration_today', 'unique_id': 'withings_12345_activity_moderate_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_moderate_activity_today-state] @@ -1760,13 +1772,13 @@ 'friendly_name': 'henk Moderate activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_moderate_activity_today', 'last_changed': , 'last_updated': , - 'state': '1487', + 'state': '24.8', }) # --- # name: test_all_entities[sensor.henk_muscle_mass-entry] @@ -1839,6 +1851,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1848,7 +1863,7 @@ 'supported_features': 0, 'translation_key': 'workout_pause_duration', 'unique_id': 'withings_12345_workout_pause_duration', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_pause_during_last_workout-state] @@ -1856,13 +1871,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'duration', 'friendly_name': 'henk Pause during last workout', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_pause_during_last_workout', 'last_changed': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_all_entities[sensor.henk_pulse_wave_velocity-entry] @@ -2030,6 +2045,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2039,7 +2057,7 @@ 'supported_features': 0, 'translation_key': 'sleep_goal', 'unique_id': 'withings_12345_sleep_goal', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_sleep_goal-state] @@ -2048,13 +2066,13 @@ 'device_class': 'duration', 'friendly_name': 'henk Sleep goal', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_sleep_goal', 'last_changed': , 'last_updated': , - 'state': '28800', + 'state': '8.000', }) # --- # name: test_all_entities[sensor.henk_sleep_score-entry] @@ -2217,6 +2235,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2226,7 +2247,7 @@ 'supported_features': 0, 'translation_key': 'activity_soft_duration_today', 'unique_id': 'withings_12345_activity_soft_duration_today', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_soft_activity_today-state] @@ -2236,13 +2257,13 @@ 'friendly_name': 'henk Soft activity today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_soft_activity_today', 'last_changed': , 'last_updated': , - 'state': '1516', + 'state': '25.3', }) # --- # name: test_all_entities[sensor.henk_spo2-entry] diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 08d362072d4..d4649671f47 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -21,6 +21,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, UnitOfVolumetricFlux, @@ -31,6 +32,7 @@ from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -56,6 +58,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { for converter in ( DataRateConverter, DistanceConverter, + DurationConverter, ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, @@ -79,6 +82,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 8, ), DistanceConverter: (UnitOfLength.KILOMETERS, UnitOfLength.METERS, 0.001), + DurationConverter: (UnitOfTime.MINUTES, UnitOfTime.SECONDS, 1 / 60), ElectricCurrentConverter: ( UnitOfElectricCurrent.AMPERE, UnitOfElectricCurrent.MILLIAMPERE, @@ -202,6 +206,50 @@ _CONVERTED_VALUE: dict[ (5000000, UnitOfLength.MILLIMETERS, 16404.2, UnitOfLength.FEET), (5000000, UnitOfLength.MILLIMETERS, 196850.5, UnitOfLength.INCHES), ], + DurationConverter: [ + (5, UnitOfTime.MICROSECONDS, 0.005, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MICROSECONDS, 5e-6, UnitOfTime.SECONDS), + (5, UnitOfTime.MICROSECONDS, 8.333333333333333e-8, UnitOfTime.MINUTES), + (5, UnitOfTime.MICROSECONDS, 1.388888888888889e-9, UnitOfTime.HOURS), + (5, UnitOfTime.MICROSECONDS, 5.787e-11, UnitOfTime.DAYS), + (5, UnitOfTime.MICROSECONDS, 8.267195767195767e-12, UnitOfTime.WEEKS), + (5, UnitOfTime.MILLISECONDS, 5000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MILLISECONDS, 0.005, UnitOfTime.SECONDS), + (5, UnitOfTime.MILLISECONDS, 8.333333333333333e-5, UnitOfTime.MINUTES), + (5, UnitOfTime.MILLISECONDS, 1.388888888888889e-6, UnitOfTime.HOURS), + (5, UnitOfTime.MILLISECONDS, 5.787e-8, UnitOfTime.DAYS), + (5, UnitOfTime.MILLISECONDS, 8.267195767195767e-9, UnitOfTime.WEEKS), + (5, UnitOfTime.SECONDS, 5e6, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.SECONDS, 5000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.SECONDS, 0.0833333, UnitOfTime.MINUTES), + (5, UnitOfTime.SECONDS, 0.00138889, UnitOfTime.HOURS), + (5, UnitOfTime.SECONDS, 5.787037037037037e-5, UnitOfTime.DAYS), + (5, UnitOfTime.SECONDS, 8.267195767195768e-06, UnitOfTime.WEEKS), + (5, UnitOfTime.MINUTES, 3e8, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.MINUTES, 300000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.MINUTES, 300, UnitOfTime.SECONDS), + (5, UnitOfTime.MINUTES, 0.0833333, UnitOfTime.HOURS), + (5, UnitOfTime.MINUTES, 0.00347222, UnitOfTime.DAYS), + (5, UnitOfTime.MINUTES, 0.000496031746031746, UnitOfTime.WEEKS), + (5, UnitOfTime.HOURS, 18000000000, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.HOURS, 18000000, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.HOURS, 18000, UnitOfTime.SECONDS), + (5, UnitOfTime.HOURS, 300, UnitOfTime.MINUTES), + (5, UnitOfTime.HOURS, 0.208333333, UnitOfTime.DAYS), + (5, UnitOfTime.HOURS, 0.02976190476190476, UnitOfTime.WEEKS), + (5, UnitOfTime.DAYS, 4.32e11, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.DAYS, 4.32e8, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.DAYS, 432000, UnitOfTime.SECONDS), + (5, UnitOfTime.DAYS, 7200, UnitOfTime.MINUTES), + (5, UnitOfTime.DAYS, 120, UnitOfTime.HOURS), + (5, UnitOfTime.DAYS, 0.7142857142857143, UnitOfTime.WEEKS), + (5, UnitOfTime.WEEKS, 3.024e12, UnitOfTime.MICROSECONDS), + (5, UnitOfTime.WEEKS, 3.024e9, UnitOfTime.MILLISECONDS), + (5, UnitOfTime.WEEKS, 3024000, UnitOfTime.SECONDS), + (5, UnitOfTime.WEEKS, 50400, UnitOfTime.MINUTES), + (5, UnitOfTime.WEEKS, 840, UnitOfTime.HOURS), + (5, UnitOfTime.WEEKS, 35, UnitOfTime.DAYS), + ], ElectricCurrentConverter: [ (5, UnitOfElectricCurrent.AMPERE, 5000, UnitOfElectricCurrent.MILLIAMPERE), (5, UnitOfElectricCurrent.MILLIAMPERE, 0.005, UnitOfElectricCurrent.AMPERE), From 2909e1c4fe0bcb8bd4449e2ef52078bfaabee830 Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Tue, 30 Jan 2024 22:11:18 +0000 Subject: [PATCH 1199/1544] Fix ZHA handling of power factor ElectricalMeasurement attribute sensor (#107641) * Correct handling of power_factor ElectricalMeasurement attribute The Zigbee Cluster Library defines PowerFactor as an int8 with value supported from -100 to 100. Currently the zha sensor handler attempts to apply the ac_power_divisor and ac_power_multiplier formatters against the attribute value, the spec outlines that this should not be the case. The impact of the current code is that quirks not using the default values of 1 are multiplying/dividing power and power factor values prior to updating the cluster attribute. This results in either a non-conformant power_factor e.g. the value was multiplied by 10 so that an ac_power_divider of 10 could be used, or the power readings sacrificing a point of measurement for lower readings. Two quirks currently use this workaround: * ts0601_din_power.py * ts0601_rcbo.py * Update ZHA Metering formatter to perform None check on _div_mul_prefix To address feedback: https://github.com/home-assistant/core/pull/107641#discussion_r1447547054 * _div_mul_prefix needs self reference * Simplify None check for _div_mul_prefix Co-authored-by: Joakim Plate * Updates to formatting and CI test typing fix * Use ' | ' in place of Union * Add tests for power_factor sensor --------- Co-authored-by: Joakim Plate --- homeassistant/components/zha/sensor.py | 17 +++++++++++------ tests/components/zha/test_sensor.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index bb62494396a..4986742c63d 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -319,7 +319,7 @@ class ElectricalMeasurement(PollableSensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement: str = UnitOfPower.WATT - _div_mul_prefix = "ac_power" + _div_mul_prefix: str | None = "ac_power" @property def extra_state_attributes(self) -> dict[str, Any]: @@ -342,10 +342,14 @@ class ElectricalMeasurement(PollableSensor): def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" - multiplier = getattr( - self._cluster_handler, f"{self._div_mul_prefix}_multiplier" - ) - divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") + if self._div_mul_prefix: + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") + else: + multiplier = self._multiplier + divisor = self._divisor value = float(value * multiplier) / divisor if value < 100 and divisor > 1: return round(value, self._decimals) @@ -419,13 +423,14 @@ class ElectricalMeasurementFrequency(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): - """Frequency measurement.""" + """Power Factor measurement.""" _attribute_name = "power_factor" _unique_id_suffix = "power_factor" _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_native_unit_of_measurement = PERCENTAGE + _div_mul_prefix = None @MULTI_MATCH( diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 48651df082d..e25430a293b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -259,6 +259,24 @@ async def async_test_em_apparent_power(hass: HomeAssistant, cluster, entity_id): assert_state(hass, entity_id, "9.9", UnitOfApparentPower.VOLT_AMPERE) +async def async_test_em_power_factor(hass: HomeAssistant, cluster, entity_id): + """Test electrical measurement Power Factor sensor.""" + # update divisor cached value + await send_attributes_report(hass, cluster, {"ac_power_divisor": 1}) + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 1000}) + assert_state(hass, entity_id, "100", PERCENTAGE) + + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 1000}) + assert_state(hass, entity_id, "99", PERCENTAGE) + + await send_attributes_report(hass, cluster, {"ac_power_divisor": 10}) + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 100, 10: 5000}) + assert_state(hass, entity_id, "100", PERCENTAGE) + + await send_attributes_report(hass, cluster, {0: 1, 0x0510: 99, 10: 5000}) + assert_state(hass, entity_id, "99", PERCENTAGE) + + async def async_test_em_rms_current(hass: HomeAssistant, cluster, entity_id): """Test electrical measurement RMS Current sensor.""" @@ -428,6 +446,14 @@ async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id) {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"active_power", "rms_current", "rms_voltage"}, ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "power_factor", + async_test_em_power_factor, + 7, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"active_power", "apparent_power", "rms_current", "rms_voltage"}, + ), ( homeautomation.ElectricalMeasurement.cluster_id, "current", From bea7dd756aefe156986f740e1876c7da201e8b35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Jan 2024 12:34:48 -1000 Subject: [PATCH 1200/1544] Bump regenmaschine to 2024.01.0 (#109157) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index dabae5ff8c6..1c4c78564f6 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["regenmaschine"], - "requirements": ["regenmaschine==2023.06.0"], + "requirements": ["regenmaschine==2024.01.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 8e7ac7a1203..26fbc94e592 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2407,7 +2407,7 @@ raspyrfm-client==1.2.8 refoss-ha==1.2.0 # homeassistant.components.rainmachine -regenmaschine==2023.06.0 +regenmaschine==2024.01.0 # homeassistant.components.renault renault-api==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 559931fb0a8..339e1bdfff2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1838,7 +1838,7 @@ rapt-ble==0.1.2 refoss-ha==1.2.0 # homeassistant.components.rainmachine -regenmaschine==2023.06.0 +regenmaschine==2024.01.0 # homeassistant.components.renault renault-api==0.2.1 From b6126e78211632f6c24c446680fdfa88f82521f3 Mon Sep 17 00:00:00 2001 From: Josh Pettersen <12600312+bubonicbob@users.noreply.github.com> Date: Tue, 30 Jan 2024 14:38:45 -0800 Subject: [PATCH 1201/1544] Convert gather calls into TaskGroups (#109010) --- .../components/powerwall/__init__.py | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 29e890e6027..79e612deb4c 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -222,28 +222,31 @@ async def _login_and_fetch_base_info( async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: """Return PowerwallBaseInfo for the device.""" - ( - gateway_din, - site_info, - status, - device_type, - serial_numbers, - ) = await asyncio.gather( - power_wall.get_gateway_din(), - power_wall.get_site_info(), - power_wall.get_status(), - power_wall.get_device_type(), - power_wall.get_serial_numbers(), - ) + try: + async with asyncio.TaskGroup() as tg: + gateway_din = tg.create_task(power_wall.get_gateway_din()) + site_info = tg.create_task(power_wall.get_site_info()) + status = tg.create_task(power_wall.get_status()) + device_type = tg.create_task(power_wall.get_device_type()) + serial_numbers = tg.create_task(power_wall.get_serial_numbers()) + + # Mimic the behavior of asyncio.gather by reraising the first caught exception since + # this is what is expected by the caller of this method + # + # While it would have been cleaner to use asyncio.gather in the first place instead of + # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to + # missing typing information. + except BaseExceptionGroup as e: + raise e.exceptions[0] from None # Serial numbers MUST be sorted to ensure the unique_id is always the same # for backwards compatibility. return PowerwallBaseInfo( - gateway_din=gateway_din.upper(), - site_info=site_info, - status=status, - device_type=device_type, - serial_numbers=sorted(serial_numbers), + gateway_din=gateway_din.result().upper(), + site_info=site_info.result(), + status=status.result(), + device_type=device_type.result(), + serial_numbers=sorted(serial_numbers.result()), url=f"https://{host}", ) @@ -258,29 +261,32 @@ async def get_backup_reserve_percentage(power_wall: Powerwall) -> Optional[float async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: """Process and update powerwall data.""" - ( - backup_reserve, - charge, - site_master, - meters, - grid_services_active, - grid_status, - ) = await asyncio.gather( - get_backup_reserve_percentage(power_wall), - power_wall.get_charge(), - power_wall.get_sitemaster(), - power_wall.get_meters(), - power_wall.is_grid_services_active(), - power_wall.get_grid_status(), - ) + + try: + async with asyncio.TaskGroup() as tg: + backup_reserve = tg.create_task(get_backup_reserve_percentage(power_wall)) + charge = tg.create_task(power_wall.get_charge()) + site_master = tg.create_task(power_wall.get_sitemaster()) + meters = tg.create_task(power_wall.get_meters()) + grid_services_active = tg.create_task(power_wall.is_grid_services_active()) + grid_status = tg.create_task(power_wall.get_grid_status()) + + # Mimic the behavior of asyncio.gather by reraising the first caught exception since + # this is what is expected by the caller of this method + # + # While it would have been cleaner to use asyncio.gather in the first place instead of + # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to + # missing typing information. + except BaseExceptionGroup as e: + raise e.exceptions[0] from None return PowerwallData( - charge=charge, - site_master=site_master, - meters=meters, - grid_services_active=grid_services_active, - grid_status=grid_status, - backup_reserve=backup_reserve, + charge=charge.result(), + site_master=site_master.result(), + meters=meters.result(), + grid_services_active=grid_services_active.result(), + grid_status=grid_status.result(), + backup_reserve=backup_reserve.result(), ) From 1aa9807e26db8a8c4056b4d9f045b3c168903cf9 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 31 Jan 2024 00:07:59 +0100 Subject: [PATCH 1202/1544] Fix "deprecated" typo in ZHA smartenergy comment (#109173) * Fix "deprecated" typo in ZHA smartenergy comment * Fix in both places --- .../components/zha/core/cluster_handlers/smartenergy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 577da25332d..65a02a01e02 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -157,7 +157,7 @@ class MeteringClusterHandler(ClusterHandler): 0: "Electric Metering", 1: "Gas Metering", 2: "Water Metering", - 3: "Thermal Metering", # depreciated + 3: "Thermal Metering", # deprecated 4: "Pressure Metering", 5: "Heat Metering", 6: "Cooling Metering", @@ -173,7 +173,7 @@ class MeteringClusterHandler(ClusterHandler): 127: "Mirrored Electric Metering", 128: "Mirrored Gas Metering", 129: "Mirrored Water Metering", - 130: "Mirrored Thermal Metering", # depreciated + 130: "Mirrored Thermal Metering", # deprecated 131: "Mirrored Pressure Metering", 132: "Mirrored Heat Metering", 133: "Mirrored Cooling Metering", From 09a89cd3e9d64f125204291e1915da5400a97e27 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 31 Jan 2024 00:43:39 +0100 Subject: [PATCH 1203/1544] Fix Ecovacs duration sensors (#108868) --- homeassistant/components/ecovacs/sensor.py | 4 +++- .../ecovacs/snapshots/test_sensor.ambr | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 48c1fbbcecc..10dbf9c904d 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -72,6 +72,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.time, translation_key="stats_time", + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.MINUTES, ), @@ -89,7 +90,8 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( value_fn=lambda e: e.time, key="total_stats_time", translation_key="total_stats_time", - native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, state_class=SensorStateClass.TOTAL_INCREASING, ), diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 5b072b6c232..3a59b3ba418 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -326,8 +326,11 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Time cleaned', 'platform': 'ecovacs', @@ -335,20 +338,21 @@ 'supported_features': 0, 'translation_key': 'stats_time', 'unique_id': 'E1234567890000000001_stats_time', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Ozmo 950 Time cleaned', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_time_cleaned', 'last_changed': , 'last_updated': , - 'state': '300', + 'state': '5.0', }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:entity-registry] @@ -465,8 +469,11 @@ 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Total time cleaned', 'platform': 'ecovacs', @@ -474,21 +481,22 @@ 'supported_features': 0, 'translation_key': 'total_stats_time', 'unique_id': 'E1234567890000000001_total_stats_time', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Ozmo 950 Total time cleaned', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_total_time_cleaned', 'last_changed': , 'last_updated': , - 'state': '144000', + 'state': '40.000', }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:entity-registry] From fcfacaaabdcd80d1700f874edc81de725a79060d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 Jan 2024 00:57:46 +0100 Subject: [PATCH 1204/1544] Store preferred border agent extended address for each thread dataset (#109065) --- homeassistant/components/otbr/__init__.py | 2 + .../components/thread/dataset_store.py | 80 +++++++--- .../components/thread/websocket_api.py | 17 ++- tests/components/otbr/__init__.py | 3 +- tests/components/otbr/conftest.py | 7 + tests/components/otbr/test_init.py | 35 ++++- tests/components/otbr/test_websocket_api.py | 14 +- tests/components/thread/__init__.py | 2 + tests/components/thread/test_dataset_store.py | 142 +++++++++++++++++- tests/components/thread/test_websocket_api.py | 10 +- 10 files changed, 266 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 0f4374d95bd..3c08a74ed61 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() + extended_address = await otbrdata.get_extended_address() except ( HomeAssistantError, aiohttp.ClientError, @@ -62,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, dataset_tlvs.hex(), preferred_border_agent_id=border_agent_id.hex(), + preferred_extended_address=extended_address.hex(), ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 9dc4ad31217..b5a3b39ae26 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -23,7 +23,7 @@ BORDER_AGENT_DISCOVERY_TIMEOUT = 30 DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) @@ -38,6 +38,7 @@ class DatasetEntry: """Dataset store entry.""" preferred_border_agent_id: str | None + preferred_extended_address: str | None source: str tlv: str @@ -79,6 +80,7 @@ class DatasetEntry: "created": self.created.isoformat(), "id": self.id, "preferred_border_agent_id": self.preferred_border_agent_id, + "preferred_extended_address": self.preferred_extended_address, "source": self.source, "tlv": self.tlv, } @@ -104,6 +106,7 @@ class DatasetStoreStore(Store): created=created, id=dataset["id"], preferred_border_agent_id=None, + preferred_extended_address=None, source=dataset["source"], tlv=dataset["tlv"], ) @@ -165,10 +168,14 @@ class DatasetStoreStore(Store): "preferred_dataset": preferred_dataset, "datasets": [dataset.to_json() for dataset in datasets.values()], } - if old_minor_version < 3: - # Add border agent ID + # Migration to version 1.3 removed, it added the ID of the preferred border + # agent + if old_minor_version < 4: + # Add extended address of the preferred border agent and clear border + # agent ID for dataset in data["datasets"]: - dataset.setdefault("preferred_border_agent_id", None) + dataset["preferred_border_agent_id"] = None + dataset["preferred_extended_address"] = None return data @@ -192,7 +199,11 @@ class DatasetStore: @callback def async_add( - self, source: str, tlv: str, preferred_border_agent_id: str | None + self, + source: str, + tlv: str, + preferred_border_agent_id: str | None, + preferred_extended_address: str | None, ) -> None: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid @@ -206,16 +217,23 @@ class DatasetStore: ): raise HomeAssistantError("Invalid dataset") + # Don't allow setting preferred border agent ID without setting + # preferred extended address + if preferred_border_agent_id is not None and preferred_extended_address is None: + raise HomeAssistantError( + "Must set preferred extended address with preferred border agent ID" + ) + # Bail out if the dataset already exists entry: DatasetEntry | None for entry in self.datasets.values(): if entry.dataset == dataset: if ( - preferred_border_agent_id - and entry.preferred_border_agent_id is None + preferred_extended_address + and entry.preferred_extended_address is None ): - self.async_set_preferred_border_agent_id( - entry.id, preferred_border_agent_id + self.async_set_preferred_border_agent( + entry.id, preferred_border_agent_id, preferred_extended_address ) return @@ -262,14 +280,17 @@ class DatasetStore: self.datasets[entry.id], tlv=tlv ) self.async_schedule_save() - if preferred_border_agent_id and entry.preferred_border_agent_id is None: - self.async_set_preferred_border_agent_id( - entry.id, preferred_border_agent_id + if preferred_extended_address and entry.preferred_extended_address is None: + self.async_set_preferred_border_agent( + entry.id, preferred_border_agent_id, preferred_extended_address ) return entry = DatasetEntry( - preferred_border_agent_id=preferred_border_agent_id, source=source, tlv=tlv + preferred_border_agent_id=preferred_border_agent_id, + preferred_extended_address=preferred_extended_address, + source=source, + tlv=tlv, ) self.datasets[entry.id] = entry self.async_schedule_save() @@ -278,12 +299,12 @@ class DatasetStore: # no other router present. We only attempt this once. if ( self._preferred_dataset is None - and preferred_border_agent_id + and preferred_extended_address and not self._set_preferred_dataset_task ): self._set_preferred_dataset_task = self.hass.async_create_task( self._set_preferred_dataset_if_only_network( - entry.id, preferred_border_agent_id + entry.id, preferred_extended_address ) ) @@ -301,12 +322,21 @@ class DatasetStore: return self.datasets.get(dataset_id) @callback - def async_set_preferred_border_agent_id( - self, dataset_id: str, border_agent_id: str + def async_set_preferred_border_agent( + self, dataset_id: str, border_agent_id: str | None, extended_address: str ) -> None: - """Set preferred border agent id of a dataset.""" + """Set preferred border agent id and extended address of a dataset.""" + # Don't allow setting preferred border agent ID without setting + # preferred extended address + if border_agent_id is not None and extended_address is None: + raise HomeAssistantError( + "Must set preferred extended address with preferred border agent ID" + ) + self.datasets[dataset_id] = dataclasses.replace( - self.datasets[dataset_id], preferred_border_agent_id=border_agent_id + self.datasets[dataset_id], + preferred_border_agent_id=border_agent_id, + preferred_extended_address=extended_address, ) self.async_schedule_save() @@ -326,12 +356,12 @@ class DatasetStore: self.async_schedule_save() async def _set_preferred_dataset_if_only_network( - self, dataset_id: str, border_agent_id: str + self, dataset_id: str, extended_address: str | None ) -> None: """Set the preferred dataset, unless there are other routers present.""" _LOGGER.debug( "_set_preferred_dataset_if_only_network called for router %s", - border_agent_id, + extended_address, ) own_router_evt = Event() @@ -342,8 +372,8 @@ class DatasetStore: key: str, data: discovery.ThreadRouterDiscoveryData ) -> None: """Handle router discovered.""" - _LOGGER.debug("discovered router with id %s", data.border_agent_id) - if data.border_agent_id == border_agent_id: + _LOGGER.debug("discovered router with ext addr %s", data.extended_address) + if data.extended_address == extended_address: own_router_evt.set() return @@ -395,6 +425,7 @@ class DatasetStore: created=created, id=dataset["id"], preferred_border_agent_id=dataset["preferred_border_agent_id"], + preferred_extended_address=dataset["preferred_extended_address"], source=dataset["source"], tlv=dataset["tlv"], ) @@ -431,10 +462,11 @@ async def async_add_dataset( tlv: str, *, preferred_border_agent_id: str | None = None, + preferred_extended_address: str | None = None, ) -> None: """Add a dataset.""" store = await async_get_store(hass) - store.async_add(source, tlv, preferred_border_agent_id) + store.async_add(source, tlv, preferred_border_agent_id, preferred_extended_address) async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5b289cf1694..9dd1971f91c 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,7 +20,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) - websocket_api.async_register_command(hass, ws_set_preferred_border_agent_id) + websocket_api.async_register_command(hass, ws_set_preferred_border_agent) websocket_api.async_register_command(hass, ws_set_preferred_dataset) @@ -54,20 +54,24 @@ async def ws_add_dataset( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required("type"): "thread/set_preferred_border_agent_id", + vol.Required("type"): "thread/set_preferred_border_agent", vol.Required("dataset_id"): str, - vol.Required("border_agent_id"): str, + vol.Required("border_agent_id"): vol.Any(str, None), + vol.Required("extended_address"): str, } ) @websocket_api.async_response -async def ws_set_preferred_border_agent_id( +async def ws_set_preferred_border_agent( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Set the preferred border agent ID.""" + """Set the preferred border agent's border agent ID and extended address.""" dataset_id = msg["dataset_id"] border_agent_id = msg["border_agent_id"] + extended_address = msg["extended_address"] store = await dataset_store.async_get_store(hass) - store.async_set_preferred_border_agent_id(dataset_id, border_agent_id) + store.async_set_preferred_border_agent( + dataset_id, border_agent_id, extended_address + ) connection.send_result(msg["id"]) @@ -174,6 +178,7 @@ async def ws_list_datasets( "pan_id": dataset.pan_id, "preferred": dataset.id == preferred_dataset, "preferred_border_agent_id": dataset.preferred_border_agent_id, + "preferred_extended_address": dataset.preferred_extended_address, "source": dataset.source, } ) diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index e72849aa5a1..c839cb0d06e 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -27,8 +27,9 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) -TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") +TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") +TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") ROUTER_DISCOVERY_HASS = { "type_": "_meshcop._udp.local.", diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 75922e99aa0..c03eef8dcb7 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -10,6 +10,7 @@ from . import ( CONFIG_ENTRY_DATA_MULTIPAN, CONFIG_ENTRY_DATA_THREAD, DATASET_CH16, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, ) @@ -30,6 +31,9 @@ async def otbr_config_entry_multipan_fixture(hass): "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests @@ -50,6 +54,9 @@ async def otbr_config_entry_thread_fixture(hass): "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 496427c083a..30569fe5428 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -25,6 +25,7 @@ from . import ( DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, ROUTER_DISCOVERY_HASS, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, ) @@ -66,6 +67,9 @@ async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.BORDER_AGENT_DISCOVERY_TIMEOUT", 0.1, @@ -97,6 +101,10 @@ async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> list(dataset_store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(dataset_store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await thread.async_get_preferred_dataset(hass) == DATASET_CH16.hex() assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" @@ -130,13 +138,19 @@ async def test_import_share_radio_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, DATASET_CH16.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + DATASET_CH16.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, @@ -167,13 +181,19 @@ async def test_import_share_radio_no_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + dataset.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, @@ -202,13 +222,19 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ), patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with( - otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + otbr.DOMAIN, + dataset.hex(), + TEST_BORDER_AGENT_ID.hex(), + TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" @@ -268,6 +294,9 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: mock_api = MagicMock() mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) mock_api.get_border_agent_id = AsyncMock(return_value=TEST_BORDER_AGENT_ID) + mock_api.get_extended_address = AsyncMock( + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS + ) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index c9f5327534a..52aa792b814 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -8,7 +8,13 @@ from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL, DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID +from . import ( + BASE_URL, + DATASET_CH15, + DATASET_CH16, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, + TEST_BORDER_AGENT_ID, +) from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -37,7 +43,7 @@ async def test_get_info( "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ), patch( "python_otbr_api.OTBR.get_extended_address", - return_value=bytes.fromhex("4EF6C4F3FF750626"), + return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS, ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() @@ -48,7 +54,7 @@ async def test_get_info( "active_dataset_tlvs": DATASET_CH16.hex().lower(), "channel": 16, "border_agent_id": TEST_BORDER_AGENT_ID.hex(), - "extended_address": "4EF6C4F3FF750626".lower(), + "extended_address": TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), } @@ -125,7 +131,7 @@ async def test_create_network( assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True get_active_dataset_tlvs_mock.assert_called_once() - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None, None) async def test_create_network_no_entry( diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index 155e46a8ee0..0b53c879c37 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -18,6 +18,8 @@ DATASET_3 = ( "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) +TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") + TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") ROUTER_DISCOVERY_GOOGLE_1 = { diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 246fb88f3ef..bcc16de4ed2 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -17,6 +17,7 @@ from . import ( DATASET_3, ROUTER_DISCOVERY_GOOGLE_1, ROUTER_DISCOVERY_HASS, + TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, ) @@ -269,7 +270,7 @@ async def test_load_datasets(hass: HomeAssistant) -> None: store1 = await dataset_store.async_get_store(hass) for dataset in datasets: - store1.async_add(dataset["source"], dataset["tlv"], None) + store1.async_add(dataset["source"], dataset["tlv"], None, None) assert len(store1.datasets) == 3 dataset_id = list(store1.datasets.values())[0].id store1.preferred_dataset = dataset_id @@ -321,6 +322,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", + "preferred_extended_address": "AEEB2F594B570BBF", "source": "source_1", "tlv": DATASET_1, }, @@ -328,6 +330,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", "preferred_border_agent_id": None, + "preferred_extended_address": "AEEB2F594B570BBF", "source": "source_2", "tlv": DATASET_2, }, @@ -335,6 +338,7 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "source_3", "tlv": DATASET_3, }, @@ -556,42 +560,148 @@ async def test_migrate_set_default_border_agent_id( store = await dataset_store.async_get_store(hass) assert store.datasets[store._preferred_dataset].preferred_border_agent_id is None + assert store.datasets[store._preferred_dataset].preferred_extended_address is None async def test_set_preferred_border_agent_id(hass: HomeAssistant) -> None: """Test set the preferred border agent ID of a dataset.""" assert await dataset_store.async_get_preferred_dataset(hass) is None + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="blah" + ) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 0 + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="bleh" + ) + assert len(store.datasets) == 0 + + await dataset_store.async_add_dataset(hass, "source", DATASET_2) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + with pytest.raises(HomeAssistantError): + await store.async_set_preferred_border_agent(dataset_id, "blah", None) + assert list(store.datasets.values())[0].preferred_border_agent_id is None + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert len(store.datasets) == 2 + assert list(store.datasets.values())[1].preferred_border_agent_id is None + + with pytest.raises(HomeAssistantError): + await dataset_store.async_add_dataset( + hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[1].preferred_border_agent_id is None + + +async def test_set_preferred_border_agent_id_and_extended_address( + hass: HomeAssistant, +) -> None: + """Test set the preferred border agent ID and extended address of a dataset.""" + assert await dataset_store.async_get_preferred_dataset(hass) is None + await dataset_store.async_add_dataset( - hass, "source", DATASET_3, preferred_border_agent_id="blah" + hass, + "source", + DATASET_3, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[0].preferred_extended_address == "bleh" await dataset_store.async_add_dataset( - hass, "source", DATASET_3, preferred_border_agent_id="bleh" + hass, + "source", + DATASET_3, + preferred_border_agent_id="bleh", + preferred_extended_address="bleh", ) assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[0].preferred_extended_address == "bleh" await dataset_store.async_add_dataset(hass, "source", DATASET_2) assert len(store.datasets) == 2 assert list(store.datasets.values())[1].preferred_border_agent_id is None + assert list(store.datasets.values())[1].preferred_extended_address is None await dataset_store.async_add_dataset( - hass, "source", DATASET_2, preferred_border_agent_id="blah" + hass, + "source", + DATASET_2, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[1].preferred_extended_address == "bleh" await dataset_store.async_add_dataset(hass, "source", DATASET_1) assert len(store.datasets) == 3 assert list(store.datasets.values())[2].preferred_border_agent_id is None + assert list(store.datasets.values())[2].preferred_extended_address is None await dataset_store.async_add_dataset( - hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" + hass, + "source", + DATASET_1_LARGER_TIMESTAMP, + preferred_border_agent_id="blah", + preferred_extended_address="bleh", ) - assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[2].preferred_border_agent_id == "blah" + assert list(store.datasets.values())[2].preferred_extended_address == "bleh" + + +async def test_set_preferred_extended_address(hass: HomeAssistant) -> None: + """Test set the preferred extended address of a dataset.""" + assert await dataset_store.async_get_preferred_dataset(hass) is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_extended_address="blah" + ) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_extended_address="bleh" + ) + assert list(store.datasets.values())[0].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_2) + assert len(store.datasets) == 2 + assert list(store.datasets.values())[1].preferred_extended_address is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_extended_address="blah" + ) + assert list(store.datasets.values())[1].preferred_extended_address == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert len(store.datasets) == 3 + assert list(store.datasets.values())[2].preferred_extended_address is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_extended_address="blah" + ) + assert list(store.datasets.values())[2].preferred_extended_address == "blah" async def test_automatically_set_preferred_dataset( @@ -624,6 +734,7 @@ async def test_automatically_set_preferred_dataset( "source", DATASET_1, preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) # Wait for discovery to start @@ -651,6 +762,10 @@ async def test_automatically_set_preferred_dataset( list(store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await dataset_store.async_get_preferred_dataset(hass) == DATASET_1 @@ -687,6 +802,7 @@ async def test_automatically_set_preferred_dataset_own_and_other_router( "source", DATASET_1, preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) # Wait for discovery to start @@ -725,6 +841,10 @@ async def test_automatically_set_preferred_dataset_own_and_other_router( list(store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await dataset_store.async_get_preferred_dataset(hass) is None @@ -761,6 +881,7 @@ async def test_automatically_set_preferred_dataset_other_router( "source", DATASET_1, preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) # Wait for discovery to start @@ -788,6 +909,10 @@ async def test_automatically_set_preferred_dataset_other_router( list(store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await dataset_store.async_get_preferred_dataset(hass) is None @@ -824,6 +949,7 @@ async def test_automatically_set_preferred_dataset_no_router( "source", DATASET_1, preferred_border_agent_id=TEST_BORDER_AGENT_ID.hex(), + preferred_extended_address=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(), ) # Wait for discovery to start @@ -840,4 +966,8 @@ async def test_automatically_set_preferred_dataset_no_router( list(store.datasets.values())[0].preferred_border_agent_id == TEST_BORDER_AGENT_ID.hex() ) + assert ( + list(store.datasets.values())[0].preferred_extended_address + == TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex() + ) assert await dataset_store.async_get_preferred_dataset(hass) is None diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 3b05586a1db..b277dcafcf4 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -175,6 +175,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": True, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "Google", }, { @@ -186,6 +187,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": False, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "Multipan", }, { @@ -197,6 +199,7 @@ async def test_list_get_dataset( "pan_id": "1234", "preferred": False, "preferred_border_agent_id": None, + "preferred_extended_address": None, "source": "🎅", }, ] @@ -217,7 +220,7 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} -async def test_set_preferred_border_agent_id( +async def test_set_preferred_border_agent( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test setting the preferred border agent ID.""" @@ -239,12 +242,14 @@ async def test_set_preferred_border_agent_id( datasets = msg["result"]["datasets"] dataset_id = datasets[0]["dataset_id"] assert datasets[0]["preferred_border_agent_id"] is None + assert datasets[0]["preferred_extended_address"] is None await client.send_json_auto_id( { - "type": "thread/set_preferred_border_agent_id", + "type": "thread/set_preferred_border_agent", "dataset_id": dataset_id, "border_agent_id": "blah", + "extended_address": "bleh", } ) msg = await client.receive_json() @@ -256,6 +261,7 @@ async def test_set_preferred_border_agent_id( assert msg["success"] datasets = msg["result"]["datasets"] assert datasets[0]["preferred_border_agent_id"] == "blah" + assert datasets[0]["preferred_extended_address"] == "bleh" async def test_set_preferred_dataset( From 182d00be6638e76e49b3ec0d91a6507ce2f91fd7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 31 Jan 2024 01:39:00 +0100 Subject: [PATCH 1205/1544] Bump python-matter-server to 5.4.0 (#109178) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b8b384060d6..4173e129895 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.3.1"] + "requirements": ["python-matter-server==5.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 26fbc94e592..e377ad8434c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ python-kasa[speedups]==0.6.2 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.3.1 +python-matter-server==5.4.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 339e1bdfff2..955cac70ed0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1705,7 +1705,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2 # homeassistant.components.matter -python-matter-server==5.3.1 +python-matter-server==5.4.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 95fca44e6d23fc66568d004a2224cec95715cd90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Wed, 31 Jan 2024 01:44:31 +0100 Subject: [PATCH 1206/1544] Fix schema validation for product_id in picnic integration (#109083) --- homeassistant/components/picnic/services.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index b44d4dd5a62..2aafb20abaf 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -42,9 +42,7 @@ async def async_register_services(hass: HomeAssistant) -> None: schema=vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, - vol.Exclusive( - ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS - ): cv.positive_int, + vol.Exclusive(ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS): cv.string, vol.Exclusive(ATTR_PRODUCT_NAME, ATTR_PRODUCT_IDENTIFIERS): cv.string, vol.Optional(ATTR_AMOUNT): vol.All(vol.Coerce(int), vol.Range(min=1)), } @@ -73,7 +71,7 @@ async def handle_add_product( raise PicnicServiceException("No product found or no product ID given!") await hass.async_add_executor_job( - api_client.add_product, str(product_id), call.data.get("amount", 1) + api_client.add_product, product_id, call.data.get("amount", 1) ) From 41fdcce2269120a19b11db821833ab5de6c9e01c Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 31 Jan 2024 01:50:00 +0100 Subject: [PATCH 1207/1544] Bumb python-homewizard-energy to 4.3.0 (#109131) --- .../components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homewizard/fixtures/HWE-KWH1/data.json | 16 + .../homewizard/fixtures/HWE-KWH1/device.json | 7 + .../homewizard/fixtures/HWE-KWH1/system.json | 3 + .../homewizard/fixtures/HWE-KWH3/data.json | 37 + .../homewizard/fixtures/HWE-KWH3/device.json | 7 + .../homewizard/fixtures/HWE-KWH3/system.json | 3 + .../fixtures/SDM230/SDM630/data.json | 37 + .../fixtures/SDM230/SDM630/device.json | 7 + .../fixtures/SDM230/SDM630/system.json | 3 + .../snapshots/test_diagnostics.ambr | 166 + .../homewizard/snapshots/test_sensor.ambr | 3043 +++++++++++++++++ .../homewizard/snapshots/test_switch.ambr | 150 + tests/components/homewizard/test_button.py | 4 +- .../components/homewizard/test_diagnostics.py | 2 + tests/components/homewizard/test_number.py | 4 +- tests/components/homewizard/test_sensor.py | 170 + tests/components/homewizard/test_switch.py | 16 + 20 files changed, 3676 insertions(+), 5 deletions(-) create mode 100644 tests/components/homewizard/fixtures/HWE-KWH1/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-KWH1/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-KWH1/system.json create mode 100644 tests/components/homewizard/fixtures/HWE-KWH3/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-KWH3/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-KWH3/system.json create mode 100644 tests/components/homewizard/fixtures/SDM230/SDM630/data.json create mode 100644 tests/components/homewizard/fixtures/SDM230/SDM630/device.json create mode 100644 tests/components/homewizard/fixtures/SDM230/SDM630/system.json diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 9a1fc9c1a1d..2db140d5fe9 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==4.2.2"], + "requirements": ["python-homewizard-energy==4.3.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e377ad8434c..d4ecc6f66ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2214,7 +2214,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.2.2 +python-homewizard-energy==4.3.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 955cac70ed0..3b4e0f364f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1693,7 +1693,7 @@ python-fullykiosk==0.0.12 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==4.2.2 +python-homewizard-energy==4.3.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/data.json b/tests/components/homewizard/fixtures/HWE-KWH1/data.json new file mode 100644 index 00000000000..7f970de2cde --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/data.json @@ -0,0 +1,16 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 2.705, + "total_power_export_t1_kwh": 255.551, + "active_power_w": -1058.296, + "active_power_l1_w": -1058.296, + "active_voltage_v": 228.472, + "active_current_a": 0.273, + "active_apparent_current_a": 0.447, + "active_reactive_current_a": 0.354, + "active_apparent_power_va": 74.052, + "active_reactive_power_var": -58.612, + "active_power_factor": 0.611, + "active_frequency_hz": 50 +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/device.json b/tests/components/homewizard/fixtures/HWE-KWH1/device.json new file mode 100644 index 00000000000..67f9ddf42cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH1", + "product_name": "kWh meter", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH1/system.json b/tests/components/homewizard/fixtures/HWE-KWH1/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH1/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/data.json b/tests/components/homewizard/fixtures/HWE-KWH3/data.json new file mode 100644 index 00000000000..fc0d1e929f9 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/data.json @@ -0,0 +1,37 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 0.101, + "total_power_export_t1_kwh": 0.523, + "active_power_w": -900.194, + "active_power_l1_w": -1058.296, + "active_power_l2_w": 158.102, + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/device.json b/tests/components/homewizard/fixtures/HWE-KWH3/device.json new file mode 100644 index 00000000000..e3122c8ff89 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH3", + "product_name": "KWh meter 3-phase", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-KWH3/system.json b/tests/components/homewizard/fixtures/HWE-KWH3/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-KWH3/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/data.json b/tests/components/homewizard/fixtures/SDM230/SDM630/data.json new file mode 100644 index 00000000000..fc0d1e929f9 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/data.json @@ -0,0 +1,37 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 92, + "total_power_import_t1_kwh": 0.101, + "total_power_export_t1_kwh": 0.523, + "active_power_w": -900.194, + "active_power_l1_w": -1058.296, + "active_power_l2_w": 158.102, + "active_power_l3_w": 0.0, + "active_voltage_l1_v": 230.751, + "active_voltage_l2_v": 228.391, + "active_voltage_l3_v": 229.612, + "active_current_a": 30.999, + "active_current_l1_a": 0, + "active_current_l2_a": 15.521, + "active_current_l3_a": 15.477, + "active_apparent_current_a": 31.058, + "active_apparent_current_l1_a": 0, + "active_apparent_current_l2_a": 15.539, + "active_apparent_current_l3_a": 15.519, + "active_reactive_current_a": 1.872, + "active_reactive_current_l1_a": 0, + "active_reactive_current_l2_a": 0.73, + "active_reactive_current_l3_a": 1.143, + "active_apparent_power_va": 7112.293, + "active_apparent_power_l1_va": 0, + "active_apparent_power_l2_va": 3548.879, + "active_apparent_power_l3_va": 3563.414, + "active_reactive_power_var": -429.025, + "active_reactive_power_l1_var": 0, + "active_reactive_power_l2_var": -166.675, + "active_reactive_power_l3_var": -262.35, + "active_power_factor_l1": 1, + "active_power_factor_l2": 0.999, + "active_power_factor_l3": 0.997, + "active_frequency_hz": 49.926 +} diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/device.json b/tests/components/homewizard/fixtures/SDM230/SDM630/device.json new file mode 100644 index 00000000000..b8ec1d18fe8 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "SDM630-wifi", + "product_name": "KWh meter 3-phase", + "serial": "3c39e7aabbcc", + "firmware_version": "3.06", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/SDM230/SDM630/system.json b/tests/components/homewizard/fixtures/SDM230/SDM630/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/SDM230/SDM630/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index eb7716c2037..9e3a468d58f 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,4 +1,170 @@ # serializer version: 1 +# name: test_diagnostics[HWE-KWH1] + dict({ + 'data': dict({ + 'data': dict({ + 'active_apparent_power_l1_va': None, + 'active_apparent_power_l2_va': None, + 'active_apparent_power_l3_va': None, + 'active_apparent_power_va': 74.052, + 'active_current_a': 0.273, + 'active_current_l1_a': None, + 'active_current_l2_a': None, + 'active_current_l3_a': None, + 'active_frequency_hz': 50, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_factor': 0.611, + 'active_power_factor_l1': None, + 'active_power_factor_l2': None, + 'active_power_factor_l3': None, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': None, + 'active_power_l3_w': None, + 'active_power_w': -1058.296, + 'active_reactive_power_l1_var': None, + 'active_reactive_power_l2_var': None, + 'active_reactive_power_l3_var': None, + 'active_reactive_power_var': -58.612, + 'active_tariff': None, + 'active_voltage_l1_v': None, + 'active_voltage_l2_v': None, + 'active_voltage_l3_v': None, + 'active_voltage_v': 228.472, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 255.551, + 'total_energy_export_t1_kwh': 255.551, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 2.705, + 'total_energy_import_t1_kwh': 2.705, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'kWh meter', + 'product_type': 'HWE-KWH1', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics[HWE-KWH3] + dict({ + 'data': dict({ + 'data': dict({ + 'active_apparent_power_l1_va': 0, + 'active_apparent_power_l2_va': 3548.879, + 'active_apparent_power_l3_va': 3563.414, + 'active_apparent_power_va': 7112.293, + 'active_current_a': 30.999, + 'active_current_l1_a': 0, + 'active_current_l2_a': 15.521, + 'active_current_l3_a': 15.477, + 'active_frequency_hz': 49.926, + 'active_liter_lpm': None, + 'active_power_average_w': None, + 'active_power_factor': None, + 'active_power_factor_l1': 1, + 'active_power_factor_l2': 0.999, + 'active_power_factor_l3': 0.997, + 'active_power_l1_w': -1058.296, + 'active_power_l2_w': 158.102, + 'active_power_l3_w': 0.0, + 'active_power_w': -900.194, + 'active_reactive_power_l1_var': 0, + 'active_reactive_power_l2_var': -166.675, + 'active_reactive_power_l3_var': -262.35, + 'active_reactive_power_var': -429.025, + 'active_tariff': None, + 'active_voltage_l1_v': 230.751, + 'active_voltage_l2_v': 228.391, + 'active_voltage_l3_v': 229.612, + 'active_voltage_v': None, + 'any_power_fail_count': None, + 'external_devices': None, + 'gas_timestamp': None, + 'gas_unique_id': None, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'smr_version': None, + 'total_energy_export_kwh': 0.523, + 'total_energy_export_t1_kwh': 0.523, + 'total_energy_export_t2_kwh': None, + 'total_energy_export_t3_kwh': None, + 'total_energy_export_t4_kwh': None, + 'total_energy_import_kwh': 0.101, + 'total_energy_import_t1_kwh': 0.101, + 'total_energy_import_t2_kwh': None, + 'total_energy_import_t3_kwh': None, + 'total_energy_import_t4_kwh': None, + 'total_gas_m3': None, + 'total_liter_m3': None, + 'unique_meter_id': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 92, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '3.06', + 'product_name': 'KWh meter 3-phase', + 'product_type': 'HWE-KWH3', + 'serial': '**REDACTED**', + }), + 'state': None, + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'Product name', + 'product_type': 'product_type', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 863403f3406..f3a78d14d5b 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -216,6 +216,3049 @@ 'unit_of_measurement': None, }) # --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '74.052', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '0.273', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '255.551', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '2.705', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_power_factor:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor', + 'last_changed': , + 'last_updated': , + 'state': '61.1', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-58.612', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_updated': , + 'state': '228.472', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH1-entity_ids6][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_apparent_power_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power', + 'last_changed': , + 'last_updated': , + 'state': '7112.293', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l1_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l2_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '3548.879', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_apparent_power_phase_va', + 'unique_id': 'aabbccddeeff_active_apparent_power_l3_va', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_apparent_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Device Apparent power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_apparent_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '3563.414', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_updated': , + 'state': '30.999', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '15.521', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '15.477', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_updated': , + 'state': '0.523', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_updated': , + 'state': '49.926', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_updated': , + 'state': '-900.194', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '99.9', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_factor_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_factor_phase', + 'unique_id': 'aabbccddeeff_active_power_factor_l3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_factor_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Device Power factor phase 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_power_factor_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '99.7', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '-1058.296', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '158.102', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_reactive_power_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power', + 'last_changed': , + 'last_updated': , + 'state': '-429.025', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l1_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 1', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l2_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 2', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '-166.675', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_reactive_power_phase_var', + 'unique_id': 'aabbccddeeff_active_reactive_power_l3_var', + 'unit_of_measurement': 'var', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_reactive_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'reactive_power', + 'friendly_name': 'Device Reactive power phase 3', + 'state_class': , + 'unit_of_measurement': 'var', + }), + 'context': , + 'entity_id': 'sensor.device_reactive_power_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '-262.35', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_updated': , + 'state': '230.751', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_updated': , + 'state': '228.391', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_updated': , + 'state': '229.612', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-KWH3-entity_ids7][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_updated': , + 'state': '92', + }) +# --- # name: test_sensors[HWE-P1-entity_ids0][sensor.device_active_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 8830ac2e9ed..c8591b1f1d9 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -1,4 +1,154 @@ # serializer version: 1 +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Cloud connection', + }), + 'context': , + 'entity_id': 'switch.device_cloud_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.device_cloud_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud connection', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_connection', + 'unique_id': 'aabbccddeeff_cloud_connection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system_set-cloud_enabled].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'manufacturer': 'HomeWizard', + 'model': 'HWE-KWH3', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '3.06', + 'via_device_id': None, + }) +# --- # name: test_switch_entities[HWE-SKT-switch.device-state_set-power_on] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index c25a4ed0f4e..b73a194c5ae 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -17,7 +17,9 @@ pytestmark = [ ] -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize( + "device_fixture", ["HWE-WTR", "SDM230", "SDM630", "HWE-KWH1", "HWE-KWH3"] +) async def test_identify_button_entity_not_loaded_when_not_available( hass: HomeAssistant, ) -> None: diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 5a140fa70c8..8356c94d164 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -18,6 +18,8 @@ from tests.typing import ClientSessionGenerator "HWE-WTR", "SDM230", "SDM630", + "HWE-KWH1", + "HWE-KWH3", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index a54f98899c6..a7fb2834bd3 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -97,7 +97,9 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize( + "device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630", "HWE-KWH1", "HWE-KWH3"] +) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c1acd49a590..243e8f542e2 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -179,6 +179,54 @@ pytestmark = [ "sensor.device_wi_fi_strength", ], ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_power", + "sensor.device_reactive_power", + "sensor.device_voltage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_sensors( @@ -276,6 +324,43 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor", + "sensor.device_reactive_power", + "sensor.device_voltage", + "sensor.device_wi_fi_strength", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_wi_fi_strength", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -518,6 +603,91 @@ async def test_external_sensors_unreachable( "sensor.device_water_usage", ], ), + ( + "HWE-KWH1", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_average_demand", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + ], + ), + ( + "HWE-KWH3", + [ + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_voltage", + "sensor.device_water_usage", + ], + ), ], ) async def test_entities_not_created_for_device( diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 61ca34fab7a..bfc23264340 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -58,6 +58,20 @@ pytestmark = [ "switch.device_switch_lock", ], ), + ( + "HWE-KWH1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), + ( + "HWE-KWH3", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ], ) async def test_entities_not_created_for_device( @@ -77,6 +91,8 @@ async def test_entities_not_created_for_device( ("HWE-SKT", "switch.device_cloud_connection", "system_set", "cloud_enabled"), ("SDM230", "switch.device_cloud_connection", "system_set", "cloud_enabled"), ("SDM630", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-KWH1", "switch.device_cloud_connection", "system_set", "cloud_enabled"), + ("HWE-KWH3", "switch.device_cloud_connection", "system_set", "cloud_enabled"), ], ) async def test_switch_entities( From 712ba2fdca8604d0ed56e8ae84bc657c50bc6cca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 Jan 2024 02:38:32 +0100 Subject: [PATCH 1208/1544] Add alexa PowerController on enabled features for climate entities (#109174) Depend alexa PowerController on enabled features for climate entities --- homeassistant/components/alexa/entities.py | 8 ++ homeassistant/components/alexa/handlers.py | 4 + tests/components/alexa/test_capabilities.py | 52 +++++++- tests/components/alexa/test_smart_home.py | 133 +++++++++++++++++++- 4 files changed, 195 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 939644b4600..ddc0bc70987 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -478,6 +478,14 @@ class ClimateCapabilities(AlexaEntity): self.entity.domain == climate.DOMAIN and climate.HVACMode.OFF in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + or self.entity.domain == climate.DOMAIN + and ( + supported_features + & ( + climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + ) + ) or self.entity.domain == water_heater.DOMAIN and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 398c6218193..b5b72bc6dc5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -178,6 +178,8 @@ async def async_api_turn_on( service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_ON elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_ON elif domain == humidifier.DOMAIN: @@ -227,6 +229,8 @@ async def async_api_turn_off( service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == climate.DOMAIN: + service = climate.SERVICE_TURN_OFF elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_OFF elif domain == humidifier.DOMAIN: diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index b83bdb794a8..5011fee8838 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -5,7 +5,11 @@ from unittest.mock import patch import pytest from homeassistant.components.alexa import smart_home -from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ClimateEntityFeature, + HVACMode, +) from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.valve import ValveEntityFeature @@ -923,6 +927,52 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_on_off_climate_state(hass: HomeAssistant) -> None: + """Test ThermostatController with on/off features reports state correctly.""" + on_off_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + for auto_modes in (HVACMode.HEAT,): + hass.states.async_set( + "climate.onoff", + auto_modes, + { + "friendly_name": "Climate Downstairs", + "supported_features": on_off_features, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "climate.onoff") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "HEAT") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_modes in [HVACMode.OFF]: + hass.states.async_set( + "climate.onoff", + off_modes, + { + "friendly_name": "Climate Downstairs", + "supported_features": on_off_features, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "climate.onoff") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "OFF") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + async def test_report_water_heater_state(hass: HomeAssistant) -> None: """Test ThermostatController also reports state correctly for water heaters.""" for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index ff8fef43a66..97b8bac4cd1 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature @@ -20,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .test_common import ( MockConfig, @@ -3118,6 +3119,136 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_onoff_thermostat(hass: HomeAssistant) -> None: + """Test onoff thermostat discovery.""" + on_off_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + hass.config.units = METRIC_SYSTEM + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": 20.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 19.0, + "friendly_name": "Test Thermostat", + "supported_features": on_off_features, + "hvac_modes": ["auto"], + "min_temp": 7, + "max_temp": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "COOL") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 20.0, "scale": "CELSIUS"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 19.0, "scale": "CELSIUS"} + ) + + thermostat_capability = get_capability(capabilities, "Alexa.ThermostatController") + assert thermostat_capability is not None + configuration = thermostat_capability["configuration"] + assert configuration["supportsScheduling"] is False + + supported_modes = ["AUTO"] + for mode in supported_modes: + assert mode in configuration["supportedModes"] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpoint": {"value": 21.0, "scale": "CELSIUS"}}, + ) + assert call.data["temperature"] == 21.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 21.0, "scale": "CELSIUS"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOn", + "climate#test_thermostat", + "climate.turn_on", + hass, + ) + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOff", + "climate#test_thermostat", + "climate.turn_off", + hass, + ) + + # Test the power controller is not enabled when there is no `off` mode + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": 20.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 19.0, + "friendly_name": "Test Thermostat", + "supported_features": ClimateEntityFeature.TARGET_TEMPERATURE, + "hvac_modes": ["auto"], + "min_temp": 7, + "max_temp": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + async def test_water_heater(hass: HomeAssistant) -> None: """Test water_heater discovery.""" hass.config.units = US_CUSTOMARY_SYSTEM From 82e1ed43f85b3254c51e7a8200ece159019743ab Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 31 Jan 2024 03:22:22 +0100 Subject: [PATCH 1209/1544] Migrate Tuya integration to new sharing SDK (#109155) * Scan QR code to log in And Migrate Tuya integration to new sharing SDK (#104767) * Remove non-opt-in/out reporting * Improve setup, fix unload * Cleanup token listner, remove logging of sensitive data * Collection of fixes after extensive testing * Tests happy user config flow path * Test unhappy paths * Add reauth * Fix translation key * Prettier manifest * Ruff format * Cleanup of const * Process review comments * Adjust update token handling --------- Co-authored-by: melo <411787243@qq.com> --- homeassistant/components/tuya/__init__.py | 191 +++++----- .../components/tuya/alarm_control_panel.py | 14 +- homeassistant/components/tuya/base.py | 6 +- .../components/tuya/binary_sensor.py | 12 +- homeassistant/components/tuya/button.py | 14 +- homeassistant/components/tuya/camera.py | 12 +- homeassistant/components/tuya/climate.py | 16 +- homeassistant/components/tuya/config_flow.py | 267 +++++++++----- homeassistant/components/tuya/const.py | 278 +------------- homeassistant/components/tuya/cover.py | 14 +- homeassistant/components/tuya/diagnostics.py | 27 +- homeassistant/components/tuya/fan.py | 12 +- homeassistant/components/tuya/humidifier.py | 12 +- homeassistant/components/tuya/light.py | 14 +- homeassistant/components/tuya/manifest.json | 2 +- homeassistant/components/tuya/number.py | 14 +- homeassistant/components/tuya/scene.py | 10 +- homeassistant/components/tuya/select.py | 14 +- homeassistant/components/tuya/sensor.py | 18 +- homeassistant/components/tuya/siren.py | 14 +- homeassistant/components/tuya/strings.json | 22 +- homeassistant/components/tuya/switch.py | 14 +- homeassistant/components/tuya/vacuum.py | 10 +- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/tuya/conftest.py | 69 ++++ .../tuya/snapshots/test_config_flow.ambr | 112 ++++++ tests/components/tuya/test_config_flow.py | 339 +++++++++++++----- 28 files changed, 829 insertions(+), 708 deletions(-) create mode 100644 tests/components/tuya/conftest.py create mode 100644 tests/components/tuya/snapshots/test_config_flow.ambr diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ee084b77ef1..ea38c117af7 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -1,103 +1,75 @@ """Support for Tuya Smart devices.""" from __future__ import annotations -from typing import NamedTuple +import logging +from typing import Any, NamedTuple -import requests -from tuya_iot import ( - AuthType, - TuyaDevice, - TuyaDeviceListener, - TuyaDeviceManager, - TuyaHomeManager, - TuyaOpenAPI, - TuyaOpenMQ, +from tuya_sharing import ( + CustomerDevice, + Manager, + SharingDeviceListener, + SharingTokenListener, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, LOGGER, PLATFORMS, + TUYA_CLIENT_ID, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, ) +# Suppress logs from the library, it logs unneeded on error +logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL) + class HomeAssistantTuyaData(NamedTuple): """Tuya data stored in the Home Assistant data object.""" - device_listener: TuyaDeviceListener - device_manager: TuyaDeviceManager - home_manager: TuyaHomeManager + manager: Manager + listener: SharingDeviceListener async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" - hass.data.setdefault(DOMAIN, {}) + if CONF_APP_TYPE in entry.data: + raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) - api = TuyaOpenAPI( - endpoint=entry.data[CONF_ENDPOINT], - access_id=entry.data[CONF_ACCESS_ID], - access_secret=entry.data[CONF_ACCESS_SECRET], - auth_type=auth_type, + token_listener = TokenListener(hass, entry) + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + token_listener, ) - api.set_dev_channel("hass") - - try: - if auth_type == AuthType.CUSTOM: - response = await hass.async_add_executor_job( - api.connect, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - else: - response = await hass.async_add_executor_job( - api.connect, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTRY_CODE], - entry.data[CONF_APP_TYPE], - ) - except requests.exceptions.RequestException as err: - raise ConfigEntryNotReady(err) from err - - if response.get("success", False) is False: - raise ConfigEntryNotReady(response) - - tuya_mq = TuyaOpenMQ(api) - tuya_mq.start() - - device_ids: set[str] = set() - device_manager = TuyaDeviceManager(api, tuya_mq) - home_manager = TuyaHomeManager(api, tuya_mq, device_manager) - listener = DeviceListener(hass, device_manager, device_ids) - device_manager.add_device_listener(listener) - - hass.data[DOMAIN][entry.entry_id] = HomeAssistantTuyaData( - device_listener=listener, - device_manager=device_manager, - home_manager=home_manager, + listener = DeviceListener(hass, manager) + manager.add_device_listener(listener) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( + manager=manager, listener=listener ) # Get devices & clean up device entities - await hass.async_add_executor_job(home_manager.update_device_cache) - await cleanup_device_registry(hass, device_manager) + await hass.async_add_executor_job(manager.update_device_cache) + await cleanup_device_registry(hass, manager) # Register known device IDs device_registry = dr.async_get(hass) - for device in device_manager.device_map.values(): + for device in manager.device_map.values(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, @@ -105,15 +77,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=device.name, model=f"{device.product_name} (unsupported)", ) - device_ids.add(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # If the device does not register any entities, the device does not need to subscribe + # So the subscription is here + await hass.async_add_executor_job(manager.refresh_mq) return True -async def cleanup_device_registry( - hass: HomeAssistant, device_manager: TuyaDeviceManager -) -> None: +async def cleanup_device_registry(hass: HomeAssistant, device_manager: Manager) -> None: """Remove deleted device registry entry if there are no remaining entities.""" device_registry = dr.async_get(hass) for dev_id, device_entry in list(device_registry.devices.items()): @@ -125,59 +97,44 @@ async def cleanup_device_registry( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" - unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload: - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - hass_data.device_manager.mq.stop() - hass_data.device_manager.remove_device_listener(hass_data.device_listener) - - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + tuya: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + if tuya.manager.mq is not None: + tuya.manager.mq.stop() + tuya.manager.remove_device_listener(tuya.listener) + await hass.async_add_executor_job(tuya.manager.unload) + del hass.data[DOMAIN][entry.entry_id] + return unload_ok -class DeviceListener(TuyaDeviceListener): +class DeviceListener(SharingDeviceListener): """Device Update Listener.""" def __init__( self, hass: HomeAssistant, - device_manager: TuyaDeviceManager, - device_ids: set[str], + manager: Manager, ) -> None: """Init DeviceListener.""" self.hass = hass - self.device_manager = device_manager - self.device_ids = device_ids + self.manager = manager - def update_device(self, device: TuyaDevice) -> None: + def update_device(self, device: CustomerDevice) -> None: """Update device status.""" - if device.id in self.device_ids: - LOGGER.debug( - "Received update for device %s: %s", - device.id, - self.device_manager.device_map[device.id].status, - ) - dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") + LOGGER.debug( + "Received update for device %s: %s", + device.id, + self.manager.device_map[device.id].status, + ) + dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") - def add_device(self, device: TuyaDevice) -> None: + def add_device(self, device: CustomerDevice) -> None: """Add device added listener.""" # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) - self.device_ids.add(device.id) dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) - device_manager = self.device_manager - device_manager.mq.stop() - tuya_mq = TuyaOpenMQ(device_manager.api) - tuya_mq.start() - - device_manager.mq = tuya_mq - tuya_mq.add_message_listener(device_manager.on_message) - def remove_device(self, device_id: str) -> None: """Add device removed listener.""" self.hass.add_job(self.async_remove_device, device_id) @@ -192,4 +149,36 @@ class DeviceListener(TuyaDeviceListener): ) if device_entry is not None: device_registry.async_remove_device(device_entry.id) - self.device_ids.discard(device_id) + + +class TokenListener(SharingTokenListener): + """Token listener for upstream token updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Init TokenListener.""" + self.hass = hass + self.entry = entry + + def update_token(self, token_info: dict[str, Any]) -> None: + """Update token info in config entry.""" + data = { + **self.entry.data, + CONF_TOKEN_INFO: { + "t": token_info["t"], + "uid": token_info["uid"], + "expire_time": token_info["expire_time"], + "access_token": token_info["access_token"], + "refresh_token": token_info["refresh_token"], + }, + } + + @callback + def async_update_entry() -> None: + """Update config entry.""" + self.hass.config_entries.async_update_entry(self.entry, data=data) + + self.hass.add_job(async_update_entry) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index cd92e62b864..681f025f57b 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -68,18 +68,16 @@ async def async_setup_entry( """Discover and add a discovered Tuya siren.""" entities: list[TuyaAlarmEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := ALARM.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaAlarmEntity( - device, hass_data.device_manager, description - ) + TuyaAlarmEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -94,8 +92,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: AlarmControlPanelEntityDescription, ) -> None: """Init Tuya Alarm.""" diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 3aae417aac7..7c4e213fe65 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -7,7 +7,7 @@ import json import struct from typing import Any, Literal, Self, overload -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -135,9 +135,11 @@ class TuyaEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init TuyaHaEntity.""" self._attr_unique_id = f"tuya.{device.id}" + # TuyaEntity initialize mq can subscribe + device.set_up = True self.device = device self.device_manager = device_manager diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 8e934ae6593..5664801d76e 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -354,20 +354,20 @@ async def async_setup_entry( """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: entities.append( TuyaBinarySensorEntity( - device, hass_data.device_manager, description + device, hass_data.manager, description ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -381,8 +381,8 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaBinarySensorEntityDescription, ) -> None: """Init Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 4c73b70c29a..5b936b305fb 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,7 +1,7 @@ """Support for Tuya buttons.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -74,19 +74,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya buttons.""" entities: list[TuyaButtonEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := BUTTONS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaButtonEntity( - device, hass_data.device_manager, description - ) + TuyaButtonEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -98,8 +96,8 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: ButtonEntityDescription, ) -> None: """Init Tuya button.""" diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 72216057aff..07c4adb8889 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,7 +1,7 @@ """Support for Tuya cameras.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature @@ -34,13 +34,13 @@ async def async_setup_entry( """Discover and add a discovered Tuya camera.""" entities: list[TuyaCameraEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category in CAMERAS: - entities.append(TuyaCameraEntity(device, hass_data.device_manager)) + entities.append(TuyaCameraEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -56,8 +56,8 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Camera.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 74399d70991..9f20df98370 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.climate import ( SWING_BOTH, @@ -98,18 +98,19 @@ async def async_setup_entry( """Discover and add a discovered Tuya climate.""" entities: list[TuyaClimateEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, - hass_data.device_manager, + hass_data.manager, CLIMATE_DESCRIPTIONS[device.category], + hass.config.units.temperature_unit, ) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -129,9 +130,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaClimateEntityDescription, + system_temperature_unit: UnitOfTemperature, ) -> None: """Determine which values to use.""" self._attr_target_temperature_step = 1.0 @@ -157,7 +159,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): prefered_temperature_unit = UnitOfTemperature.FAHRENHEIT # Default to System Temperature Unit - self._attr_temperature_unit = self.hass.config.units.temperature_unit + self._attr_temperature_unit = system_temperature_unit # Figure out current temperature, use preferred unit or what is available celsius_type = self.find_dpcode( diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index f933ac84519..3577a6d6b06 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -1,115 +1,65 @@ """Config flow for Tuya.""" from __future__ import annotations +from collections.abc import Mapping +from io import BytesIO from typing import Any -from tuya_iot import AuthType, TuyaOpenAPI +import segno +from tuya_sharing import LoginControl import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.data_entry_flow import FlowResult from .const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, DOMAIN, - LOGGER, - SMARTLIFE_APP, - TUYA_COUNTRIES, + TUYA_CLIENT_ID, TUYA_RESPONSE_CODE, TUYA_RESPONSE_MSG, - TUYA_RESPONSE_PLATFORM_URL, + TUYA_RESPONSE_QR_CODE, TUYA_RESPONSE_RESULT, TUYA_RESPONSE_SUCCESS, - TUYA_SMART_APP, + TUYA_SCHEMA, ) -class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Tuya Config Flow.""" +class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): + """Tuya config flow.""" - @staticmethod - def _try_login(user_input: dict[str, Any]) -> tuple[dict[Any, Any], dict[str, Any]]: - """Try login.""" - response = {} + __user_code: str + __qr_code: str + __qr_image: str + __reauth_entry: ConfigEntry | None = None - country = [ - country - for country in TUYA_COUNTRIES - if country.name == user_input[CONF_COUNTRY_CODE] - ][0] + def __init__(self) -> None: + """Initialize the config flow.""" + self.__login_control = LoginControl() - data = { - CONF_ENDPOINT: country.endpoint, - CONF_AUTH_TYPE: AuthType.CUSTOM, - CONF_ACCESS_ID: user_input[CONF_ACCESS_ID], - CONF_ACCESS_SECRET: user_input[CONF_ACCESS_SECRET], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_COUNTRY_CODE: country.country_code, - } - - for app_type in ("", TUYA_SMART_APP, SMARTLIFE_APP): - data[CONF_APP_TYPE] = app_type - if data[CONF_APP_TYPE] == "": - data[CONF_AUTH_TYPE] = AuthType.CUSTOM - else: - data[CONF_AUTH_TYPE] = AuthType.SMART_HOME - - api = TuyaOpenAPI( - endpoint=data[CONF_ENDPOINT], - access_id=data[CONF_ACCESS_ID], - access_secret=data[CONF_ACCESS_SECRET], - auth_type=data[CONF_AUTH_TYPE], - ) - api.set_dev_channel("hass") - - response = api.connect( - username=data[CONF_USERNAME], - password=data[CONF_PASSWORD], - country_code=data[CONF_COUNTRY_CODE], - schema=data[CONF_APP_TYPE], - ) - - LOGGER.debug("Response %s", response) - - if response.get(TUYA_RESPONSE_SUCCESS, False): - break - - return response, data - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Step user.""" errors = {} placeholders = {} if user_input is not None: - response, data = await self.hass.async_add_executor_job( - self._try_login, user_input + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] ) + if success: + return await self.async_step_scan() - if response.get(TUYA_RESPONSE_SUCCESS, False): - if endpoint := response.get(TUYA_RESPONSE_RESULT, {}).get( - TUYA_RESPONSE_PLATFORM_URL - ): - data[CONF_ENDPOINT] = endpoint - - data[CONF_AUTH_TYPE] = data[CONF_AUTH_TYPE].value - - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=data, - ) errors["base"] = "login_error" placeholders = { - TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE), - TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG), + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), } - - if user_input is None: + else: user_input = {} return self.async_show_form( @@ -117,27 +67,146 @@ class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required( - CONF_COUNTRY_CODE, - default=user_input.get(CONF_COUNTRY_CODE, "United States"), - ): vol.In( - # We don't pass a dict {code:name} because country codes can be duplicate. - [country.name for country in TUYA_COUNTRIES] - ), - vol.Required( - CONF_ACCESS_ID, default=user_input.get(CONF_ACCESS_ID, "") - ): str, - vol.Required( - CONF_ACCESS_SECRET, - default=user_input.get(CONF_ACCESS_SECRET, ""), - ): str, - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") ): str, } ), errors=errors, description_placeholders=placeholders, ) + + async def async_step_scan( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step scan.""" + if user_input is None: + return self.async_show_form( + step_id="scan", + description_placeholders={ + TUYA_RESPONSE_QR_CODE: self.__qr_image, + }, + ) + + ret, info = await self.hass.async_add_executor_job( + self.__login_control.login_result, + self.__qr_code, + TUYA_CLIENT_ID, + self.__user_code, + ) + if not ret: + return self.async_show_form( + step_id="scan", + errors={"base": "login_error"}, + description_placeholders={ + TUYA_RESPONSE_QR_CODE: self.__qr_image, + TUYA_RESPONSE_MSG: info.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: info.get(TUYA_RESPONSE_CODE, 0), + }, + ) + + entry_data = { + CONF_USER_CODE: self.__user_code, + CONF_TOKEN_INFO: { + "t": info["t"], + "uid": info["uid"], + "expire_time": info["expire_time"], + "access_token": info["access_token"], + "refresh_token": info["refresh_token"], + }, + CONF_TERMINAL_ID: info[CONF_TERMINAL_ID], + CONF_ENDPOINT: info[CONF_ENDPOINT], + } + + if self.__reauth_entry: + return self.async_update_reload_and_abort( + self.__reauth_entry, + data=entry_data, + ) + + return self.async_create_entry( + title=info.get("username"), + data=entry_data, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with Tuya.""" + self.__reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + if self.__reauth_entry and CONF_USER_CODE in self.__reauth_entry.data: + success, _ = await self.__async_get_qr_code( + self.__reauth_entry.data[CONF_USER_CODE] + ) + if success: + return await self.async_step_scan() + + return await self.async_step_reauth_user_code() + + async def async_step_reauth_user_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with a Tuya.""" + errors = {} + placeholders = {} + + if user_input is not None: + success, response = await self.__async_get_qr_code( + user_input[CONF_USER_CODE] + ) + if success: + return await self.async_step_scan() + + errors["base"] = "login_error" + placeholders = { + TUYA_RESPONSE_MSG: response.get(TUYA_RESPONSE_MSG, "Unknown error"), + TUYA_RESPONSE_CODE: response.get(TUYA_RESPONSE_CODE, "0"), + } + else: + user_input = {} + + return self.async_show_form( + step_id="reauth_user_code", + data_schema=vol.Schema( + { + vol.Required( + CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "") + ): str, + } + ), + errors=errors, + description_placeholders=placeholders, + ) + + async def __async_get_qr_code(self, user_code: str) -> tuple[bool, dict[str, Any]]: + """Get the QR code.""" + response = await self.hass.async_add_executor_job( + self.__login_control.qr_code, + TUYA_CLIENT_ID, + TUYA_SCHEMA, + user_code, + ) + if success := response.get(TUYA_RESPONSE_SUCCESS, False): + self.__user_code = user_code + self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE] + self.__qr_image = _generate_qr_code(self.__qr_code) + return success, response + + +def _generate_qr_code(data: str) -> str: + """Create an SVG QR code that can be scanned with the Smart Life app.""" + qr_code = segno.make(f"tuyaSmart--qrLogin?token={data}", error="h") + with BytesIO() as buffer: + qr_code.save( + buffer, + kind="svg", + border=5, + scale=5, + xmldecl=False, + svgns=False, + svgclass=None, + lineclass=None, + svgversion=2, + dark="#1abcf2", + ) + return str(buffer.getvalue().decode("ascii")) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 4cdca8f3904..8f15114aa80 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -6,8 +6,6 @@ from dataclasses import dataclass, field from enum import StrEnum import logging -from tuya_iot import TuyaCloudOpenAPIEndpoint - from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -31,24 +29,24 @@ from homeassistant.const import ( DOMAIN = "tuya" LOGGER = logging.getLogger(__package__) -CONF_AUTH_TYPE = "auth_type" -CONF_PROJECT_TYPE = "tuya_project_type" -CONF_ENDPOINT = "endpoint" -CONF_ACCESS_ID = "access_id" -CONF_ACCESS_SECRET = "access_secret" CONF_APP_TYPE = "tuya_app_type" +CONF_ENDPOINT = "endpoint" +CONF_TERMINAL_ID = "terminal_id" +CONF_TOKEN_INFO = "token_info" +CONF_USER_CODE = "user_code" +CONF_USERNAME = "username" + +TUYA_CLIENT_ID = "HA_3y9q4ak7g4ephrvke" +TUYA_SCHEMA = "haauthorize" TUYA_DISCOVERY_NEW = "tuya_discovery_new" TUYA_HA_SIGNAL_UPDATE_ENTITY = "tuya_entry_update" TUYA_RESPONSE_CODE = "code" -TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_MSG = "msg" +TUYA_RESPONSE_QR_CODE = "qrcode" +TUYA_RESPONSE_RESULT = "result" TUYA_RESPONSE_SUCCESS = "success" -TUYA_RESPONSE_PLATFORM_URL = "platform_url" - -TUYA_SMART_APP = "tuyaSmart" -SMARTLIFE_APP = "smartlife" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -570,259 +568,3 @@ for uom in UNITS: DEVICE_CLASS_UNITS.setdefault(device_class, {})[uom.unit] = uom for unit_alias in uom.aliases: DEVICE_CLASS_UNITS[device_class][unit_alias] = uom - - -@dataclass -class Country: - """Describe a supported country.""" - - name: str - country_code: str - endpoint: str = TuyaCloudOpenAPIEndpoint.AMERICA - - -# https://developer.tuya.com/en/docs/iot/oem-app-data-center-distributed?id=Kafi0ku9l07qb -TUYA_COUNTRIES = [ - Country("Afghanistan", "93", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Albania", "355", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Algeria", "213", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("American Samoa", "1-684", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Andorra", "376", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Angola", "244", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Anguilla", "1-264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Antarctica", "672", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Antigua and Barbuda", "1-268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Argentina", "54", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Armenia", "374", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Aruba", "297", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Australia", "61", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Austria", "43", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Azerbaijan", "994", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahamas", "1-242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bahrain", "973", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bangladesh", "880", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Barbados", "1-246", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belarus", "375", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belgium", "32", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Belize", "501", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Benin", "229", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bermuda", "1-441", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bhutan", "975", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bolivia", "591", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Bosnia and Herzegovina", "387", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Botswana", "267", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brazil", "55", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Indian Ocean Territory", "246", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("British Virgin Islands", "1-284", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Brunei", "673", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Bulgaria", "359", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burkina Faso", "226", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Burundi", "257", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cambodia", "855", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cameroon", "237", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Canada", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Capo Verde", "238", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cayman Islands", "1-345", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Central African Republic", "236", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chad", "235", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Chile", "56", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("China", "86", TuyaCloudOpenAPIEndpoint.CHINA), - Country("Christmas Island", "61"), - Country("Cocos Islands", "61"), - Country("Colombia", "57", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Comoros", "269", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cook Islands", "682", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Costa Rica", "506", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Croatia", "385", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Cuba", "53"), - Country("Curacao", "599", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Cyprus", "357", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Czech Republic", "420", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Democratic Republic of the Congo", "243", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Denmark", "45", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Djibouti", "253", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominica", "1-767", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Dominican Republic", "1-809", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("East Timor", "670", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Ecuador", "593", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Egypt", "20", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("El Salvador", "503", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Equatorial Guinea", "240", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Eritrea", "291", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Estonia", "372", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ethiopia", "251", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Falkland Islands", "500", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Faroe Islands", "298", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Fiji", "679", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Finland", "358", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("France", "33", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("French Polynesia", "689", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gabon", "241", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gambia", "220", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Georgia", "995", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Germany", "49", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ghana", "233", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Gibraltar", "350", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greece", "30", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Greenland", "299", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Grenada", "1-473", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guam", "1-671", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Guatemala", "502", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guernsey", "44-1481"), - Country("Guinea", "224"), - Country("Guinea-Bissau", "245", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Guyana", "592", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Haiti", "509", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Honduras", "504", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Hong Kong", "852", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Hungary", "36", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Iceland", "354", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("India", "91", TuyaCloudOpenAPIEndpoint.INDIA), - Country("Indonesia", "62", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Iran", "98"), - Country("Iraq", "964", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ireland", "353", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Isle of Man", "44-1624"), - Country("Israel", "972", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Italy", "39", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ivory Coast", "225", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Jamaica", "1-876", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Japan", "81", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Jersey", "44-1534"), - Country("Jordan", "962", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kazakhstan", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kenya", "254", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kiribati", "686", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Kosovo", "383"), - Country("Kuwait", "965", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Kyrgyzstan", "996", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Laos", "856", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Latvia", "371", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lebanon", "961", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lesotho", "266", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liberia", "231", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Libya", "218", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Liechtenstein", "423", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Lithuania", "370", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Luxembourg", "352", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Macao", "853", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Macedonia", "389", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Madagascar", "261", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malawi", "265", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malaysia", "60", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Maldives", "960", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mali", "223", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Malta", "356", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Marshall Islands", "692", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritania", "222", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mauritius", "230", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mayotte", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mexico", "52", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Micronesia", "691", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Moldova", "373", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Monaco", "377", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mongolia", "976", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montenegro", "382", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Montserrat", "1-664", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Morocco", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Mozambique", "258", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Myanmar", "95", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Namibia", "264", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nauru", "674", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nepal", "977", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands", "31", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Netherlands Antilles", "599"), - Country("New Caledonia", "687", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("New Zealand", "64", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Nicaragua", "505", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niger", "227", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Nigeria", "234", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Niue", "683", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("North Korea", "850"), - Country("Northern Mariana Islands", "1-670", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Norway", "47", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Oman", "968", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Pakistan", "92", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palau", "680", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Palestine", "970", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Panama", "507", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Papua New Guinea", "675", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Paraguay", "595", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Peru", "51", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Philippines", "63", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Pitcairn", "64"), - Country("Poland", "48", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Portugal", "351", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Puerto Rico", "1-787, 1-939", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Qatar", "974", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Republic of the Congo", "242", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Reunion", "262", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Romania", "40", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Russia", "7", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Rwanda", "250", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Barthelemy", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Helena", "290"), - Country("Saint Kitts and Nevis", "1-869", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Lucia", "1-758", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Martin", "590", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Saint Pierre and Miquelon", "508", TuyaCloudOpenAPIEndpoint.EUROPE), - Country( - "Saint Vincent and the Grenadines", "1-784", TuyaCloudOpenAPIEndpoint.EUROPE - ), - Country("Samoa", "685", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("San Marino", "378", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sao Tome and Principe", "239", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Saudi Arabia", "966", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Senegal", "221", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Serbia", "381", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Seychelles", "248", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sierra Leone", "232", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Singapore", "65", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sint Maarten", "1-721", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Slovakia", "421", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Slovenia", "386", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Solomon Islands", "677", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Somalia", "252", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Africa", "27", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("South Korea", "82", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("South Sudan", "211"), - Country("Spain", "34", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sri Lanka", "94", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sudan", "249"), - Country("Suriname", "597", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Svalbard and Jan Mayen", "4779", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Swaziland", "268", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Sweden", "46", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Switzerland", "41", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Syria", "963"), - Country("Taiwan", "886", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tajikistan", "992", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tanzania", "255", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Thailand", "66", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Togo", "228", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tokelau", "690", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Tonga", "676", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Trinidad and Tobago", "1-868", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tunisia", "216", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkey", "90", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turkmenistan", "993", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Turks and Caicos Islands", "1-649", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Tuvalu", "688", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("U.S. Virgin Islands", "1-340", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Uganda", "256", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Ukraine", "380", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Arab Emirates", "971", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United Kingdom", "44", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("United States", "1", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uruguay", "598", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Uzbekistan", "998", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Vanuatu", "678", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vatican", "379", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Venezuela", "58", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Vietnam", "84", TuyaCloudOpenAPIEndpoint.AMERICA), - Country("Wallis and Futuna", "681", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Western Sahara", "212", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Yemen", "967", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zambia", "260", TuyaCloudOpenAPIEndpoint.EUROPE), - Country("Zimbabwe", "263", TuyaCloudOpenAPIEndpoint.EUROPE), -] diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 46bd0721ccb..912087d2c8c 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.cover import ( ATTR_POSITION, @@ -152,7 +152,7 @@ async def async_setup_entry( """Discover and add a discovered tuya cover.""" entities: list[TuyaCoverEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := COVERS.get(device.category): for description in descriptions: if ( @@ -160,14 +160,12 @@ async def async_setup_entry( or description.key in device.status_range ): entities.append( - TuyaCoverEntity( - device, hass_data.device_manager, description - ) + TuyaCoverEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -184,8 +182,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaCoverEntityDescription, ) -> None: """Init Tuya Cover.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index adac97174b9..cdd0d5ed51c 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -5,18 +5,17 @@ from contextlib import suppress import json from typing import Any, cast -from tuya_iot import TuyaDevice +from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.util import dt as dt_util from . import HomeAssistantTuyaData -from .const import CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, DOMAIN, DPCode +from .const import DOMAIN, DPCode async def async_get_config_entry_diagnostics( @@ -43,14 +42,12 @@ def _async_get_diagnostics( hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] mqtt_connected = None - if hass_data.home_manager.mq.client: - mqtt_connected = hass_data.home_manager.mq.client.is_connected() + if hass_data.manager.mq.client: + mqtt_connected = hass_data.manager.mq.client.is_connected() data = { - "endpoint": entry.data[CONF_ENDPOINT], - "auth_type": entry.data[CONF_AUTH_TYPE], - "country_code": entry.data[CONF_COUNTRY_CODE], - "app_type": entry.data[CONF_APP_TYPE], + "endpoint": hass_data.manager.customer_api.endpoint, + "terminal_id": hass_data.manager.terminal_id, "mqtt_connected": mqtt_connected, "disabled_by": entry.disabled_by, "disabled_polling": entry.pref_disable_polling, @@ -59,13 +56,13 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] data |= _async_device_as_dict( - hass, hass_data.device_manager.device_map[tuya_device_id] + hass, hass_data.manager.device_map[tuya_device_id] ) else: data.update( devices=[ _async_device_as_dict(hass, device) - for device in hass_data.device_manager.device_map.values() + for device in hass_data.manager.device_map.values() ] ) @@ -73,13 +70,15 @@ def _async_get_diagnostics( @callback -def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, Any]: +def _async_device_as_dict( + hass: HomeAssistant, device: CustomerDevice +) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" # Base device information, without sensitive information. data = { + "id": device.id, "name": device.name, - "model": device.model if hasattr(device, "model") else None, "category": device.category, "product_id": device.product_id, "product_name": device.product_name, @@ -93,6 +92,8 @@ def _async_device_as_dict(hass: HomeAssistant, device: TuyaDevice) -> dict[str, "status_range": {}, "status": {}, "home_assistant": {}, + "set_up": device.set_up, + "support_local": device.support_local, } # Gather Tuya states diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 210cc5c7518..0971462e450 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -44,12 +44,12 @@ async def async_setup_entry( """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device and device.category in TUYA_SUPPORT_TYPE: - entities.append(TuyaFanEntity(device, hass_data.device_manager)) + entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -69,8 +69,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index a8008ced953..7cc4fee03fc 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.humidifier import ( HumidifierDeviceClass, @@ -65,14 +65,14 @@ async def async_setup_entry( """Discover and add a discovered Tuya (de)humidifier.""" entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if description := HUMIDIFIERS.get(device.category): entities.append( - TuyaHumidifierEntity(device, hass_data.device_manager, description) + TuyaHumidifierEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -90,8 +90,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaHumidifierEntityDescription, ) -> None: """Init Tuya (de)humidifier.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 50927d35d32..98d704326ae 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field import json from typing import Any, cast -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -413,19 +413,17 @@ async def async_setup_entry( """Discover and add a discovered tuya light.""" entities: list[TuyaLightEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaLightEntity( - device, hass_data.device_manager, description - ) + TuyaLightEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -447,8 +445,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaLightEntityDescription, ) -> None: """Init TuyaHaLight.""" diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index a6d0a28d36a..71e43c8d445 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-iot-py-sdk==0.6.6"] + "requirements": ["tuya-device-sharing-sdk==0.1.9", "segno==1.5.3"] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 5e7bdcc260a..8fc55d2c230 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -1,7 +1,7 @@ """Support for Tuya number.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( NumberDeviceClass, @@ -323,19 +323,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya number.""" entities: list[TuyaNumberEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := NUMBERS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaNumberEntity( - device, hass_data.device_manager, description - ) + TuyaNumberEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -349,8 +347,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: NumberEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 289e319df1b..8db3ef60658 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaHomeManager, TuyaScene +from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry @@ -20,10 +20,8 @@ async def async_setup_entry( ) -> None: """Set up Tuya scenes.""" hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] - scenes = await hass.async_add_executor_job(hass_data.home_manager.query_scenes) - async_add_entities( - TuyaSceneEntity(hass_data.home_manager, scene) for scene in scenes - ) + scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) + async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) class TuyaSceneEntity(Scene): @@ -33,7 +31,7 @@ class TuyaSceneEntity(Scene): _attr_has_entity_name = True _attr_name = None - def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: + def __init__(self, home_manager: Manager, scene: SharingScene) -> None: """Init Tuya Scene.""" super().__init__() self._attr_unique_id = f"tys{scene.scene_id}" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index bc44ddf479c..5d712767697 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -1,7 +1,7 @@ """Support for Tuya select.""" from __future__ import annotations -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -356,19 +356,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya select.""" entities: list[TuyaSelectEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SELECTS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSelectEntity( - device, hass_data.device_manager, description - ) + TuyaSelectEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -380,8 +378,8 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SelectEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 62b59cb8ed9..80c76a0c253 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from tuya_iot import TuyaDevice, TuyaDeviceManager -from tuya_iot.device import TuyaDeviceStatusRange +from tuya_sharing import CustomerDevice, Manager +from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( SensorDeviceClass, @@ -1112,19 +1112,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya sensor.""" entities: list[TuyaSensorEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SENSORS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSensorEntity( - device, hass_data.device_manager, description - ) + TuyaSensorEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -1136,15 +1134,15 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): entity_description: TuyaSensorEntityDescription - _status_range: TuyaDeviceStatusRange | None = None + _status_range: DeviceStatusRange | None = None _type: DPType | None = None _type_data: IntegerTypeData | EnumTypeData | None = None _uom: UnitOfMeasurement | None = None def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: TuyaSensorEntityDescription, ) -> None: """Init Tuya sensor.""" diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index c2dc8cea99b..baba339318d 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.siren import ( SirenEntity, @@ -57,19 +57,17 @@ async def async_setup_entry( """Discover and add a discovered Tuya siren.""" entities: list[TuyaSirenEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SIRENS.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSirenEntity( - device, hass_data.device_manager, description - ) + TuyaSirenEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -84,8 +82,8 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SirenEntityDescription, ) -> None: """Init Tuya Siren.""" diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ad9da548d6c..6e4848d9cc0 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1,20 +1,26 @@ { "config": { "step": { - "user": { - "description": "Enter your Tuya credentials", + "reauth_user_code": { + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", "data": { - "country_code": "Country", - "access_id": "Tuya IoT Access ID", - "access_secret": "Tuya IoT Access Secret", - "username": "Account", - "password": "[%key:common::config_flow::data::password%]" + "user_code": "User code" } + }, + "user": { + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "data": { + "user_code": "User code" + } + }, + "scan": { + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login:\n\n {qrcode} \n\nContinue to the next step once you have completed this step in the app." } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "login_error": "Login error ({code}): {msg}" + "login_error": "Login error ({code}): {msg}", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ba304b4069e..a89dbbd7132 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.switch import ( SwitchDeviceClass, @@ -730,19 +730,17 @@ async def async_setup_entry( """Discover and add a discovered tuya sensor.""" entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if descriptions := SWITCHES.get(device.category): for description in descriptions: if description.key in device.status: entities.append( - TuyaSwitchEntity( - device, hass_data.device_manager, description - ) + TuyaSwitchEntity(device, hass_data.manager, description) ) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -754,8 +752,8 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity): def __init__( self, - device: TuyaDevice, - device_manager: TuyaDeviceManager, + device: CustomerDevice, + device_manager: Manager, description: SwitchEntityDescription, ) -> None: """Init TuyaHaSwitch.""" diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index d067d3786ea..9ebfe899518 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from tuya_iot import TuyaDevice, TuyaDeviceManager +from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( STATE_CLEANING, @@ -61,12 +61,12 @@ async def async_setup_entry( """Discover and add a discovered Tuya vacuum.""" entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: - device = hass_data.device_manager.device_map[device_id] + device = hass_data.manager.device_map[device_id] if device.category == "sd": - entities.append(TuyaVacuumEntity(device, hass_data.device_manager)) + entities.append(TuyaVacuumEntity(device, hass_data.manager)) async_add_entities(entities) - async_discover_device([*hass_data.device_manager.device_map]) + async_discover_device([*hass_data.manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) @@ -80,7 +80,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): _battery_level: IntegerTypeData | None = None _attr_name = None - def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: + def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: """Init Tuya vacuum.""" super().__init__(device, device_manager) diff --git a/requirements_all.txt b/requirements_all.txt index d4ecc6f66ff..c31be96d2d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2487,6 +2487,9 @@ scsgate==0.1.0 # homeassistant.components.backup securetar==2023.3.0 +# homeassistant.components.tuya +segno==1.5.3 + # homeassistant.components.sendgrid sendgrid==6.8.2 @@ -2723,7 +2726,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b4e0f364f1..ef7bab8f77b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1891,6 +1891,9 @@ screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 +# homeassistant.components.tuya +segno==1.5.3 + # homeassistant.components.emulated_kasa # homeassistant.components.sense sense-energy==0.12.2 @@ -2067,7 +2070,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.6 +tuya-device-sharing-sdk==0.1.9 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py new file mode 100644 index 00000000000..6decb7c5f10 --- /dev/null +++ b/tests/components/tuya/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for the Tuya integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock an old config entry that can be migrated.""" + return MockConfigEntry( + title="Old Tuya configuration entry", + domain=DOMAIN, + data={CONF_APP_TYPE: "tuyaSmart"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock an config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_USER_CODE: "12345"}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_tuya_login_control() -> Generator[MagicMock, None, None]: + """Return a mocked Tuya login control.""" + with patch( + "homeassistant.components.tuya.config_flow.LoginControl", autospec=True + ) as login_control_mock: + login_control = login_control_mock.return_value + login_control.qr_code.return_value = { + "success": True, + "result": { + "qrcode": "mocked_qr_code", + }, + } + login_control.login_result.return_value = ( + True, + { + "t": "mocked_t", + "uid": "mocked_uid", + "username": "mocked_username", + "expire_time": "mocked_expire_time", + "access_token": "mocked_access_token", + "refresh_token": "mocked_refresh_token", + "terminal_id": "mocked_terminal_id", + "endpoint": "mocked_endpoint", + }, + ) + yield login_control diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..416a656c238 --- /dev/null +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -0,0 +1,112 @@ +# serializer version: 1 +# name: test_reauth_flow + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_reauth_flow_migration + ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Old Tuya configuration entry', + 'unique_id': '12345', + 'version': 1, + }) +# --- +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tuya', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'endpoint': 'mocked_endpoint', + 'terminal_id': 'mocked_terminal_id', + 'token_info': dict({ + 'access_token': 'mocked_access_token', + 'expire_time': 'mocked_expire_time', + 'refresh_token': 'mocked_refresh_token', + 't': 'mocked_t', + 'uid': 'mocked_uid', + }), + 'user_code': '12345', + }), + 'disabled_by': None, + 'domain': 'tuya', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'mocked_username', + 'unique_id': None, + 'version': 1, + }), + 'title': 'mocked_username', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index f8345683d4a..66a5d1d226d 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -1,127 +1,270 @@ """Tests for the Tuya config flow.""" from __future__ import annotations -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from tuya_iot import TuyaCloudOpenAPIEndpoint +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.tuya.const import ( - CONF_ACCESS_ID, - CONF_ACCESS_SECRET, - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_ENDPOINT, - DOMAIN, - SMARTLIFE_APP, - TUYA_COUNTRIES, - TUYA_SMART_APP, -) -from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -MOCK_SMART_HOME_PROJECT_TYPE = 0 -MOCK_INDUSTRY_PROJECT_TYPE = 1 +from tests.common import ANY, MockConfigEntry -MOCK_COUNTRY = "India" -MOCK_ACCESS_ID = "myAccessId" -MOCK_ACCESS_SECRET = "myAccessSecret" -MOCK_USERNAME = "myUsername" -MOCK_PASSWORD = "myPassword" -MOCK_ENDPOINT = TuyaCloudOpenAPIEndpoint.INDIA - -TUYA_INPUT_DATA = { - CONF_COUNTRY_CODE: MOCK_COUNTRY, - CONF_ACCESS_ID: MOCK_ACCESS_ID, - CONF_ACCESS_SECRET: MOCK_ACCESS_SECRET, - CONF_USERNAME: MOCK_USERNAME, - CONF_PASSWORD: MOCK_PASSWORD, -} - -RESPONSE_SUCCESS = { - "success": True, - "code": 1024, - "result": {"platform_url": MOCK_ENDPOINT}, -} -RESPONSE_ERROR = {"success": False, "code": 123, "msg": "Error"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -@pytest.fixture(name="tuya") -def tuya_fixture() -> MagicMock: - """Patch libraries.""" - with patch("homeassistant.components.tuya.config_flow.TuyaOpenAPI") as tuya: - yield tuya - - -@pytest.fixture(name="tuya_setup", autouse=True) -def tuya_setup_fixture() -> None: - """Mock tuya entry setup.""" - with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): - yield - - -@pytest.mark.parametrize( - ("app_type", "side_effects", "project_type"), - [ - ("", [RESPONSE_SUCCESS], 1), - (TUYA_SMART_APP, [RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - (SMARTLIFE_APP, [RESPONSE_ERROR, RESPONSE_ERROR, RESPONSE_SUCCESS], 0), - ], -) +@pytest.mark.usefixtures("mock_tuya_login_control") async def test_user_flow( hass: HomeAssistant, - tuya: MagicMock, - app_type: str, - side_effects: list[dict[str, Any]], - project_type: int, -): - """Test user flow.""" + snapshot: SnapshotAssertion, +) -> None: + """Test the full happy path user flow from start to finish.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": SOURCE_USER}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" - tuya().connect = MagicMock(side_effect=side_effects) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, ) - await hass.async_block_till_done() - country = [country for country in TUYA_COUNTRIES if country.name == MOCK_COUNTRY][0] + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + assert result2.get("description_placeholders") == {"qrcode": ANY} - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_USERNAME - assert result["data"][CONF_ACCESS_ID] == MOCK_ACCESS_ID - assert result["data"][CONF_ACCESS_SECRET] == MOCK_ACCESS_SECRET - assert result["data"][CONF_USERNAME] == MOCK_USERNAME - assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD - assert result["data"][CONF_ENDPOINT] == country.endpoint - assert result["data"][CONF_APP_TYPE] == app_type - assert result["data"][CONF_AUTH_TYPE] == project_type - assert result["data"][CONF_COUNTRY_CODE] == country.country_code - assert not result["result"].unique_id + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.CREATE_ENTRY + assert result3 == snapshot -async def test_error_on_invalid_credentials(hass: HomeAssistant, tuya) -> None: - """Test when we have invalid credentials.""" +async def test_user_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while retrieving the QR code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_failed_scan( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, +) -> None: + """Test an error occurring while verifying login.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + + # Access has been denied, or the code hasn't been scanned yet + good_values = mock_tuya_login_control.login_result.return_value + mock_tuya_login_control.login_result.return_value = ( + False, + {"msg": "oops", "code": 42}, + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.FORM + assert result3.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.login_result.return_value = good_values + + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result4.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "scan" + assert result.get("description_placeholders") == {"qrcode": ANY} - tuya().connect = MagicMock(return_value=RESPONSE_ERROR) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TUYA_INPUT_DATA + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, ) - await hass.async_block_till_done() - assert result["errors"]["base"] == "login_error" - assert result["description_placeholders"]["code"] == RESPONSE_ERROR["code"] - assert result["description_placeholders"]["msg"] == RESPONSE_ERROR["msg"] + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry == snapshot + + +@pytest.mark.usefixtures("mock_tuya_login_control") +async def test_reauth_flow_migration( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the reauthentication configuration flow. + + This flow tests the migration from an old config entry. + """ + mock_old_config_entry.add_to_hass(hass) + + # Ensure old data is there, new data is missing + assert CONF_APP_TYPE in mock_old_config_entry.data + assert CONF_USER_CODE not in mock_old_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_user_code" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "scan" + assert result2.get("description_placeholders") == {"qrcode": ANY} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" + + # Ensure the old data is gone, new data is present + assert CONF_APP_TYPE not in mock_old_config_entry.data + assert CONF_USER_CODE in mock_old_config_entry.data + + assert mock_old_config_entry == snapshot + + +async def test_reauth_flow_failed_qr_code( + hass: HomeAssistant, + mock_tuya_login_control: MagicMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test an error occurring while retrieving the QR code.""" + mock_old_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_old_config_entry.unique_id, + "entry_id": mock_old_config_entry.entry_id, + }, + data=mock_old_config_entry.data, + ) + + # Something went wrong getting the QR code (like an invalid user code) + mock_tuya_login_control.qr_code.return_value["success"] = False + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "login_error"} + + # This time it worked out + mock_tuya_login_control.qr_code.return_value["success"] = True + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USER_CODE: "12345"}, + ) + assert result3.get("step_id") == "scan" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" From a7a41e54f67282c4f0534ebf88a1ee3614bd649a Mon Sep 17 00:00:00 2001 From: Caius-Bonus <123886836+Caius-Bonus@users.noreply.github.com> Date: Wed, 31 Jan 2024 03:26:19 +0100 Subject: [PATCH 1210/1544] Add ZHA ZCL thermostat entities (#106563) --- .../zha/core/cluster_handlers/hvac.py | 3 + homeassistant/components/zha/number.py | 68 +++++++++++++++++++ homeassistant/components/zha/select.py | 25 +++++++ homeassistant/components/zha/sensor.py | 58 ++++++++++++++++ homeassistant/components/zha/strings.json | 15 ++++ tests/components/zha/test_climate.py | 4 +- tests/components/zha/test_sensor.py | 36 +++++++++- tests/components/zha/zha_devices_list.py | 30 ++++++++ 8 files changed, 237 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 8c9ee07c6f1..4c03d31135e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -146,6 +146,7 @@ class ThermostatClusterHandler(ClusterHandler): Thermostat.AttributeDefs.min_cool_setpoint_limit.name: True, Thermostat.AttributeDefs.min_heat_setpoint_limit.name: True, Thermostat.AttributeDefs.local_temperature_calibration.name: True, + Thermostat.AttributeDefs.setpoint_change_source.name: True, } @property @@ -341,3 +342,5 @@ class ThermostatClusterHandler(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) class UserInterfaceClusterHandler(ClusterHandler): """User interface (thermostat) cluster handler.""" + + ZCL_INIT_ATTRS = {UserInterface.AttributeDefs.keypad_lockout.name: True} diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c3c4c0b604a..2b6a64edf69 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,6 +5,8 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.zcl.clusters.hvac import Thermostat + from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature @@ -985,3 +987,69 @@ class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): _attr_mode: NumberMode = NumberMode.BOX _attr_icon: str = "mdi:timer-edit" + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLTemperatureEntity(ZHANumberConfigurationEntity): + """Common entity class for ZCL temperature input.""" + + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_step: float = 0.01 + _attr_multiplier: float = 0.01 + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLHeatSetpointLimitEntity(ZCLTemperatureEntity): + """Min or max heat setpoint setting on thermostats.""" + + _attr_icon: str = "mdi:thermostat" + _attr_native_step: float = 0.5 + + _min_source = Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name + _max_source = Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + # The spec says 0x954D, which is a signed integer, therefore the value is in decimals + min_present_value = self._cluster_handler.cluster.get(self._min_source, -27315) + return min_present_value * self._attr_multiplier + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + max_present_value = self._cluster_handler.cluster.get(self._max_source, 0x7FFF) + return max_present_value * self._attr_multiplier + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Max heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "max_heat_setpoint_limit" + _attribute_name: str = "max_heat_setpoint_limit" + _attr_translation_key: str = "max_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _min_source = Thermostat.AttributeDefs.min_heat_setpoint_limit.name + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Min heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "min_heat_setpoint_limit" + _attribute_name: str = "min_heat_setpoint_limit" + _attr_translation_key: str = "min_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _max_source = Thermostat.AttributeDefs.max_heat_setpoint_limit.name diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 5c32ca44dee..58f2a608e47 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -673,3 +673,28 @@ class SonoffPresenceDetectionSensitivity(ZCLEnumSelectEntity): _attribute_name = "ultrasonic_u_to_o_threshold" _enum = SonoffPresenceDetectionSensitivityEnum _attr_translation_key: str = "detection_sensitivity" + + +class KeypadLockoutEnum(types.enum8): + """Keypad lockout options.""" + + Unlock = 0x00 + Lock1 = 0x01 + Lock2 = 0x02 + Lock3 = 0x03 + Lock4 = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="thermostat_ui") +class KeypadLockout(ZCLEnumSelectEntity): + """Mandatory attribute for thermostat_ui cluster. + + Often only the first two are implemented, and Lock2 to Lock4 should map to Lock1 in the firmware. + This however covers all bases. + """ + + _unique_id_suffix = "keypad_lockout" + _attribute_name: str = "keypad_lockout" + _enum = KeypadLockoutEnum + _attr_translation_key: str = "keypad_lockout" + _attr_icon: str = "mdi:lock" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 4986742c63d..d3c8fc0b29d 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -95,6 +95,9 @@ CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( ) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.SENSOR +) async def async_setup_entry( @@ -238,6 +241,19 @@ class PollableSensor(Sensor): ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class EnumSensor(Sensor): + """Sensor with value from enum.""" + + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM + _enum: type[enum.Enum] + + def formatter(self, value: int) -> str | None: + """Use name of enum.""" + assert self._enum is not None + return self._enum(value).name + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", @@ -1254,3 +1270,45 @@ class SonoffPresenceSenorIlluminationStatus(Sensor): def formatter(self, value: int) -> int | float | None: """Numeric pass-through formatter.""" return SonoffIlluminationStates(value).name + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PiHeatingDemand(Sensor): + """Sensor that displays the percentage of heating power demanded. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "pi_heating_demand" + _attribute_name = "pi_heating_demand" + _attr_translation_key: str = "pi_heating_demand" + _attr_icon: str = "mdi:radiator" + _attr_native_unit_of_measurement = PERCENTAGE + _decimals = 0 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +class SetpointChangeSourceEnum(types.enum8): + """The source of the setpoint change.""" + + Manual = 0x00 + Schedule = 0x01 + External = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SetpointChangeSource(EnumSensor): + """Sensor that displays the source of the setpoint change. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "setpoint_change_source" + _attribute_name = "setpoint_change_source" + _attr_translation_key: str = "setpoint_change_source" + _attr_icon: str = "mdi:thermostat" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _enum = SetpointChangeSourceEnum diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 08c485f01b3..0c9ff765710 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -730,6 +730,12 @@ }, "presence_detection_timeout": { "name": "Presence detection timeout" + }, + "max_heat_setpoint_limit": { + "name": "Max heat setpoint limit" + }, + "min_heat_setpoint_limit": { + "name": "Min heat setpoint limit" } }, "select": { @@ -798,6 +804,9 @@ }, "detection_sensitivity": { "name": "Detection Sensitivity" + }, + "keypad_lockout": { + "name": "Keypad lockout" } }, "sensor": { @@ -881,6 +890,12 @@ }, "last_illumination_state": { "name": "Last illumination state" + }, + "pi_heating_demand": { + "name": "Pi heating demand" + }, + "setpoint_change_source": { + "name": "Setpoint change source" } }, "switch": { diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index b693c034199..d60b4bd1a49 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -400,7 +400,9 @@ async def test_climate_hvac_action_running_state_zen( thrm_cluster = device_climate_zen.device.endpoints[1].thermostat entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen, hass) - sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_zen, hass) + sensor_entity_id = find_entity_id( + Platform.SENSOR, device_climate_zen, hass, "hvac_action" + ) state = hass.states.get(entity_id) assert ATTR_HVAC_ACTION not in state.attributes diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index e25430a293b..005e9b86e3a 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha -from zigpy.zcl.clusters import general, homeautomation, measurement, smartenergy +from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy +from zigpy.zcl.clusters.hvac import Thermostat from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zha.core.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ @@ -342,6 +343,23 @@ async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id) assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS) +async def async_test_setpoint_change_source(hass, cluster, entity_id): + """Test the translation of numerical state into enum text.""" + await send_attributes_report( + hass, cluster, {Thermostat.AttributeDefs.setpoint_change_source.id: 0x01} + ) + hass_state = hass.states.get(entity_id) + assert hass_state.state == "Schedule" + + +async def async_test_pi_heating_demand(hass, cluster, entity_id): + """Test pi heating demand is correctly returned.""" + await send_attributes_report( + hass, cluster, {Thermostat.AttributeDefs.pi_heating_demand.id: 1} + ) + assert_state(hass, entity_id, "1", "%") + + @pytest.mark.parametrize( ( "cluster_id", @@ -502,6 +520,22 @@ async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id) None, None, ), + ( + hvac.Thermostat.cluster_id, + "setpoint_change_source", + async_test_setpoint_change_source, + 10, + None, + None, + ), + ( + hvac.Thermostat.cluster_id, + "pi_heating_demand", + async_test_pi_heating_demand, + 10, + None, + None, + ), ), ) async def test_sensor( diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 8078b9e13bd..a45ffce9e47 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -4434,6 +4434,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_setpoint_change_source", + }, }, }, { @@ -4524,6 +4534,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_setpoint_change_source", + }, }, }, { @@ -4817,6 +4837,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_setpoint_change_source", + }, }, }, { From 320bf53f75bd5cac94c53efdc2ecff04b05f8925 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 Jan 2024 03:27:36 +0100 Subject: [PATCH 1211/1544] Add OnOff trait for climate entities in google_assistant (#109160) --- homeassistant/components/google_assistant/trait.py | 5 +++++ tests/components/google_assistant/__init__.py | 7 ++++++- .../google_assistant/test_google_assistant.py | 6 ++++++ tests/components/google_assistant/test_trait.py | 12 +++++++++--- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 189d1354e26..bb03e796d91 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -484,6 +484,11 @@ class OnOffTrait(_Trait): if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: return True + if domain == climate.DOMAIN and features & ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ): + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 6fc1c9f580d..931f4d25522 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -305,6 +305,7 @@ DEMO_DEVICES = [ "id": "climate.hvac", "name": {"name": "Hvac"}, "traits": [ + "action.devices.traits.OnOff", "action.devices.traits.TemperatureSetting", "action.devices.traits.FanSpeed", ], @@ -326,7 +327,10 @@ DEMO_DEVICES = [ { "id": "climate.heatpump", "name": {"name": "HeatPump"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.TemperatureSetting", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, }, @@ -334,6 +338,7 @@ DEMO_DEVICES = [ "id": "climate.ecobee", "name": {"name": "Ecobee"}, "traits": [ + "action.devices.traits.OnOff", "action.devices.traits.TemperatureSetting", "action.devices.traits.FanSpeed", ], diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 6d3a9b34cce..4fb6f50a5e6 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -233,12 +233,14 @@ async def test_query_climate_request( assert len(devices) == 3 assert devices["climate.heatpump"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": 20.0, "thermostatTemperatureAmbient": 25.0, "thermostatMode": "heat", } assert devices["climate.ecobee"] == { "online": True, + "on": True, "thermostatTemperatureSetpointHigh": 24, "thermostatTemperatureAmbient": 23, "thermostatMode": "heatcool", @@ -247,6 +249,7 @@ async def test_query_climate_request( } assert devices["climate.hvac"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": 21, "thermostatTemperatureAmbient": 22, "thermostatMode": "cool", @@ -294,12 +297,14 @@ async def test_query_climate_request_f( assert len(devices) == 3 assert devices["climate.heatpump"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": -6.7, "thermostatTemperatureAmbient": -3.9, "thermostatMode": "heat", } assert devices["climate.ecobee"] == { "online": True, + "on": True, "thermostatTemperatureSetpointHigh": -4.4, "thermostatTemperatureAmbient": -5, "thermostatMode": "heatcool", @@ -308,6 +313,7 @@ async def test_query_climate_request_f( } assert devices["climate.hvac"] == { "online": True, + "on": True, "thermostatTemperatureSetpoint": -6.1, "thermostatTemperatureAmbient": -5.6, "thermostatMode": "cool", diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 3f1e28cb667..58cbc5dce0e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1080,7 +1080,9 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: "climate.bla", climate.HVACMode.AUTO, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [ climate.HVACMode.OFF, climate.HVACMode.COOL, @@ -1161,7 +1163,9 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: { climate.ATTR_CURRENT_TEMPERATURE: 70, climate.ATTR_CURRENT_HUMIDITY: 25, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [ STATE_OFF, climate.HVACMode.COOL, @@ -1273,7 +1277,9 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None "climate.bla", climate.HVACMode.COOL, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF, climate.ATTR_HVAC_MODES: [STATE_OFF, climate.HVACMode.COOL], climate.ATTR_MIN_TEMP: 10, climate.ATTR_MAX_TEMP: 30, From 2f9f0bae468216f3c88a0f74fcbebdc4ef38b578 Mon Sep 17 00:00:00 2001 From: Josh Pettersen <12600312+bubonicbob@users.noreply.github.com> Date: Tue, 30 Jan 2024 18:28:27 -0800 Subject: [PATCH 1212/1544] Add generic typing for powerwall sensors (#109008) --- homeassistant/components/powerwall/sensor.py | 28 ++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index d797f56df02..398e972d723 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic, TypeVar from tesla_powerwall import MeterResponse, MeterType @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, POWERWALL_COORDINATOR @@ -32,17 +33,22 @@ from .models import PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" +_ValueParamT = TypeVar("_ValueParamT") +_ValueT = TypeVar("_ValueT", bound=float) + @dataclass(frozen=True) -class PowerwallRequiredKeysMixin: +class PowerwallRequiredKeysMixin(Generic[_ValueParamT, _ValueT]): """Mixin for required keys.""" - value_fn: Callable[[MeterResponse], float] + value_fn: Callable[[_ValueParamT], _ValueT] @dataclass(frozen=True) class PowerwallSensorEntityDescription( - SensorEntityDescription, PowerwallRequiredKeysMixin + SensorEntityDescription, + PowerwallRequiredKeysMixin[_ValueParamT, _ValueT], + Generic[_ValueParamT, _ValueT], ): """Describes Powerwall entity.""" @@ -68,7 +74,7 @@ def _get_meter_average_voltage(meter: MeterResponse) -> float: POWERWALL_INSTANT_SENSORS = ( - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_power", translation_key="instant_power", state_class=SensorStateClass.MEASUREMENT, @@ -76,7 +82,7 @@ POWERWALL_INSTANT_SENSORS = ( native_unit_of_measurement=UnitOfPower.KILO_WATT, value_fn=_get_meter_power, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_frequency", translation_key="instant_frequency", state_class=SensorStateClass.MEASUREMENT, @@ -85,7 +91,7 @@ POWERWALL_INSTANT_SENSORS = ( entity_registry_enabled_default=False, value_fn=_get_meter_frequency, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_current", translation_key="instant_current", state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +100,7 @@ POWERWALL_INSTANT_SENSORS = ( entity_registry_enabled_default=False, value_fn=_get_meter_total_current, ), - PowerwallSensorEntityDescription( + PowerwallSensorEntityDescription[MeterResponse, float]( key="instant_voltage", translation_key="instant_voltage", state_class=SensorStateClass.MEASUREMENT, @@ -116,7 +122,7 @@ async def async_setup_entry( coordinator = powerwall_data[POWERWALL_COORDINATOR] assert coordinator is not None data = coordinator.data - entities: list[PowerWallEntity] = [ + entities: list[Entity] = [ PowerWallChargeSensor(powerwall_data), ] @@ -156,13 +162,13 @@ class PowerWallChargeSensor(PowerWallEntity, SensorEntity): class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" - entity_description: PowerwallSensorEntityDescription + entity_description: PowerwallSensorEntityDescription[MeterResponse, float] def __init__( self, powerwall_data: PowerwallRuntimeData, meter: MeterType, - description: PowerwallSensorEntityDescription, + description: PowerwallSensorEntityDescription[MeterResponse, float], ) -> None: """Initialize the sensor.""" self.entity_description = description From 3115af10416cb87bb9f53b2c758b93edbf4f8bb8 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 30 Jan 2024 22:34:48 -0500 Subject: [PATCH 1213/1544] Add update platform to ZHA (bumps zigpy to 0.61.0) (#107612) * stub out zha update entity * update matcher * updates based on assumptions / conversation * hook into current installed version * post rebase cleanup * incorporate zigpy changes * fix async_setup_entry * fix sw_version * make ota work with config diagnostic match * fix version format * sync up with latest Zigpy changes * fix name attribute * disable ota providers for tests * update device list * review comment * add current_file_version to Ota ZCL_INIT_ATTRS * updates to update and start tests * get installed version from restore data * better version handling * remove done todo notes * reorganize test * move image notify to cluster handler * add test for manual update check * firmware update success test * coverage * use zigpy defs * clean up after rebase * bump Zigpy * cleanup from review comments * fix busted F string * fix empty error * move inside check * guard zigbee network from bulk check for updates --- .../zha/core/cluster_handlers/general.py | 49 +- homeassistant/components/zha/core/const.py | 2 + homeassistant/components/zha/core/device.py | 10 + .../components/zha/core/discovery.py | 11 +- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/update.py | 235 +++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/common.py | 1 - tests/components/zha/conftest.py | 8 + tests/components/zha/test_cluster_handlers.py | 16 +- tests/components/zha/test_update.py | 587 ++++++++++++++++++ tests/components/zha/zha_devices_list.py | 385 ++++++++++++ 13 files changed, 1291 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/zha/update.py create mode 100644 tests/components/zha/test_update.py diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 3cb450cc270..045e6a8f593 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -56,6 +56,7 @@ from ..const import ( SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, + UNKNOWN as ZHA_UNKNOWN, ) from . import ( AttrReportConfig, @@ -523,12 +524,47 @@ class OnOffConfigurationClusterHandler(ClusterHandler): @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) -class OtaClientClusterHandler(ClientClusterHandler): +class OtaClusterHandler(ClusterHandler): """OTA cluster handler.""" BIND: bool = False + # Some devices have this cluster in the wrong collection (e.g. Third Reality) + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> str: + """Return cached value of current_file_version attribute.""" + current_file_version = self.cluster.get( + Ota.AttributeDefs.current_file_version.name + ) + if current_file_version is not None: + return f"0x{int(current_file_version):08x}" + return ZHA_UNKNOWN + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClientClusterHandler(ClientClusterHandler): + """OTA client cluster handler.""" + + BIND: bool = False + + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> str: + """Return cached value of current_file_version attribute.""" + current_file_version = self.cluster.get( + Ota.AttributeDefs.current_file_version.name + ) + if current_file_version is not None: + return f"0x{int(current_file_version):08x}" + return ZHA_UNKNOWN + @callback def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None @@ -540,10 +576,17 @@ class OtaClientClusterHandler(ClientClusterHandler): cmd_name = command_id signal_id = self._endpoint.unique_id.split("-")[0] - if cmd_name == "query_next_image": + if cmd_name == Ota.ServerCommandDefs.query_next_image.name: assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) + async def async_check_for_update(self): + """Check for firmware availability by issuing an image notify command.""" + await self.cluster.image_notify( + payload_type=(self.cluster.ImageNotifyCommand.PayloadType.QueryJitter), + query_jitter=100, + ) + @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) class PartitionClusterHandler(ClusterHandler): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecbd347a621..cb0aa466046 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -89,6 +89,7 @@ CLUSTER_HANDLER_LEVEL = ATTR_LEVEL CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" CLUSTER_HANDLER_OCCUPANCY = "occupancy" CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_OTA = "ota" CLUSTER_HANDLER_POWER_CONFIGURATION = "power" CLUSTER_HANDLER_PRESSURE = "pressure" CLUSTER_HANDLER_SHADE = "shade" @@ -120,6 +121,7 @@ PLATFORMS = ( Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.UPDATE, ) CONF_ALARM_MASTER_CODE = "alarm_master_code" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index a678dbea89a..dd5a39115ae 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -26,6 +26,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -406,6 +407,15 @@ class ZHADevice(LogMixin): ATTR_MODEL: self.model, } + @property + def sw_version(self) -> str | None: + """Return the software version for this device.""" + device_registry = dr.async_get(self.hass) + reg_device: DeviceEntry | None = device_registry.async_get(self.device_id) + if reg_device is None: + return None + return reg_device.sw_version + @classmethod def new( cls, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 1944f632e9a..1fed2caab60 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -6,6 +6,8 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING, cast +from zigpy.zcl.clusters.general import Ota + from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -32,6 +34,7 @@ from .. import ( # noqa: F401 sensor, siren, switch, + update, ) from . import const as zha_const, registries as zha_regs @@ -233,10 +236,16 @@ class ProbeEndpoint: cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if config_diagnostic_entities: + cluster_handlers = list(endpoint.all_cluster_handlers.values()) + ota_handler_id = f"{endpoint.id}:0x{Ota.cluster_id:04x}" + if ota_handler_id in endpoint.client_cluster_handlers: + cluster_handlers.append( + endpoint.client_cluster_handlers[ota_handler_id] + ) matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( endpoint.device.manufacturer, endpoint.device.model, - list(endpoint.all_cluster_handlers.values()), + cluster_handlers, endpoint.device.quirk_id, ) else: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index ead1087b8c8..9e09e20819f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.110", "zigpy-deconz==0.22.4", - "zigpy==0.60.7", + "zigpy==0.61.0", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py new file mode 100644 index 00000000000..93912fc68db --- /dev/null +++ b/homeassistant/components/zha/update.py @@ -0,0 +1,235 @@ +"""Representation of ZHA updates.""" +from __future__ import annotations + +from dataclasses import dataclass +import functools +from typing import TYPE_CHECKING, Any + +from zigpy.ota.image import BaseOTAImage +from zigpy.types import uint16_t +from zigpy.zcl.foundation import Status + +from homeassistant.components.update import ( + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData + +from .core import discovery +from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, UNKNOWN +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE +) + +# don't let homeassistant check for updates button hammer the zigbee network +PARALLEL_UPDATES = 1 + + +@dataclass +class ZHAFirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for ZHA firmware update entity.""" + + image_type: uint16_t | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return {"image_type": self.image_type} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation update from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.UPDATE] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA) +class ZHAFirmwareUpdateEntity(ZhaEntity, UpdateEntity): + """Representation of a ZHA firmware update entity.""" + + _unique_id_suffix = "firmware_update" + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.SPECIFIC_VERSION + ) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Initialize the ZHA update entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._ota_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_OTA + ] + self._attr_installed_version: str = self.determine_installed_version() + self._image_type: uint16_t | None = None + self._latest_version_firmware: BaseOTAImage | None = None + self._result = None + + @callback + def determine_installed_version(self) -> str: + """Determine the currently installed firmware version.""" + currently_installed_version = self._ota_cluster_handler.current_file_version + version_from_dr = self.zha_device.sw_version + if currently_installed_version == UNKNOWN and version_from_dr: + currently_installed_version = version_from_dr + return currently_installed_version + + @property + def extra_restore_state_data(self) -> ZHAFirmwareUpdateExtraStoredData: + """Return ZHA firmware update specific state data to be restored.""" + return ZHAFirmwareUpdateExtraStoredData(self._image_type) + + @callback + def device_ota_update_available(self, image: BaseOTAImage) -> None: + """Handle ota update available signal from Zigpy.""" + self._latest_version_firmware = image + self._attr_latest_version = f"0x{image.header.file_version:08x}" + self._image_type = image.header.image_type + self.async_write_ha_state() + + @callback + def _update_progress(self, current: int, total: int, progress: float) -> None: + """Update install progress on event.""" + assert self._latest_version_firmware + self._attr_in_progress = int(progress) + self.async_write_ha_state() + + @callback + def _reset_progress(self, write_state: bool = True) -> None: + """Reset update install progress.""" + self._result = None + self._attr_in_progress = False + if write_state: + self.async_write_ha_state() + + async def async_update(self) -> None: + """Handle the update entity service call to manually check for available firmware updates.""" + await super().async_update() + # check for updates in the HA settings menu can invoke this so we need to check if the device + # is mains powered so we don't get a ton of errors in the logs from sleepy devices. + if self.zha_device.available and self.zha_device.is_mains_powered: + await self._ota_cluster_handler.async_check_for_update() + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + firmware = self._latest_version_firmware + assert firmware + self._reset_progress(False) + self._attr_in_progress = True + self.async_write_ha_state() + + try: + self._result = await self.zha_device.device.update_firmware( + self._latest_version_firmware, + self._update_progress, + ) + except Exception as ex: + self._reset_progress() + raise HomeAssistantError(ex) from ex + + assert self._result is not None + + # If the update was not successful, we should throw an error to let the user know + if self._result != Status.SUCCESS: + # save result since reset_progress will clear it + results = self._result + self._reset_progress() + raise HomeAssistantError(f"Update was not successful - result: {results}") + + # If we get here, all files were installed successfully + self._attr_installed_version = ( + self._attr_latest_version + ) = f"0x{firmware.header.file_version:08x}" + self._latest_version_firmware = None + self._reset_progress() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + # If we have a complete previous state, use that to set the installed version + if ( + last_state + and self._attr_installed_version == UNKNOWN + and (installed_version := last_state.attributes.get(ATTR_INSTALLED_VERSION)) + ): + self._attr_installed_version = installed_version + # If we have a complete previous state, use that to set the latest version + if ( + last_state + and (latest_version := last_state.attributes.get(ATTR_LATEST_VERSION)) + is not None + and latest_version != UNKNOWN + ): + self._attr_latest_version = latest_version + # If we have no state or latest version to restore, or the latest version is + # the same as the installed version, we can set the latest + # version to installed so that the entity starts as off. + elif ( + not last_state + or not latest_version + or latest_version == self._attr_installed_version + ): + self._attr_latest_version = self._attr_installed_version + + if self._attr_latest_version != self._attr_installed_version and ( + extra_data := await self.async_get_last_extra_data() + ): + self._image_type = extra_data.as_dict()["image_type"] + if self._image_type: + self._latest_version_firmware = ( + await self.zha_device.device.application.ota.get_ota_image( + self.zha_device.manufacturer_code, self._image_type + ) + ) + # if we can't locate an image but we have a latest version that differs + # we should set the latest version to the installed version to avoid + # confusion and errors + if not self._latest_version_firmware: + self._attr_latest_version = self._attr_installed_version + + self.zha_device.device.add_listener(self) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed.""" + await super().async_will_remove_from_hass() + self._reset_progress(False) diff --git a/requirements_all.txt b/requirements_all.txt index c31be96d2d3..89c4ef5d6c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2930,7 +2930,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.7 +zigpy==0.61.0 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef7bab8f77b..c4e1d822f5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2241,7 +2241,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.7 +zigpy==0.61.0 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 44155d741b7..d679ac5cb03 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -54,7 +54,6 @@ def patch_cluster(cluster): cluster.configure_reporting_multiple = AsyncMock( return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0] ) - cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) cluster.read_attributes_raw = AsyncMock(side_effect=_read_attribute_raw) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 4303c156e4b..1627ced5cbb 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -154,6 +154,14 @@ async def zigpy_app_controller(): zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, zigpy.config.CONF_NWK_BACKUP_ENABLED: False, zigpy.config.CONF_TOPO_SCAN_ENABLED: False, + zigpy.config.CONF_OTA: { + zigpy.config.CONF_OTA_IKEA: False, + zigpy.config.CONF_OTA_INOVELLI: False, + zigpy.config.CONF_OTA_LEDVANCE: False, + zigpy.config.CONF_OTA_SALUS: False, + zigpy.config.CONF_OTA_SONOFF: False, + zigpy.config.CONF_OTA_THIRDREALITY: False, + }, } ) diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index b248244e243..7c17d79fe0e 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -990,15 +990,9 @@ async def test_cluster_handler_naming() -> None: assert issubclass(client_cluster_handler, cluster_handlers.ClientClusterHandler) assert client_cluster_handler.__name__.endswith("ClientClusterHandler") - server_cluster_handlers = [] for cluster_handler_dict in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.values(): - # remove this filter in the update platform PR - server_cluster_handlers += [ - cluster_handler - for cluster_handler in cluster_handler_dict.values() - if cluster_handler.__name__ != "OtaClientClusterHandler" - ] - - for cluster_handler in server_cluster_handlers: - assert not issubclass(cluster_handler, cluster_handlers.ClientClusterHandler) - assert cluster_handler.__name__.endswith("ClusterHandler") + for cluster_handler in cluster_handler_dict.values(): + assert not issubclass( + cluster_handler, cluster_handlers.ClientClusterHandler + ) + assert cluster_handler.__name__.endswith("ClusterHandler") diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py new file mode 100644 index 00000000000..c1424ca1730 --- /dev/null +++ b/tests/components/zha/test_update.py @@ -0,0 +1,587 @@ +"""Test ZHA firmware updates.""" +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest +from zigpy.exceptions import DeliveryError +from zigpy.ota import CachedImage +import zigpy.ota.image as firmware +import zigpy.profiles.zha as zha +import zigpy.types as t +import zigpy.zcl.clusters.general as general +import zigpy.zcl.foundation as foundation + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.components.update.const import ATTR_SKIPPED_VERSION +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .common import async_enable_traffic, find_entity_id, update_attribute_cache +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + +from tests.common import mock_restore_cache_with_extra_data + + +@pytest.fixture(autouse=True) +def update_platform_only(): + """Only set up the update and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.UPDATE, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + ), + ): + yield + + +@pytest.fixture +def zigpy_device(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00" + ) + + +async def setup_test_data( + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=False, + file_not_found=False, +): + """Set up test data for the tests.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + cluster = zigpy_device.endpoints[1].out_clusters[general.Ota.cluster_id] + if not skip_attribute_plugs: + cluster.PLUGGED_ATTR_READS = { + general.Ota.AttributeDefs.current_file_version.name: installed_fw_version + } + update_attribute_cache(cluster) + + # set up firmware image + fw_image = firmware.OTAImage() + fw_image.subelements = [firmware.SubElement(tag_id=0x0000, data=b"fw_image")] + fw_header = firmware.OTAImageHeader( + file_version=fw_version, + image_type=0x90, + manufacturer_id=zigpy_device.manufacturer_id, + upgrade_file_id=firmware.OTAImageHeader.MAGIC_VALUE, + header_version=256, + header_length=56, + field_control=0, + stack_version=2, + header_string="This is a test header!", + image_size=56 + 2 + 4 + 8, + ) + fw_image.header = fw_header + fw_image.should_update = MagicMock(return_value=True) + cached_image = CachedImage(fw_image) + + cluster.endpoint.device.application.ota.get_ota_image = AsyncMock( + return_value=None if file_not_found else cached_image + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + zha_device.async_update_sw_build_id(installed_fw_version) + + return zha_device, cluster, fw_image, installed_fw_version + + +async def test_firmware_update_notification_from_zigpy( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update notification.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +async def test_firmware_update_notification_from_service_call( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update manual check.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + async def _async_image_notify_side_effect(*args, **kwargs): + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await async_setup_component(hass, HA_DOMAIN, {}) + cluster.image_notify = AsyncMock(side_effect=_async_image_notify_side_effect) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert cluster.image_notify.await_count == 1 + assert cluster.image_notify.call_args_list[0] == call( + payload_type=cluster.ImageNotifyCommand.PayloadType.QueryJitter, + query_jitter=100, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +def make_packet(zigpy_device, cluster, cmd_name: str, **kwargs): + """Make a zigpy packet.""" + req_hdr, req_cmd = cluster._create_request( + general=False, + command_id=cluster.commands_by_name[cmd_name].id, + schema=cluster.commands_by_name[cmd_name].schema, + disable_default_response=False, + direction=foundation.Direction.Server_to_Client, + args=(), + kwargs=kwargs, + ) + + ota_packet = t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=zigpy_device.nwk), + src_ep=1, + dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=0x0000), + dst_ep=1, + tsn=req_hdr.tsn, + profile_id=260, + cluster_id=cluster.cluster_id, + data=t.SerializableBytes(req_hdr.serialize() + req_cmd.serialize()), + lqi=255, + rssi=-30, + ) + + return ota_packet + + +async def test_firmware_update_success( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update success.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + current_file_version=fw_image.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.image_size == fw_image.header.image_size + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + file_offset=0, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.image_block_response.schema + ): + if cmd.file_offset == 0: + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.file_offset == 0 + assert cmd.image_data == fw_image.serialize()[0:40] + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + file_offset=40, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif cmd.file_offset == 40: + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.file_offset == 40 + assert cmd.image_data == fw_image.serialize()[40:70] + + # make sure the state machine gets progress reports + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert ( + attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + ) + assert attrs[ATTR_IN_PROGRESS] == 57 + assert ( + attrs[ATTR_LATEST_VERSION] + == f"0x{fw_image.header.file_version:08x}" + ) + + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.upgrade_end.name, + status=foundation.Status.SUCCESS, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + file_version=fw_image.header.file_version, + ) + ) + + elif isinstance( + cmd, general.Ota.ClientCommandDefs.upgrade_end_response.schema + ): + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.current_time == 0 + assert cmd.upgrade_time == 0 + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{fw_image.header.file_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == attrs[ATTR_INSTALLED_VERSION] + + +async def test_firmware_update_raises( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - firmware update raises.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + assert hass.states.get(entity_id).state == STATE_OFF + + # simulate an image available notification + await cluster._handle_query_next_image( + fw_image.header.field_control, + zha_device.manufacturer_code, + fw_image.header.image_type, + installed_fw_version, + fw_image.header.header_version, + tsn=15, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.header.manufacturer_id, + image_type=fw_image.header.image_type, + current_file_version=fw_image.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.header.manufacturer_id + assert cmd.image_type == fw_image.header.image_type + assert cmd.file_version == fw_image.header.file_version + assert cmd.image_size == fw_image.header.image_size + raise DeliveryError("failed to deliver") + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + with patch( + "zigpy.device.Device.update_firmware", + AsyncMock(side_effect=DeliveryError("failed to deliver")), + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + +async def test_firmware_update_restore_data( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.header.file_version:08x}" + + +async def test_firmware_update_restore_file_not_found( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data - file not found.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, zigpy_device, file_not_found=True + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{installed_fw_version:08x}" + + +async def test_firmware_update_restore_version_from_state_machine( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device +) -> None: + """Test ZHA update platform - restore data - file not found.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "update.fakemanufacturer_fakemodel_firmware", + STATE_ON, + { + ATTR_INSTALLED_VERSION: f"0x{installed_fw_version:08x}", + ATTR_LATEST_VERSION: f"0x{fw_version:08x}", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"image_type": 0x90}, + ) + ], + ) + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=True, + file_not_found=True, + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + attrs = state.attributes + assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not attrs[ATTR_IN_PROGRESS] + assert attrs[ATTR_LATEST_VERSION] == f"0x{installed_fw_version:08x}" diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index a45ffce9e47..9a9535178d2 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -127,6 +127,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_lqi", }, + ("update", "00:11:22:33:44:55:66:77-5-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.bosch_isw_zpr1_wp13_firmware", + }, }, }, { @@ -165,6 +170,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3130_firmware", + }, }, }, { @@ -248,6 +258,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3210_l_firmware", + }, }, }, { @@ -296,6 +311,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_humidity", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3310_s_firmware", + }, }, }, { @@ -351,6 +371,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3315_s_firmware", + }, }, }, { @@ -406,6 +431,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3320_l_firmware", + }, }, }, { @@ -461,6 +491,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3326_l_firmware", + }, }, }, { @@ -523,6 +558,11 @@ DEVICES = [ "binary_sensor.centralite_motion_sensor_a_occupancy" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_motion_sensor_a_firmware", + }, }, }, { @@ -593,6 +633,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", }, + ("update", "00:11:22:33:44:55:66:77-4-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.climaxtechnology_psmp5_00_00_02_02tc_firmware", + }, }, }, { @@ -798,6 +843,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_smokesensor_em_firmware", + }, }, }, { @@ -836,6 +886,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_co_v16_firmware", + }, }, }, { @@ -899,6 +954,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_warningdevice_firmware", + }, }, }, { @@ -952,6 +1012,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_lqi", }, + ("update", "00:11:22:33:44:55:66:77-6-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.hivehome_com_mot003_firmware", + }, }, }, { @@ -1005,6 +1070,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_firmware", + }, }, }, { @@ -1051,6 +1121,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_firmware", + }, }, }, { @@ -1097,6 +1172,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_firmware", + }, }, }, { @@ -1143,6 +1223,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_firmware", + }, }, }, { @@ -1189,6 +1274,11 @@ DEVICES = [ "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_firmware", + }, }, }, { @@ -1231,6 +1321,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_control_outlet_firmware", + }, }, }, { @@ -1280,6 +1375,11 @@ DEVICES = [ "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion" ), }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_motion_sensor_firmware", + }, }, }, { @@ -1322,6 +1422,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_on_off_switch_firmware", + }, }, }, { @@ -1364,6 +1469,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_remote_control_firmware", + }, }, }, { @@ -1408,6 +1518,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_signal_repeater_firmware", + }, }, }, { @@ -1452,6 +1567,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_wireless_dimmer_firmware", + }, }, }, { @@ -1512,6 +1632,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45852_firmware", + }, }, }, { @@ -1572,6 +1697,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45856_firmware", + }, }, }, { @@ -1632,6 +1762,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45857_firmware", + }, }, }, { @@ -1685,6 +1820,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_610_mp_1_3_firmware", + }, }, }, { @@ -1738,6 +1878,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_2_firmware", + }, }, }, { @@ -1791,6 +1936,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_3_firmware", + }, }, }, { @@ -1836,6 +1986,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.king_of_fans_inc_hbuniversalcfremote_firmware", + }, }, }, { @@ -1874,6 +2029,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lds_zbt_cctswitch_d0001_firmware", + }, }, }, { @@ -1912,6 +2072,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_a19_rgbw_firmware", + }, }, }, { @@ -1950,6 +2115,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_flex_rgbw_firmware", + }, }, }, { @@ -1988,6 +2158,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_plug_firmware", + }, }, }, { @@ -2026,6 +2201,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_rt_rgbw_firmware", + }, }, }, { @@ -2110,6 +2290,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_summation_delivered", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_plug_maus01_firmware", + }, }, }, { @@ -2210,6 +2395,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light_2", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_relay_c2acn01_firmware", + }, }, }, { @@ -2262,6 +2452,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b186acn01_firmware", + }, }, }, { @@ -2314,6 +2509,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b286acn01_firmware", + }, }, }, { @@ -2780,6 +2980,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_86sw1_firmware", + }, }, }, { @@ -2832,6 +3037,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_cube_aqgl01_firmware", + }, }, }, { @@ -2894,6 +3104,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_humidity", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_ht_firmware", + }, }, }, { @@ -2937,6 +3152,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_opening", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_magnet_firmware", + }, }, }, { @@ -3042,6 +3262,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_motion_aq2_firmware", + }, }, }, { @@ -3092,6 +3317,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_smoke_firmware", + }, }, }, { @@ -3130,6 +3360,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_switch_firmware", + }, }, }, { @@ -3246,6 +3481,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_wleak_aq1_firmware", + }, }, }, { @@ -3316,6 +3556,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_device_temperature", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_vibration_aq1_firmware", + }, }, }, { @@ -3534,6 +3779,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_a19_rgbw_firmware", + }, }, }, { @@ -3572,6 +3822,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_dimming_switch_firmware", + }, }, }, { @@ -3610,6 +3865,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_flex_rgbw_firmware", + }, }, }, { @@ -3684,6 +3944,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_rt_tunable_white_firmware", + }, }, }, { @@ -3722,6 +3987,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_plug_01_firmware", + }, }, }, { @@ -3816,6 +4086,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_switch_4x_lightify_firmware", + }, }, }, { @@ -3866,6 +4141,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_battery", }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_rwl020_firmware", + }, }, }, { @@ -3914,6 +4194,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_button_firmware", + }, }, }, { @@ -3967,6 +4252,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_multi_firmware", + }, }, }, { @@ -4015,6 +4305,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_water_firmware", + }, }, }, { @@ -4083,6 +4378,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.securifi_ltd_unk_model_firmware", + }, }, }, { @@ -4131,6 +4431,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_dws04n_sf_firmware", + }, }, }, { @@ -4221,6 +4526,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_esw01_firmware", + }, }, }, { @@ -4274,6 +4584,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_pir04_firmware", + }, }, }, { @@ -4344,6 +4659,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_rm3250zb_firmware", + }, }, }, { @@ -4444,6 +4764,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_setpoint_change_source", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1123zb_firmware", + }, }, }, { @@ -4544,6 +4869,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_setpoint_change_source", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1124zb_firmware", + }, }, }, { @@ -4617,6 +4947,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_outletv4_firmware", + }, }, }, { @@ -4660,6 +4995,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_tagv4_firmware", + }, }, }, { @@ -4698,6 +5038,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss007z_firmware", + }, }, }, { @@ -4741,6 +5086,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_switch", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss008z_firmware", + }, }, }, { @@ -4789,6 +5139,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.visonic_mct_340_e_firmware", + }, }, }, { @@ -4847,6 +5202,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_setpoint_change_source", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.zen_within_zen_01_firmware", + }, }, }, { @@ -4916,6 +5276,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_4", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.tyzb01_ns1ndbww_ts0004_firmware", + }, }, }, { @@ -5012,6 +5377,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e11_g13_firmware", + }, }, }, { @@ -5065,6 +5435,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e12_n14_firmware", + }, }, }, { @@ -5118,6 +5493,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "LQISensor", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_lqi", }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_z01_a19nae26_firmware", + }, }, }, { @@ -5584,6 +5964,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", DEV_SIG_ENT_MAP_ID: "select.philips_sml001_motion_sensitivity", }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "ZHAFirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_sml001_firmware", + }, }, }, ] From ac8f555a70d90e49af3aa0b72d6f256f28bcfdf2 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 30 Jan 2024 22:40:33 -0500 Subject: [PATCH 1214/1544] Add additional entities for the Aqara E1 curtain motor to ZHA (#108243) * aqara curtain motor opened by hand binary sensor add icon and translation key for identify button remove previous inversion entity add window covering type sensor and aqara curtain motor sensors add aqara curtain motor hook lock switch add aqara curtain motor attributes zcl_init_attrs add aqara curtain motor zcl_init_attrs translations * update translation string * review comments * use enum sensor after rebase * remove button change --- homeassistant/components/zha/binary_sensor.py | 15 ++++- .../zha/core/cluster_handlers/general.py | 3 + .../cluster_handlers/manufacturerspecific.py | 8 +++ homeassistant/components/zha/select.py | 19 ------- homeassistant/components/zha/sensor.py | 55 +++++++++++++++++++ homeassistant/components/zha/strings.json | 15 +++++ homeassistant/components/zha/switch.py | 12 ++++ 7 files changed, 107 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9b057a3cbc3..5ec829fcb05 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -74,7 +74,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): _attribute_name: str - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Initialize the ZHA binary sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler = cluster_handlers[0] @@ -336,3 +336,16 @@ class AqaraLinkageAlarmState(BinarySensor): _unique_id_suffix = "linkage_alarm_state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE _attr_translation_key: str = "linkage_alarm_state" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorOpenedByHandBinarySensor(BinarySensor): + """Opened by hand binary sensor.""" + + _unique_id_suffix = "hand_open" + _attribute_name = "hand_open" + _attr_translation_key = "hand_open" + _attr_icon = "mdi:hand-wave" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 045e6a8f593..14401b260b2 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -203,6 +203,9 @@ class BasicClusterHandler(ClusterHandler): ): self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["transmit_power"] = True + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["power_source"] = True @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 9375ecf60b1..608a256606f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -160,6 +160,14 @@ class OppleRemoteClusterHandler(ClusterHandler): "startup_on_off": True, "decoupled_mode": True, } + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = { + "hooks_state": True, + "hooks_lock": True, + "positions_stored": True, + "light_level": True, + "hand_open": True, + } async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Initialize cluster handler specific.""" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 58f2a608e47..3736858d599 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -471,25 +471,6 @@ class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): _attr_translation_key: str = "decoupled_mode" -class AqaraE1ReverseDirection(types.enum8): - """Aqara curtain reversal.""" - - Normal = 0x00 - Inverted = 0x01 - - -@CONFIG_DIAGNOSTIC_MATCH( - cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} -) -class AqaraCurtainMode(ZCLEnumSelectEntity): - """Representation of a ZHA curtain mode configuration entity.""" - - _unique_id_suffix = "window_covering_mode" - _attribute_name = "window_covering_mode" - _enum = AqaraE1ReverseDirection - _attr_translation_key: str = "window_covering_mode" - - class InovelliOutputMode(types.enum1): """Inovelli output mode.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index d3c8fc0b29d..f4689460f93 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -10,6 +10,8 @@ import random from typing import TYPE_CHECKING, Any, Self from zigpy import types +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import Basic from homeassistant.components.climate import HVACAction from homeassistant.components.sensor import ( @@ -51,6 +53,7 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_ANALOG_INPUT, CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_DEVICE_TEMPERATURE, CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, CLUSTER_HANDLER_HUMIDITY, @@ -1312,3 +1315,55 @@ class SetpointChangeSource(EnumSensor): _attr_icon: str = "mdi:thermostat" _attr_entity_category = EntityCategory.DIAGNOSTIC _enum = SetpointChangeSourceEnum + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class WindowCoveringTypeSensor(EnumSensor): + """Sensor that displays the type of a cover device.""" + + _attribute_name: str = WindowCovering.AttributeDefs.window_covering_type.name + _enum = WindowCovering.WindowCoveringType + _unique_id_suffix: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_translation_key: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:curtains" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_BASIC, models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainMotorPowerSourceSensor(EnumSensor): + """Sensor that displays the power source of the Aqara E1 curtain motor device.""" + + _attribute_name: str = Basic.AttributeDefs.power_source.name + _enum = Basic.PowerSource + _unique_id_suffix: str = Basic.AttributeDefs.power_source.name + _attr_translation_key: str = Basic.AttributeDefs.power_source.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:battery-positive" + + +class AqaraE1HookState(types.enum8): + """Aqara hook state.""" + + Unlocked = 0x00 + Locked = 0x01 + Locking = 0x02 + Unlocking = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainHookStateSensor(EnumSensor): + """Representation of a ZHA curtain mode configuration entity.""" + + _attribute_name = "hooks_state" + _enum = AqaraE1HookState + _unique_id_suffix = "hooks_state" + _attr_translation_key: str = "hooks_state" + _attr_icon: str = "mdi:hook" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 0c9ff765710..3db54712dee 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -566,6 +566,9 @@ }, "ias_zone": { "name": "IAS zone" + }, + "hand_open": { + "name": "Opened by hand" } }, "button": { @@ -896,6 +899,15 @@ }, "setpoint_change_source": { "name": "Setpoint change source" + }, + "power_source": { + "name": "Power source" + }, + "window_covering_type": { + "name": "Window covering type" + }, + "hooks_state": { + "name": "Hooks state" } }, "switch": { @@ -923,6 +935,9 @@ "inverted": { "name": "Inverted" }, + "hooks_locked": { + "name": "Hooks locked" + }, "smart_bulb_mode": { "name": "Smart bulb mode" }, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index fe2a43f7334..afc73baca70 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -685,3 +685,15 @@ class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): if send_command: await self._cluster_handler.write_attributes_safe({name: current_mode}) await self.async_update() + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorHooksLockedSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls whether the curtain motor hooks are locked.""" + + _unique_id_suffix = "hooks_lock" + _attribute_name = "hooks_lock" + _attr_translation_key = "hooks_locked" + _attr_icon: str = "mdi:lock" From b8c9da470589c05e01655dfb21e5a250b93c8234 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 30 Jan 2024 23:38:27 -0500 Subject: [PATCH 1215/1544] Add icon and state translations for zwave_js sensors (#109186) --- homeassistant/components/zwave_js/icons.json | 24 +++++++++++++++ homeassistant/components/zwave_js/sensor.py | 30 ++----------------- .../components/zwave_js/strings.json | 22 ++++++++++++++ tests/components/zwave_js/test_sensor.py | 13 -------- 4 files changed, 49 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/zwave_js/icons.json diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json new file mode 100644 index 00000000000..2280811d3da --- /dev/null +++ b/homeassistant/components/zwave_js/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "controller_status": { + "default": "mdi:help-rhombus", + "state": { + "ready": "mdi:check", + "unresponsive": "mdi:bell-off", + "jammed": "mdi:lock" + } + }, + "node_status": { + "default": "mdi:help-rhombus", + "state": { + "alive": "mdi:heart-pulse", + "asleep": "mdi:sleep", + "awake": "mdi:eye", + "dead": "mdi:robot-dead", + "unknown": "mdi:help-rhombus" + } + } + } + } +} diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 9e95d430a4c..f89498af72b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -6,7 +6,7 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ControllerStatus, NodeStatus +from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, @@ -91,20 +91,6 @@ from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 -CONTROLLER_STATUS_ICON: dict[ControllerStatus, str] = { - ControllerStatus.READY: "mdi:check", - ControllerStatus.UNRESPONSIVE: "mdi:bell-off", - ControllerStatus.JAMMED: "mdi:lock", -} - -NODE_STATUS_ICON: dict[NodeStatus, str] = { - NodeStatus.ALIVE: "mdi:heart-pulse", - NodeStatus.ASLEEP: "mdi:sleep", - NodeStatus.AWAKE: "mdi:eye", - NodeStatus.DEAD: "mdi:robot-dead", - NodeStatus.UNKNOWN: "mdi:help-rhombus", -} - # These descriptions should include device class. ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ @@ -784,6 +770,7 @@ class ZWaveNodeStatusSensor(SensorEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True + _attr_translation_key = "node_status" def __init__( self, config_entry: ConfigEntry, driver: Driver, node: ZwaveNode @@ -793,7 +780,6 @@ class ZWaveNodeStatusSensor(SensorEntity): self.node = node # Entity class attributes - self._attr_name = "Node status" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.node_status" # device may not be precreated in main handler yet @@ -815,11 +801,6 @@ class ZWaveNodeStatusSensor(SensorEntity): self._attr_native_value = self.node.status.name.lower() self.async_write_ha_state() - @property - def icon(self) -> str | None: - """Icon of the entity.""" - return NODE_STATUS_ICON[self.node.status] - async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. @@ -852,6 +833,7 @@ class ZWaveControllerStatusSensor(SensorEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True + _attr_translation_key = "controller_status" def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" @@ -861,7 +843,6 @@ class ZWaveControllerStatusSensor(SensorEntity): assert node # Entity class attributes - self._attr_name = "Status" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.controller_status" # device may not be precreated in main handler yet @@ -883,11 +864,6 @@ class ZWaveControllerStatusSensor(SensorEntity): self._attr_native_value = self.controller.status.name.lower() self.async_write_ha_state() - @property - def icon(self) -> str | None: - """Icon of the entity.""" - return CONTROLLER_STATUS_ICON[self.controller.status] - async def async_added_to_hass(self) -> None: """Call when entity is added.""" # Add value_changed callbacks. diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 8dadac12af1..db19c0fceeb 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,4 +1,26 @@ { + "entity": { + "sensor": { + "node_status": { + "name": "Node status", + "state": { + "alive": "Alive", + "asleep": "Asleep", + "awake": "Awake", + "dead": "Dead", + "unknown": "Unknown" + } + }, + "controller_status": { + "name": "Status", + "state": { + "ready": "Ready", + "unresponsive": "Unresponsive", + "jammed": "Jammed" + } + } + } + }, "config": { "flow_title": "{name}", "step": { diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 0fe3e32043b..96ad8bfa82c 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -25,7 +25,6 @@ from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -318,7 +317,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "ready" - assert state.attributes[ATTR_ICON] == "mdi:check" event = Event( "status changed", @@ -328,7 +326,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "unresponsive" - assert state.attributes[ATTR_ICON] == "mdi:bell-off" # Test transitions work event = Event( @@ -339,7 +336,6 @@ async def test_controller_status_sensor( state = hass.states.get(entity_id) assert state assert state.state == "jammed" - assert state.attributes[ATTR_ICON] == "mdi:lock" # Disconnect the client and make sure the entity is still available await client.disconnect() @@ -365,33 +361,24 @@ async def test_node_status_sensor( ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "dead" - assert ( - hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:robot-dead" - ) event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "awake" - assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "asleep" - assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) assert hass.states.get(node_status_entity_id).state == "alive" - assert ( - hass.states.get(node_status_entity_id).attributes[ATTR_ICON] - == "mdi:heart-pulse" - ) # Disconnect the client and make sure the entity is still available await client.disconnect() From 961a1c4d001998aec1d9884dfeaf441257f733bf Mon Sep 17 00:00:00 2001 From: Josh Pettersen <12600312+bubonicbob@users.noreply.github.com> Date: Tue, 30 Jan 2024 20:40:05 -0800 Subject: [PATCH 1216/1544] Change the suggested energy units to kWh (#109184) * Change the suggested energy units to kWh since the practical value is pretty large. * Fix unit tests --- homeassistant/components/tesla_wall_connector/sensor.py | 1 + tests/components/tesla_wall_connector/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 1b9433eb696..d1c108aaf95 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -128,6 +128,7 @@ WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( key="energy_kWh", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 0cafc15c6f1..06cd5a8ef83 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -24,7 +24,7 @@ async def test_sensors(hass: HomeAssistant) -> None: "sensor.tesla_wall_connector_grid_frequency", "50.021", "49.981" ), EntityAndExpectedValues( - "sensor.tesla_wall_connector_energy", "988022", "989000" + "sensor.tesla_wall_connector_energy", "988.022", "989.000" ), EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_a_current", "10", "7" From 4c6ac743136a61e6972cad0d76c125b47287e8df Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Wed, 31 Jan 2024 04:41:20 +0000 Subject: [PATCH 1217/1544] allow songcast source to be stopped and played (#109180) --- homeassistant/components/openhome/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index a4a16c6713c..4935af1bc46 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -154,7 +154,7 @@ class OpenhomeDevice(MediaPlayerEntity): self._source_index = source_index self._attr_source_list = source_names - if source["type"] == "Radio": + if source["type"] in ("Radio", "Receiver"): self._attr_supported_features |= ( MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY From 9c22226fed83e5ea6ba01964fc46dad086eafe2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Jan 2024 18:48:05 -1000 Subject: [PATCH 1218/1544] Ensure bluetooth auto recovery does not run in tests (#109163) If time was moved forward too much the scanner would try to auto recover --- tests/conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2b61cee4e33..9e946c55831 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1576,9 +1576,10 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] - with patch( - "habluetooth.scanner.OriginalBleakScanner.start", - ) as mock_bleak_scanner_start: + with patch.object( + bluetooth_scanner.OriginalBleakScanner, + "start", + ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"): yield mock_bleak_scanner_start From 54b08ea675994d986e32db62ba84ef6549d0ca33 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 05:48:22 +0100 Subject: [PATCH 1219/1544] Remove unused constants from QNAP (#109152) --- homeassistant/components/qnap/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 4677d2aabb6..e84124a96bd 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -31,8 +31,6 @@ ATTR_MASK = "Mask" ATTR_MAX_SPEED = "Max Speed" ATTR_MEMORY_SIZE = "Memory Size" ATTR_MODEL = "Model" -ATTR_PACKETS_TX = "Packets (TX)" -ATTR_PACKETS_RX = "Packets (RX)" ATTR_PACKETS_ERR = "Packets (Err)" ATTR_SERIAL = "Serial #" ATTR_TYPE = "Type" From 7e99ddcac992bffcfa6a3fe8c3af96732af18c66 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 31 Jan 2024 05:48:56 +0100 Subject: [PATCH 1220/1544] Bump aioelectricitymaps to 0.2.0 (#109150) --- homeassistant/components/co2signal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index c2e70bdb21e..87f2b5c2db0 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.1.6"] + "requirements": ["aioelectricitymaps==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 89c4ef5d6c2..811438841aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.6 +aioelectricitymaps==0.2.0 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4e1d822f5f..b691ded8376 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.1.6 +aioelectricitymaps==0.2.0 # homeassistant.components.emonitor aioemonitor==1.0.5 From 5fd6028d9785c76c1b645821fbbc256ab468737a Mon Sep 17 00:00:00 2001 From: julienfreche Date: Tue, 30 Jan 2024 20:52:02 -0800 Subject: [PATCH 1221/1544] Intellifire: fix incorrect name attribute in debug log when setting flame height (#109168) --- homeassistant/components/intellifire/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 5da3c3cdbf8..efcafd2acd8 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -69,7 +69,7 @@ class IntellifireFlameControlEntity(IntellifireEntity, NumberEntity): value_to_send: int = int(value) - 1 LOGGER.debug( "%s set flame height to %d with raw value %s", - self._attr_name, + self.name, value, value_to_send, ) From b629ad9c3d3cd278e13487120b6e28015b69f6a4 Mon Sep 17 00:00:00 2001 From: Josh Pettersen <12600312+bubonicbob@users.noreply.github.com> Date: Tue, 30 Jan 2024 21:03:01 -0800 Subject: [PATCH 1222/1544] Add individual battery banks as devices (#108339) --- .../components/powerwall/__init__.py | 4 + homeassistant/components/powerwall/entity.py | 35 ++++- homeassistant/components/powerwall/models.py | 3 + homeassistant/components/powerwall/sensor.py | 148 +++++++++++++++++- .../components/powerwall/strings.json | 14 ++ .../powerwall/fixtures/batteries.json | 32 ++++ tests/components/powerwall/mocks.py | 7 + tests/components/powerwall/test_sensor.py | 61 ++++++++ 8 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 tests/components/powerwall/fixtures/batteries.json diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 79e612deb4c..d975537ca61 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -229,6 +229,7 @@ async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo status = tg.create_task(power_wall.get_status()) device_type = tg.create_task(power_wall.get_device_type()) serial_numbers = tg.create_task(power_wall.get_serial_numbers()) + batteries = tg.create_task(power_wall.get_batteries()) # Mimic the behavior of asyncio.gather by reraising the first caught exception since # this is what is expected by the caller of this method @@ -248,6 +249,7 @@ async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo device_type=device_type.result(), serial_numbers=sorted(serial_numbers.result()), url=f"https://{host}", + batteries={battery.serial_number: battery for battery in batteries.result()}, ) @@ -270,6 +272,7 @@ async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: meters = tg.create_task(power_wall.get_meters()) grid_services_active = tg.create_task(power_wall.is_grid_services_active()) grid_status = tg.create_task(power_wall.get_grid_status()) + batteries = tg.create_task(power_wall.get_batteries()) # Mimic the behavior of asyncio.gather by reraising the first caught exception since # this is what is expected by the caller of this method @@ -287,6 +290,7 @@ async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: grid_services_active=grid_services_active.result(), grid_status=grid_status.result(), backup_reserve=backup_reserve.result(), + batteries={battery.serial_number: battery for battery in batteries.result()}, ) diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index 0ee4249a8e9..cad371ea42c 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -14,7 +14,7 @@ from .const import ( POWERWALL_BASE_INFO, POWERWALL_COORDINATOR, ) -from .models import PowerwallData, PowerwallRuntimeData +from .models import BatteryResponse, PowerwallData, PowerwallRuntimeData class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): @@ -43,3 +43,36 @@ class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): def data(self) -> PowerwallData: """Return the coordinator data.""" return self.coordinator.data + + +class BatteryEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): + """Base class for battery entities.""" + + _attr_has_entity_name = True + + def __init__( + self, powerwall_data: PowerwallRuntimeData, battery: BatteryResponse + ) -> None: + """Initialize the entity.""" + base_info = powerwall_data[POWERWALL_BASE_INFO] + coordinator = powerwall_data[POWERWALL_COORDINATOR] + assert coordinator is not None + super().__init__(coordinator) + self.serial_number = battery.serial_number + self.power_wall = powerwall_data[POWERWALL_API] + self.base_unique_id = f"{base_info.gateway_din}_{battery.serial_number}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.base_unique_id)}, + manufacturer=MANUFACTURER, + model=f"{MODEL} ({battery.part_number})", + name=f"{base_info.site_info.site_name} {battery.serial_number}", + sw_version=base_info.status.version, + configuration_url=base_info.url, + via_device=(DOMAIN, base_info.gateway_din), + ) + + @property + def battery_data(self) -> BatteryResponse: + """Return the coordinator data.""" + return self.coordinator.data.batteries[self.serial_number] diff --git a/homeassistant/components/powerwall/models.py b/homeassistant/components/powerwall/models.py index 65213065d0e..3216b83a7db 100644 --- a/homeassistant/components/powerwall/models.py +++ b/homeassistant/components/powerwall/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import TypedDict from tesla_powerwall import ( + BatteryResponse, DeviceType, GridStatus, MetersAggregatesResponse, @@ -27,6 +28,7 @@ class PowerwallBaseInfo: device_type: DeviceType serial_numbers: list[str] url: str + batteries: dict[str, BatteryResponse] @dataclass @@ -39,6 +41,7 @@ class PowerwallData: grid_services_active: bool grid_status: GridStatus backup_reserve: float | None + batteries: dict[str, BatteryResponse] class PowerwallRuntimeData(TypedDict): diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 398e972d723..24aeb9e4f4e 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, TypeVar -from tesla_powerwall import MeterResponse, MeterType +from tesla_powerwall import GridState, MeterResponse, MeterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -27,14 +28,14 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, POWERWALL_COORDINATOR -from .entity import PowerWallEntity -from .models import PowerwallRuntimeData +from .entity import BatteryEntity, PowerWallEntity +from .models import BatteryResponse, PowerwallRuntimeData _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" _ValueParamT = TypeVar("_ValueParamT") -_ValueT = TypeVar("_ValueT", bound=float) +_ValueT = TypeVar("_ValueT", bound=float | int | str) @dataclass(frozen=True) @@ -112,6 +113,116 @@ POWERWALL_INSTANT_SENSORS = ( ) +def _get_battery_charge(battery_data: BatteryResponse) -> float: + """Get the current value in %.""" + ratio = float(battery_data.energy_remaining) / float(battery_data.capacity) + return round(100 * ratio, 1) + + +BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="battery_capacity", + translation_key="battery_capacity", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda battery_data: battery_data.capacity, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_instant_voltage", + translation_key="battery_instant_voltage", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda battery_data: round(battery_data.v_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="instant_frequency", + translation_key="instant_frequency", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + entity_registry_enabled_default=False, + value_fn=lambda battery_data: round(battery_data.f_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="instant_current", + translation_key="instant_current", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + value_fn=lambda battery_data: round(battery_data.i_out, 1), + ), + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="instant_power", + translation_key="instant_power", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda battery_data: battery_data.p_out, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_export", + translation_key="battery_export", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=0, + value_fn=lambda battery_data: battery_data.energy_discharged, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="battery_import", + translation_key="battery_import", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=0, + value_fn=lambda battery_data: battery_data.energy_charged, + ), + PowerwallSensorEntityDescription[BatteryResponse, int]( + key="battery_remaining", + translation_key="battery_remaining", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda battery_data: battery_data.energy_remaining, + ), + PowerwallSensorEntityDescription[BatteryResponse, float]( + key="charge", + translation_key="charge", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=_get_battery_charge, + ), + PowerwallSensorEntityDescription[BatteryResponse, str]( + key="grid_state", + translation_key="grid_state", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[state.value.lower() for state in GridState], + value_fn=lambda battery_data: battery_data.grid_state.value.lower(), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -137,6 +248,12 @@ async def async_setup_entry( for description in POWERWALL_INSTANT_SENSORS ) + for battery in data.batteries.values(): + entities.extend( + PowerWallBatterySensor(powerwall_data, battery, description) + for description in BATTERY_INSTANT_SENSORS + ) + async_add_entities(entities) @@ -281,3 +398,26 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor): if TYPE_CHECKING: assert meter is not None return meter.get_energy_imported() + + +class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]): + """Representation of an Powerwall Battery sensor.""" + + entity_description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT] + + def __init__( + self, + powerwall_data: PowerwallRuntimeData, + battery: BatteryResponse, + description: PowerwallSensorEntityDescription[BatteryResponse, _ValueT], + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(powerwall_data, battery) + self._attr_translation_key = description.translation_key + self._attr_unique_id = f"{self.base_unique_id}_{description.key}" + + @property + def native_value(self) -> float | int | str: + """Get the current value.""" + return self.entity_description.value_fn(self.battery_data) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 3a44aa8053e..8e18dfb308d 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -146,6 +146,20 @@ "battery_export": { "name": "Battery export" }, + "battery_capacity": { + "name": "Battery capacity" + }, + "battery_remaining": { + "name": "Battery remaining" + }, + "grid_state": { + "name": "Grid state", + "state": { + "grid_compliant": "Compliant", + "grid_qualifying": "Qualifying", + "grid_uncompliant": "Uncompliant" + } + }, "load_import": { "name": "Load import" }, diff --git a/tests/components/powerwall/fixtures/batteries.json b/tests/components/powerwall/fixtures/batteries.json new file mode 100644 index 00000000000..fb8d4a97ee4 --- /dev/null +++ b/tests/components/powerwall/fixtures/batteries.json @@ -0,0 +1,32 @@ +[ + { + "PackagePartNumber": "3012170-05-C", + "PackageSerialNumber": "TG0123456789AB", + "energy_charged": 2693355, + "energy_discharged": 2358235, + "nominal_energy_remaining": 14715, + "nominal_full_pack_energy": 14715, + "wobble_detected": false, + "p_out": -100, + "q_out": -1080, + "v_out": 245.70000000000002, + "f_out": 50.037, + "i_out": 0.30000000000000004, + "pinv_grid_state": "Grid_Compliant" + }, + { + "PackagePartNumber": "3012170-05-C", + "PackageSerialNumber": "TG9876543210BA", + "energy_charged": 610483, + "energy_discharged": 509907, + "nominal_energy_remaining": 15137, + "nominal_full_pack_energy": 15137, + "wobble_detected": false, + "p_out": -100, + "q_out": -1090, + "v_out": 245.60000000000002, + "f_out": 50.037, + "i_out": 0.1, + "pinv_grid_state": "Grid_Compliant" + } +] diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py index c1fb2630261..10b070a0db7 100644 --- a/tests/components/powerwall/mocks.py +++ b/tests/components/powerwall/mocks.py @@ -6,6 +6,7 @@ import os from unittest.mock import MagicMock from tesla_powerwall import ( + BatteryResponse, DeviceType, GridStatus, MetersAggregatesResponse, @@ -29,6 +30,7 @@ async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> Mag site_info = tg.create_task(_async_load_json_fixture(hass, "site_info.json")) status = tg.create_task(_async_load_json_fixture(hass, "status.json")) device_type = tg.create_task(_async_load_json_fixture(hass, "device_type.json")) + batteries = tg.create_task(_async_load_json_fixture(hass, "batteries.json")) return await _mock_powerwall_return_value( site_info=SiteInfoResponse.from_dict(site_info.result()), @@ -41,6 +43,9 @@ async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> Mag device_type=DeviceType(device_type.result()["device_type"]), serial_numbers=["TG0123456789AB", "TG9876543210BA"], backup_reserve_percentage=15.0, + batteries=[ + BatteryResponse.from_dict(battery) for battery in batteries.result() + ], ) @@ -55,6 +60,7 @@ async def _mock_powerwall_return_value( device_type=None, serial_numbers=None, backup_reserve_percentage=None, + batteries=None, ): powerwall_mock = MagicMock(Powerwall) powerwall_mock.__aenter__.return_value = powerwall_mock @@ -72,6 +78,7 @@ async def _mock_powerwall_return_value( ) powerwall_mock.is_grid_services_active.return_value = grid_services_active powerwall_mock.get_gateway_din.return_value = MOCK_GATEWAY_DIN + powerwall_mock.get_batteries.return_value = batteries return powerwall_mock diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index bca17638629..11b4f25e4a3 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -132,6 +132,67 @@ async def test_sensors( assert hass.states.get("sensor.mysite_load_frequency").state == STATE_UNKNOWN assert hass.states.get("sensor.mysite_backup_reserve").state == STATE_UNKNOWN + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_capacity").state) + == 14.715 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_voltage").state) + == 245.7 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_frequency").state) == 50.0 + ) + assert float(hass.states.get("sensor.mysite_tg0123456789ab_current").state) == 0.3 + assert int(hass.states.get("sensor.mysite_tg0123456789ab_power").state) == -100 + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_export").state) + == 2358.235 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_import").state) + == 2693.355 + ) + assert ( + float(hass.states.get("sensor.mysite_tg0123456789ab_battery_remaining").state) + == 14.715 + ) + assert float(hass.states.get("sensor.mysite_tg0123456789ab_charge").state) == 100.0 + assert ( + str(hass.states.get("sensor.mysite_tg0123456789ab_grid_state").state) + == "grid_compliant" + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_capacity").state) + == 15.137 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_voltage").state) + == 245.6 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_frequency").state) == 50.0 + ) + assert float(hass.states.get("sensor.mysite_tg9876543210ba_current").state) == 0.1 + assert int(hass.states.get("sensor.mysite_tg9876543210ba_power").state) == -100 + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_export").state) + == 509.907 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_import").state) + == 610.483 + ) + assert ( + float(hass.states.get("sensor.mysite_tg9876543210ba_battery_remaining").state) + == 15.137 + ) + assert float(hass.states.get("sensor.mysite_tg9876543210ba_charge").state) == 100.0 + assert ( + str(hass.states.get("sensor.mysite_tg9876543210ba_grid_state").state) + == "grid_compliant" + ) + async def test_sensor_backup_reserve_unavailable(hass: HomeAssistant) -> None: """Confirm that backup reserve sensor is not added if data is unavailable from the device.""" From 9ed50d8b0c487dc6693a408b86a3d522ac8171a0 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 Jan 2024 01:17:43 -0500 Subject: [PATCH 1223/1544] Add last seen sensor for zwave_js devices (#107345) --- homeassistant/components/zwave_js/sensor.py | 110 ++++++++++++-------- tests/components/zwave_js/test_sensor.py | 2 + 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f89498af72b..9fed7158d4a 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -1,8 +1,10 @@ """Representation of Z-Wave sensors.""" from __future__ import annotations -from collections.abc import Mapping -from typing import cast +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient @@ -326,131 +328,166 @@ ENTITY_DESCRIPTION_KEY_MAP = { } +def convert_dict_of_dicts( + statistics: ControllerStatisticsDataType | NodeStatisticsDataType, key: str +) -> Any: + """Convert a dictionary of dictionaries to a value.""" + keys = key.split(".") + return statistics.get(keys[0], {}).get(keys[1], {}).get(keys[2]) # type: ignore[attr-defined] + + +@dataclass(frozen=True, kw_only=True) +class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription): + """Class to represent a Z-Wave JS statistics sensor entity description.""" + + convert: Callable[ + [ControllerStatisticsDataType | NodeStatisticsDataType, str], Any + ] = lambda statistics, key: statistics.get(key) + + # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesTX", name="Successful messages (TX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesRX", name="Successful messages (RX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedTX", name="Messages dropped (TX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedRX", name="Messages dropped (RX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="NAK", name="Messages not accepted", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutCallback", name="Timed out callbacks", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.average", name="Average background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.current", name="Current background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.average", name="Average background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.current", name="Current background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.average", name="Average background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_dict_of_dicts, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.current", name="Current background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, + convert=convert_dict_of_dicts, ), ] # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsRX", name="Successful commands (RX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsTX", name="Successful commands (TX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedRX", name="Commands dropped (RX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedTX", name="Commands dropped (TX)", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="rtt", name="Round Trip Time", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + ZWaveJSStatisticsSensorEntityDescription( key="rssi", name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), + ZWaveJSStatisticsSensorEntityDescription( + key="lastSeen", + name="Last Seen", + device_class=SensorDeviceClass.TIMESTAMP, + convert=( + lambda statistics, key: ( + datetime.fromisoformat(dt) # type: ignore[arg-type] + if (dt := statistics.get(key)) + else None + ) + ), + ), ] @@ -890,6 +927,7 @@ class ZWaveControllerStatusSensor(SensorEntity): class ZWaveStatisticsSensor(SensorEntity): """Representation of a node/controller statistics sensor.""" + entity_description: ZWaveJSStatisticsSensorEntityDescription _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False @@ -900,7 +938,7 @@ class ZWaveStatisticsSensor(SensorEntity): config_entry: ConfigEntry, driver: Driver, statistics_src: ZwaveNode | Controller, - description: SensorEntityDescription, + description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" self.entity_description = description @@ -929,25 +967,11 @@ class ZWaveStatisticsSensor(SensorEntity): " service won't work for it" ) - def _get_data_from_statistics( - self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType - ) -> int | None: - """Get the data from the statistics dict.""" - if "." not in self.entity_description.key: - return cast(int | None, statistics.get(self.entity_description.key)) - - # If key contains dots, we need to traverse the dict to get to the right value - for key in self.entity_description.key.split("."): - if key not in statistics: - return None - statistics = statistics[key] # type: ignore[literal-required] - return cast(int, statistics) - @callback def statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" - self._attr_native_value = self._get_data_from_statistics( - event_data["statistics"] + self._attr_native_value = self.entity_description.convert( + event_data["statistics"], self.entity_description.key ) self.async_write_ha_state() @@ -972,6 +996,6 @@ class ZWaveStatisticsSensor(SensorEntity): ) # Set initial state - self._attr_native_value = self._get_data_from_statistics( - self.statistics_src.statistics.data + self._attr_native_value = self.entity_description.convert( + self.statistics_src.statistics.data, self.entity_description.key ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 96ad8bfa82c..4e88b2b50cc 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -731,6 +731,7 @@ NODE_STATISTICS_SUFFIXES = { NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, "rssi": 7, + "last_seen": "2024-01-01T00:00:00+00:00", } @@ -843,6 +844,7 @@ async def test_statistics_sensors( "repeaterRSSI": [], "routeFailedBetween": [], }, + "lastSeen": "2024-01-01T00:00:00+0000", }, }, ) From 30fdb2a8b3c602bfff820c7c93833247ff06ff09 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 31 Jan 2024 08:46:47 +0100 Subject: [PATCH 1224/1544] Add log to show last received UniFi websocket message (#109167) --- homeassistant/components/unifi/controller.py | 17 +++++++++++++++++ homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index de97631c036..eb127a5dfd9 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -75,6 +75,7 @@ from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) +CHECK_WEBSOCKET_INTERVAL = timedelta(minutes=1) class UniFiController: @@ -89,6 +90,7 @@ class UniFiController: self.api = api self.ws_task: asyncio.Task | None = None + self._cancel_websocket_check: CALLBACK_TYPE | None = None self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] @@ -275,6 +277,9 @@ class UniFiController: self._cancel_heartbeat_check = async_track_time_interval( self.hass, self._async_check_for_stale, CHECK_HEARTBEAT_INTERVAL ) + self._cancel_websocket_check = async_track_time_interval( + self.hass, self._async_watch_websocket, CHECK_WEBSOCKET_INTERVAL + ) @callback def async_heartbeat( @@ -411,6 +416,14 @@ class UniFiController: ): self.hass.loop.call_later(RETRY_TIMER, self.reconnect) + @callback + def _async_watch_websocket(self, now: datetime) -> None: + """Watch timestamp for last received websocket message.""" + LOGGER.debug( + "Last received websocket timestamp: %s", + self.api.connectivity.ws_message_received, + ) + @callback def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. @@ -450,6 +463,10 @@ class UniFiController: self._cancel_heartbeat_check() self._cancel_heartbeat_check = None + if self._cancel_websocket_check: + self._cancel_websocket_check() + self._cancel_websocket_check = None + if self._cancel_poe_command: self._cancel_poe_command() self._cancel_poe_command = None diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 90b4421f164..f69dffc2d57 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==69"], + "requirements": ["aiounifi==70"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 811438841aa..11ad645c36f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -380,7 +380,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==69 +aiounifi==70 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b691ded8376..a107f1d2a88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==69 +aiounifi==70 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 01df5f9cab7bd1305409270e159ca01bf2f385cb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 31 Jan 2024 09:00:43 +0100 Subject: [PATCH 1225/1544] Add Ecovacs button entities (#109189) --- homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/button.py | 108 +++++++++++ homeassistant/components/ecovacs/const.py | 7 + homeassistant/components/ecovacs/icons.json | 14 ++ homeassistant/components/ecovacs/sensor.py | 8 +- homeassistant/components/ecovacs/strings.json | 14 ++ .../ecovacs/snapshots/test_button.ambr | 173 ++++++++++++++++++ tests/components/ecovacs/test_button.py | 110 +++++++++++ tests/components/ecovacs/test_init.py | 2 +- 9 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/ecovacs/button.py create mode 100644 tests/components/ecovacs/snapshots/test_button.ambr create mode 100644 tests/components/ecovacs/test_button.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 1f28240c06a..53c85d6d96f 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -26,6 +26,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.IMAGE, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py new file mode 100644 index 00000000000..c2e5458c2ed --- /dev/null +++ b/homeassistant/components/ecovacs/button.py @@ -0,0 +1,108 @@ +"""Ecovacs button module.""" +from dataclasses import dataclass + +from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +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 .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsButtonEntityDescription( + ButtonEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs button entity description.""" + + +@dataclass(kw_only=True, frozen=True) +class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): + """Ecovacs lifespan button entity description.""" + + component: LifeSpan + + +ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( + EcovacsButtonEntityDescription( + capability_fn=lambda caps: caps.map.relocation if caps.map else None, + key="relocate", + translation_key="relocate", + entity_category=EntityCategory.CONFIG, + ), +) + +LIFESPAN_ENTITY_DESCRIPTIONS = tuple( + EcovacsLifespanButtonEntityDescription( + component=component, + key=f"reset_lifespan_{component.name.lower()}", + translation_key=f"reset_lifespan_{component.name.lower()}", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ) + for component in SUPPORTED_LIFESPANS +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS + ) + for device in controller.devices: + lifespan_capability = device.capabilities.life_span + for description in LIFESPAN_ENTITY_DESCRIPTIONS: + if description.component in lifespan_capability.types: + entities.append( + EcovacsResetLifespanButtonEntity( + device, lifespan_capability, description + ) + ) + + if entities: + async_add_entities(entities) + + +class EcovacsButtonEntity( + EcovacsDescriptionEntity[CapabilityExecute], + ButtonEntity, +): + """Ecovacs button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command(self._capability.execute()) + + +class EcovacsResetLifespanButtonEntity( + EcovacsDescriptionEntity[CapabilityLifeSpan], + ButtonEntity, +): + """Ecovacs reset lifespan button entity.""" + + entity_description: EcovacsLifespanButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + await self._device.execute_command( + self._capability.reset(self.entity_description.component) + ) diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index ed33f90f191..5edbe11c265 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -1,5 +1,12 @@ """Ecovacs constants.""" +from deebot_client.events import LifeSpan DOMAIN = "ecovacs" CONF_CONTINENT = "continent" + +SUPPORTED_LIFESPANS = ( + LifeSpan.BRUSH, + LifeSpan.FILTER, + LifeSpan.SIDE_BRUSH, +) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index ca55d090ccf..86b3dc70dc1 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -8,6 +8,20 @@ } } }, + "button": { + "relocate": { + "default": "mdi:map-marker-question" + }, + "reset_lifespan_brush": { + "default": "mdi:broom" + }, + "reset_lifespan_filter": { + "default": "mdi:air-filter" + }, + "reset_lifespan_side_brush": { + "default": "mdi:broom" + } + }, "sensor": { "error": { "default": "mdi:alert-circle" diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 10dbf9c904d..16a1b4acd43 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from .const import DOMAIN, SUPPORTED_LIFESPANS from .controller import EcovacsController from .entity import ( EcovacsCapabilityEntityDescription, @@ -154,11 +154,7 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ) - for component in ( - LifeSpan.BRUSH, - LifeSpan.FILTER, - LifeSpan.SIDE_BRUSH, - ) + for component in SUPPORTED_LIFESPANS ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 0ee72a942bd..56e3ec1f866 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -24,6 +24,20 @@ "name": "Mop attached" } }, + "button": { + "relocate": { + "name": "Relocate" + }, + "reset_lifespan_brush": { + "name": "Reset brush lifespan" + }, + "reset_lifespan_filter": { + "name": "Reset filter lifespan" + }, + "reset_lifespan_side_brush": { + "name": "Reset side brush lifespan" + } + }, "image": { "map": { "name": "Map" diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr new file mode 100644 index 00000000000..ca61d16602a --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -0,0 +1,173 @@ +# serializer version: 1 +# name: test_buttons[yna5x1][button.ozmo_950_relocate:entity-registry] + 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.ozmo_950_relocate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Relocate', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relocate', + 'unique_id': 'E1234567890000000001_relocate', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_relocate:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Relocate', + }), + 'context': , + 'entity_id': 'button.ozmo_950_relocate', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:entity-registry] + 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.ozmo_950_reset_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:entity-registry] + 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.ozmo_950_reset_filter_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset filter lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_filter', + 'unique_id': 'E1234567890000000001_reset_lifespan_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset filter lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_filter_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:entity-registry] + 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.ozmo_950_reset_side_brush_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_side_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_side_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset side brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_side_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py new file mode 100644 index 00000000000..f804e813256 --- /dev/null +++ b/tests/components/ecovacs/test_button.py @@ -0,0 +1,110 @@ +"""Tests for Ecovacs sensors.""" + +from deebot_client.command import Command +from deebot_client.commands.json import ResetLifeSpan, SetRelocationState +from deebot_client.events import LifeSpan +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2024-01-01 00:00:00"), +] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.BUTTON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entities"), + [ + ( + "yna5x1", + [ + ("button.ozmo_950_relocate", SetRelocationState()), + ("button.ozmo_950_reset_brush_lifespan", ResetLifeSpan(LifeSpan.BRUSH)), + ( + "button.ozmo_950_reset_filter_lifespan", + ResetLifeSpan(LifeSpan.FILTER), + ), + ( + "button.ozmo_950_reset_side_brush_lifespan", + ResetLifeSpan(LifeSpan.SIDE_BRUSH), + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_buttons( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + entities: list[tuple[str, Command]], +) -> None: + """Test that sensor entity snapshots match.""" + assert sorted(hass.states.async_entity_ids()) == [e[0] for e in entities] + device = controller.devices[0] + for entity_id, command in entities: + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + device._execute_command.reset_mock() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(command) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == "2024-01-01T00:00:00+00:00" + assert snapshot(name=f"{entity_id}:state") == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "button.ozmo_950_reset_brush_lifespan", + "button.ozmo_950_reset_filter_lifespan", + "button.ozmo_950_reset_side_brush_lifespan", + ], + ), + ], +) +async def test_disabled_by_default_buttons( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default buttons.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 04e71567dda..11fe403ca9c 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -116,7 +116,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 17), + ("yna5x1", 21), ], ) async def test_all_entities_loaded( From db0486c5e14a5bf43b51e9eff488be48ce3bc861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Wed, 31 Jan 2024 09:01:17 +0100 Subject: [PATCH 1226/1544] Use constants in Picnic service functions (#109170) Co-authored-by: Franck Nijhof --- homeassistant/components/picnic/services.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 2aafb20abaf..03b7576703d 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -61,17 +61,17 @@ async def handle_add_product( hass: HomeAssistant, api_client: PicnicAPI, call: ServiceCall ) -> None: """Handle the call for the add_product service.""" - product_id = call.data.get("product_id") + product_id = call.data.get(ATTR_PRODUCT_ID) if not product_id: product_id = await hass.async_add_executor_job( - product_search, api_client, cast(str, call.data["product_name"]) + product_search, api_client, cast(str, call.data[ATTR_PRODUCT_NAME]) ) if not product_id: raise PicnicServiceException("No product found or no product ID given!") await hass.async_add_executor_job( - api_client.add_product, product_id, call.data.get("amount", 1) + api_client.add_product, product_id, call.data.get(ATTR_AMOUNT, 1) ) From 7e3a459c2ff8647cb8d926346fbaf561d41994c0 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:30:51 +0100 Subject: [PATCH 1227/1544] Add test case for binary sensors in ViCare (#108769) Co-authored-by: Robert Resch --- .coveragerc | 1 - tests/components/vicare/conftest.py | 34 ++++++++++++--- .../vicare/snapshots/test_binary_sensor.ambr | 42 +++++++++++++++++++ tests/components/vicare/test_binary_sensor.py | 26 ++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tests/components/vicare/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/vicare/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 5d0db972ca9..184617a2882 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1517,7 +1517,6 @@ omit = homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/__init__.py - homeassistant/components/vicare/binary_sensor.py homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py homeassistant/components/vicare/entity.py diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 5085ff6661d..46d90960f4e 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -2,10 +2,12 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass from unittest.mock import AsyncMock, Mock, patch import pytest from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareService import ViCareDeviceAccessor, readFeature from homeassistant.components.vicare.const import DOMAIN from homeassistant.core import HomeAssistant @@ -15,16 +17,26 @@ from . import ENTRY_CONFIG, MODULE from tests.common import MockConfigEntry, load_json_object_fixture +@dataclass +class Fixture: + """Fixture representation with the assigned roles and dummy data location.""" + + roles: set[str] + data_file: str + + class MockPyViCare: """Mocked PyVicare class based on a json dump.""" - def __init__(self, fixtures: list[str]) -> None: + def __init__(self, fixtures: list[Fixture]) -> None: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): self.devices.append( PyViCareDeviceConfig( - MockViCareService(fixture), + MockViCareService( + f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture + ), f"deviceId{idx}", f"model{idx}", f"online{idx}", @@ -35,10 +47,22 @@ class MockPyViCare: class MockViCareService: """PyVicareService mock using a json dump.""" - def __init__(self, fixture: str) -> None: + def __init__( + self, installation_id: str, gateway_id: str, device_id: str, fixture: Fixture + ) -> None: """Initialize the mock from a json dump.""" - self._test_data = load_json_object_fixture(fixture) + self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.roles = fixture.roles + self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) + + def hasRoles(self, requested_roles: list[str]) -> bool: + """Return true if requested roles are assigned.""" + return requested_roles and set(requested_roles).issubset(self.roles) + + def getProperty(self, property_name: str): + """Read a property from json dump.""" + return readFeature(self._test_data["data"], property_name) @pytest.fixture @@ -57,7 +81,7 @@ async def mock_vicare_gas_boiler( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[MockConfigEntry, None]: """Return a mocked ViCare API representing a single gas boiler device.""" - fixtures = ["vicare/Vitodens300W.json"] + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with patch( f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures), diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2d08a50bf3f --- /dev/null +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -0,0 +1,42 @@ +# serializer version: 1 +# name: test_binary_sensors[burner] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 Burner', + 'icon': 'mdi:gas-burner', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_burner', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensors[circulation_pump] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'model0 Circulation pump', + 'icon': 'mdi:pump', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_circulation_pump', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensors[frost_protection] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Frost protection', + 'icon': 'mdi:snowflake', + }), + 'context': , + 'entity_id': 'binary_sensor.model0_frost_protection', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/vicare/test_binary_sensor.py b/tests/components/vicare/test_binary_sensor.py new file mode 100644 index 00000000000..79ce91642af --- /dev/null +++ b/tests/components/vicare/test_binary_sensor.py @@ -0,0 +1,26 @@ +"""Test ViCare binary sensors.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + "entity_id", + [ + "burner", + "circulation_pump", + "frost_protection", + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + mock_vicare_gas_boiler: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test the ViCare binary sensor.""" + assert hass.states.get(f"binary_sensor.model0_{entity_id}") == snapshot From 7fe4a343f94b40a83bbb37de8254523332e3eb98 Mon Sep 17 00:00:00 2001 From: stegm Date: Wed, 31 Jan 2024 10:37:23 +0100 Subject: [PATCH 1228/1544] Add state_class to Kostal plenticore sensors (#108096) --- .../components/kostal_plenticore/sensor.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 111d497b128..237a50f85b7 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -82,6 +82,7 @@ SENSOR_PROCESS_DATA = [ name="Home Power from Battery", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, formatter="format_round", ), PlenticoreSensorEntityDescription( @@ -232,7 +233,7 @@ SENSOR_PROCESS_DATA = [ key="Cycles", name="Battery Cycles", icon="mdi:recycle", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_round", ), PlenticoreSensorEntityDescription( @@ -324,6 +325,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -332,6 +334,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -340,6 +343,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -357,6 +361,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -365,6 +370,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -373,6 +379,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Battery Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -390,6 +397,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -398,6 +406,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -406,6 +415,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -423,6 +433,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -431,6 +442,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -439,6 +451,7 @@ SENSOR_PROCESS_DATA = [ name="Home Consumption from PV Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -456,6 +469,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -464,6 +478,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -472,6 +487,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV1 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -489,6 +505,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -497,6 +514,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -505,6 +523,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV2 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -522,6 +541,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -530,6 +550,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -538,6 +559,7 @@ SENSOR_PROCESS_DATA = [ name="Energy PV3 Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -556,6 +578,7 @@ SENSOR_PROCESS_DATA = [ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=True, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -564,6 +587,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Yield Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -572,6 +596,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Yield Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -589,6 +614,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -597,6 +623,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -605,6 +632,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -622,6 +650,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -630,6 +659,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -638,6 +668,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Charge from PV Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -655,6 +686,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -663,6 +695,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -671,6 +704,7 @@ SENSOR_PROCESS_DATA = [ name="Battery Discharge Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -688,6 +722,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -696,6 +731,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( @@ -704,6 +740,7 @@ SENSOR_PROCESS_DATA = [ name="Energy Discharge to Grid Year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), PlenticoreSensorEntityDescription( From f725258ea9b68774a025aa1059b72decd8c71bf1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:37:55 +0000 Subject: [PATCH 1229/1544] Add coordinator to ring integration (#107088) --- homeassistant/components/ring/__init__.py | 229 +----------------- .../components/ring/binary_sensor.py | 36 ++- homeassistant/components/ring/camera.py | 39 ++- homeassistant/components/ring/const.py | 6 +- homeassistant/components/ring/coordinator.py | 118 +++++++++ homeassistant/components/ring/entity.py | 72 ++++-- homeassistant/components/ring/light.py | 31 ++- homeassistant/components/ring/sensor.py | 68 ++---- homeassistant/components/ring/siren.py | 17 +- homeassistant/components/ring/switch.py | 33 ++- tests/components/ring/test_init.py | 71 +++++- tests/components/ring/test_switch.py | 11 +- 12 files changed, 343 insertions(+), 388 deletions(-) create mode 100644 homeassistant/components/ring/coordinator.py diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 8a93d5a7768..26fdc6d0575 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -1,36 +1,25 @@ """Support for Ring Doorbell/Chimes.""" from __future__ import annotations -import asyncio -from collections.abc import Callable -from datetime import timedelta from functools import partial import logging -from typing import Any import ring_doorbell from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.event import async_track_time_interval from .const import ( - DEVICES_SCAN_INTERVAL, DOMAIN, - HEALTH_SCAN_INTERVAL, - HISTORY_SCAN_INTERVAL, - NOTIFICATIONS_SCAN_INTERVAL, PLATFORMS, RING_API, RING_DEVICES, RING_DEVICES_COORDINATOR, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, RING_NOTIFICATIONS_COORDINATOR, ) +from .coordinator import RingDataCoordinator, RingNotificationsCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,42 +42,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ring = ring_doorbell.Ring(auth) - try: - await hass.async_add_executor_job(ring.update_data) - except ring_doorbell.AuthenticationError as err: - _LOGGER.warning("Ring access token is no longer valid, need to re-authenticate") - raise ConfigEntryAuthFailed(err) from err + devices_coordinator = RingDataCoordinator(hass, ring) + notifications_coordinator = RingNotificationsCoordinator(hass, ring) + await devices_coordinator.async_config_entry_first_refresh() + await notifications_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { RING_API: ring, RING_DEVICES: ring.devices(), - RING_DEVICES_COORDINATOR: GlobalDataUpdater( - hass, "device", entry, ring, "update_devices", DEVICES_SCAN_INTERVAL - ), - RING_NOTIFICATIONS_COORDINATOR: GlobalDataUpdater( - hass, - "active dings", - entry, - ring, - "update_dings", - NOTIFICATIONS_SCAN_INTERVAL, - ), - RING_HISTORY_COORDINATOR: DeviceDataUpdater( - hass, - "history", - entry, - ring, - lambda device: device.history(limit=10), - HISTORY_SCAN_INTERVAL, - ), - RING_HEALTH_COORDINATOR: DeviceDataUpdater( - hass, - "health", - entry, - ring, - lambda device: device.update_health_data(), - HEALTH_SCAN_INTERVAL, - ), + RING_DEVICES_COORDINATOR: devices_coordinator, + RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -99,10 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_refresh_all(_: ServiceCall) -> None: """Refresh all ring data.""" for info in hass.data[DOMAIN].values(): - await info["device_data"].async_refresh_all() - await info["dings_data"].async_refresh_all() - await hass.async_add_executor_job(info["history_data"].refresh_all) - await hass.async_add_executor_job(info["health_data"].refresh_all) + await info[RING_DEVICES_COORDINATOR].async_refresh() + await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) @@ -131,173 +92,3 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a config entry from a device.""" return True - - -class GlobalDataUpdater: - """Data storage for single API endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - config_entry: ConfigEntry, - ring: ring_doorbell.Ring, - update_method: str, - update_interval: timedelta, - ) -> None: - """Initialize global data updater.""" - self.hass = hass - self.data_type = data_type - self.config_entry = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.listeners: list[Callable[[], None]] = [] - self._unsub_interval = None - - @callback - def async_add_listener(self, update_callback): - """Listen for data updates.""" - # This is the first listener, set up interval. - if not self.listeners: - self._unsub_interval = async_track_time_interval( - self.hass, self.async_refresh_all, self.update_interval - ) - - self.listeners.append(update_callback) - - @callback - def async_remove_listener(self, update_callback): - """Remove data update.""" - self.listeners.remove(update_callback) - - if not self.listeners: - self._unsub_interval() - self._unsub_interval = None - - async def async_refresh_all(self, _now: int | None = None) -> None: - """Time to update.""" - if not self.listeners: - return - - try: - await self.hass.async_add_executor_job( - getattr(self.ring, self.update_method) - ) - except ring_doorbell.AuthenticationError: - _LOGGER.warning( - "Ring access token is no longer valid, need to re-authenticate" - ) - self.config_entry.async_start_reauth(self.hass) - return - except ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data", - self.data_type, - ) - return - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data: %s", - self.data_type, - err, - ) - return - - for update_callback in self.listeners: - update_callback() - - -class DeviceDataUpdater: - """Data storage for device data.""" - - def __init__( - self, - hass: HomeAssistant, - data_type: str, - config_entry: ConfigEntry, - ring: ring_doorbell.Ring, - update_method: Callable[[ring_doorbell.Ring], Any], - update_interval: timedelta, - ) -> None: - """Initialize device data updater.""" - self.data_type = data_type - self.hass = hass - self.config_entry = config_entry - self.ring = ring - self.update_method = update_method - self.update_interval = update_interval - self.devices: dict = {} - self._unsub_interval = None - - async def async_track_device(self, device, update_callback): - """Track a device.""" - if not self.devices: - self._unsub_interval = async_track_time_interval( - self.hass, self.refresh_all, self.update_interval - ) - - if device.device_id not in self.devices: - self.devices[device.device_id] = { - "device": device, - "update_callbacks": [update_callback], - "data": None, - } - # Store task so that other concurrent requests can wait for us to finish and - # data be available. - self.devices[device.device_id]["task"] = asyncio.current_task() - self.devices[device.device_id][ - "data" - ] = await self.hass.async_add_executor_job(self.update_method, device) - self.devices[device.device_id].pop("task") - else: - self.devices[device.device_id]["update_callbacks"].append(update_callback) - # If someone is currently fetching data as part of the initialization, wait for them - if "task" in self.devices[device.device_id]: - await self.devices[device.device_id]["task"] - - update_callback(self.devices[device.device_id]["data"]) - - @callback - def async_untrack_device(self, device, update_callback): - """Untrack a device.""" - self.devices[device.device_id]["update_callbacks"].remove(update_callback) - - if not self.devices[device.device_id]["update_callbacks"]: - self.devices.pop(device.device_id) - - if not self.devices: - self._unsub_interval() - self._unsub_interval = None - - def refresh_all(self, _=None): - """Refresh all registered devices.""" - for device_id, info in self.devices.items(): - try: - data = info["data"] = self.update_method(info["device"]) - except ring_doorbell.AuthenticationError: - _LOGGER.warning( - "Ring access token is no longer valid, need to re-authenticate" - ) - self.hass.loop.call_soon_threadsafe( - self.config_entry.async_start_reauth, self.hass - ) - return - except ring_doorbell.RingTimeout: - _LOGGER.warning( - "Time out fetching Ring %s data for device %s", - self.data_type, - device_id, - ) - continue - except ring_doorbell.RingError as err: - _LOGGER.warning( - "Error fetching Ring %s data for device %s: %s", - self.data_type, - device_id, - err, - ) - continue - - for update_callback in info["update_callbacks"]: - self.hass.loop.call_soon_threadsafe(update_callback, data) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 27eb82d34ee..a7e04f4cfb9 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR -from .entity import RingEntityMixin +from .coordinator import RingNotificationsCoordinator +from .entity import RingEntity @dataclass(frozen=True) @@ -55,9 +56,12 @@ async def async_setup_entry( """Set up the Ring binary sensors from a config entry.""" ring = hass.data[DOMAIN][config_entry.entry_id][RING_API] devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ][RING_NOTIFICATIONS_COORDINATOR] entities = [ - RingBinarySensor(config_entry.entry_id, ring, device, description) + RingBinarySensor(ring, device, notifications_coordinator, description) for device_type in ("doorbots", "authorized_doorbots", "stickup_cams") for description in BINARY_SENSOR_TYPES if device_type in description.category @@ -67,7 +71,7 @@ async def async_setup_entry( async_add_entities(entities) -class RingBinarySensor(RingEntityMixin, BinarySensorEntity): +class RingBinarySensor(RingEntity, BinarySensorEntity): """A binary sensor implementation for Ring device.""" _active_alert: dict[str, Any] | None = None @@ -75,38 +79,26 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): def __init__( self, - config_entry_id, ring, device, + coordinator, description: RingBinarySensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__( + device, + coordinator, + ) self.entity_description = description self._ring = ring self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_add_listener( - self._dings_update_callback - ) - self._dings_update_callback() - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - self.ring_objects[RING_NOTIFICATIONS_COORDINATOR].async_remove_listener( - self._dings_update_callback - ) - @callback - def _dings_update_callback(self): + def _handle_coordinator_update(self, _=None): """Call update method.""" self._update_alert() - self.async_write_ha_state() + super()._handle_coordinator_update() @callback def _update_alert(self): diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 196d34600d1..265d7102b91 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta from itertools import chain import logging +from typing import Optional from haffmpeg.camera import CameraMjpeg import requests @@ -16,8 +17,9 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, RING_DEVICES, RING_HISTORY_COORDINATOR -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity FORCE_REFRESH_INTERVAL = timedelta(minutes=3) @@ -31,6 +33,9 @@ async def async_setup_entry( ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] @@ -40,19 +45,20 @@ async def async_setup_entry( if not camera.has_subscription: continue - cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) + cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager)) async_add_entities(cams) -class RingCam(RingEntityMixin, Camera): +class RingCam(RingEntity, Camera): """An implementation of a Ring Door Bell camera.""" _attr_name = None - def __init__(self, config_entry_id, ffmpeg_manager, device): + def __init__(self, device, coordinator, ffmpeg_manager): """Initialize a Ring Door Bell camera.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) + Camera.__init__(self) self._ffmpeg_manager = ffmpeg_manager self._last_event = None @@ -62,25 +68,12 @@ class RingCam(RingEntityMixin, Camera): self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL self._attr_unique_id = device.id - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" + history_data: Optional[list] + if not (history_data := self._get_coordinator_history()): + return if history_data: self._last_event = history_data[0] self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 4f208e4f63e..f0e0c63d778 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -23,17 +23,13 @@ PLATFORMS = [ ] -DEVICES_SCAN_INTERVAL = timedelta(minutes=1) +SCAN_INTERVAL = timedelta(minutes=1) NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) -HISTORY_SCAN_INTERVAL = timedelta(minutes=1) -HEALTH_SCAN_INTERVAL = timedelta(minutes=1) RING_API = "api" RING_DEVICES = "devices" RING_DEVICES_COORDINATOR = "device_data" RING_NOTIFICATIONS_COORDINATOR = "dings_data" -RING_HISTORY_COORDINATOR = "history_data" -RING_HEALTH_COORDINATOR = "health_data" CONF_2FA = "2fa" diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py new file mode 100644 index 00000000000..35692ae2648 --- /dev/null +++ b/homeassistant/components/ring/coordinator.py @@ -0,0 +1,118 @@ +"""Data coordinators for the ring integration.""" +from asyncio import TaskGroup +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any, Optional + +import ring_doorbell +from ring_doorbell.generic import RingGeneric + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def _call_api( + hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = "" +): + try: + return await hass.async_add_executor_job(target, *args) + except ring_doorbell.AuthenticationError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ring_doorbell.RingTimeout as err: + raise UpdateFailed( + f"Timeout communicating with API{msg_suffix}: {err}" + ) from err + except ring_doorbell.RingError as err: + raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err + + +@dataclass +class RingDeviceData: + """RingDeviceData.""" + + device: RingGeneric + history: Optional[list] = None + + +class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): + """Base class for device coordinators.""" + + def __init__( + self, + hass: HomeAssistant, + ring_api: ring_doorbell.Ring, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + name="devices", + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + self.first_call: bool = True + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + update_method: str = "update_data" if self.first_call else "update_devices" + await _call_api(self.hass, getattr(self.ring_api, update_method)) + self.first_call = False + data: dict[str, RingDeviceData] = {} + devices: dict[str : list[RingGeneric]] = self.ring_api.devices() + subscribed_device_ids = set(self.async_contexts()) + for device_type in devices: + for device in devices[device_type]: + # Don't update all devices in the ring api, only those that set + # their device id as context when they subscribed. + if device.id in subscribed_device_ids: + data[device.id] = RingDeviceData(device=device) + try: + async with TaskGroup() as tg: + if hasattr(device, "history"): + history_task = tg.create_task( + _call_api( + self.hass, + lambda device: device.history(limit=10), + device, + msg_suffix=f" for device {device.name}", # device_id is the mac + ) + ) + tg.create_task( + _call_api( + self.hass, + device.update_health_data, + msg_suffix=f" for device {device.name}", + ) + ) + if history_task: + data[device.id].history = history_task.result() + except ExceptionGroup as eg: + raise eg.exceptions[0] + + return data + + +class RingNotificationsCoordinator(DataUpdateCoordinator[None]): + """Global notifications coordinator.""" + + def __init__(self, hass: HomeAssistant, ring_api: ring_doorbell.Ring) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name="active dings", + update_interval=NOTIFICATIONS_SCAN_INTERVAL, + ) + self.ring_api: ring_doorbell.Ring = ring_api + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + await _call_api(self.hass, self.ring_api.update_dings) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 4896ea2db8b..78f0c8e468e 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,49 +1,71 @@ """Base class for Ring entity.""" +from typing import TypeVar + +from ring_doorbell.generic import RingGeneric + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, RING_DEVICES_COORDINATOR +from .const import ATTRIBUTION, DOMAIN +from .coordinator import ( + RingDataCoordinator, + RingDeviceData, + RingNotificationsCoordinator, +) + +_RingCoordinatorT = TypeVar( + "_RingCoordinatorT", + bound=(RingDataCoordinator | RingNotificationsCoordinator), +) -class RingEntityMixin(Entity): +class RingEntity(CoordinatorEntity[_RingCoordinatorT]): """Base implementation for Ring device.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, config_entry_id, device): + def __init__( + self, + device: RingGeneric, + coordinator: _RingCoordinatorT, + ) -> None: """Initialize a sensor for Ring device.""" - super().__init__() - self._config_entry_id = config_entry_id + super().__init__(coordinator, context=device.id) self._device = device self._attr_extra_state_attributes = {} self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_id)}, + identifiers={(DOMAIN, device.device_id)}, # device_id is the mac manufacturer="Ring", model=device.model, name=device.name, ) - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_add_listener( - self._update_callback - ) + def _get_coordinator_device_data(self) -> RingDeviceData | None: + if (data := self.coordinator.data) and ( + device_data := data.get(self._device.id) + ): + return device_data + return None - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - self.ring_objects[RING_DEVICES_COORDINATOR].async_remove_listener( - self._update_callback - ) + def _get_coordinator_device(self) -> RingGeneric | None: + if (device_data := self._get_coordinator_device_data()) and ( + device := device_data.device + ): + return device + return None + + def _get_coordinator_history(self) -> list | None: + if (device_data := self._get_coordinator_device_data()) and ( + history := device_data.history + ): + return history + return None @callback - def _update_callback(self) -> None: - """Call update method.""" - self.async_write_ha_state() - - @property - def ring_objects(self): - """Return the Ring API objects.""" - return self.hass.data[DOMAIN][self._config_entry_id] + def _handle_coordinator_update(self) -> None: + if device := self._get_coordinator_device(): + self._device = device + super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 7830b2547a5..73ec8349384 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -4,6 +4,8 @@ import logging from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -35,38 +38,42 @@ async def async_setup_entry( ) -> None: """Create the lights for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] - + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] lights = [] for device in devices["stickup_cams"]: if device.has_capability("light"): - lights.append(RingLight(config_entry.entry_id, device)) + lights.append(RingLight(device, devices_coordinator)) async_add_entities(lights) -class RingLight(RingEntityMixin, LightEntity): +class RingLight(RingEntity, LightEntity): """Creates a switch to turn the ring cameras light on and off.""" _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_translation_key = "light" - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator) -> None: """Initialize the light.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._attr_unique_id = device.id self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - - self._attr_is_on = self._device.lights == ON_STATE - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.lights == ON_STATE + super()._handle_coordinator_update() def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" @@ -78,7 +85,7 @@ class RingLight(RingEntityMixin, LightEntity): self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_write_ha_state() + self.schedule_update_ha_state() def turn_on(self, **kwargs: Any) -> None: """Turn the light on for 30 seconds.""" diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 0ed24f45cbd..356eb1c2b9b 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from ring_doorbell.generic import RingGeneric + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -18,13 +20,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DOMAIN, - RING_DEVICES, - RING_HEALTH_COORDINATOR, - RING_HISTORY_COORDINATOR, -) -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity async def async_setup_entry( @@ -34,9 +32,12 @@ async def async_setup_entry( ) -> None: """Set up a sensor for a Ring device.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] entities = [ - description.cls(config_entry.entry_id, device, description) + description.cls(device, devices_coordinator, description) for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams") for description in SENSOR_TYPES if device_type in description.category @@ -47,19 +48,19 @@ async def async_setup_entry( async_add_entities(entities) -class RingSensor(RingEntityMixin, SensorEntity): +class RingSensor(RingEntity, SensorEntity): """A sensor implementation for Ring device.""" entity_description: RingSensorEntityDescription def __init__( self, - config_entry_id, - device, + device: RingGeneric, + coordinator: RingDataCoordinator, description: RingSensorEntityDescription, ) -> None: """Initialize a sensor for Ring device.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self.entity_description = description self._attr_unique_id = f"{device.id}-{description.key}" @@ -80,27 +81,6 @@ class HealthDataRingSensor(RingSensor): # These sensors are data hungry and not useful. Disable by default. _attr_entity_registry_enabled_default = False - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HEALTH_COORDINATOR].async_track_device( - self._device, self._health_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HEALTH_COORDINATOR].async_untrack_device( - self._device, self._health_update_callback - ) - - @callback - def _health_update_callback(self, _health_data): - """Call update method.""" - self.async_write_ha_state() - @property def native_value(self): """Return the state of the sensor.""" @@ -117,26 +97,10 @@ class HistoryRingSensor(RingSensor): _latest_event: dict[str, Any] | None = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - await self.ring_objects[RING_HISTORY_COORDINATOR].async_track_device( - self._device, self._history_update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect callbacks.""" - await super().async_will_remove_from_hass() - - self.ring_objects[RING_HISTORY_COORDINATOR].async_untrack_device( - self._device, self._history_update_callback - ) - @callback - def _history_update_callback(self, history_data): + def _handle_coordinator_update(self): """Call update method.""" - if not history_data: + if not (history_data := self._get_coordinator_history()): return kind = self.entity_description.kind @@ -153,7 +117,7 @@ class HistoryRingSensor(RingSensor): return self._latest_event = found - self.async_write_ha_state() + super()._handle_coordinator_update() @property def native_value(self): diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 7daf7bd69ca..0844f650e57 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -3,14 +3,16 @@ import logging from typing import Any from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING +from ring_doorbell.generic import RingGeneric from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -22,24 +24,27 @@ async def async_setup_entry( ) -> None: """Create the sirens for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] sirens = [] for device in devices["chimes"]: - sirens.append(RingChimeSiren(config_entry, device)) + sirens.append(RingChimeSiren(device, coordinator)) async_add_entities(sirens) -class RingChimeSiren(RingEntityMixin, SirenEntity): +class RingChimeSiren(RingEntity, SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" _attr_available_tones = CHIME_TEST_SOUND_KINDS _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES _attr_translation_key = "siren" - def __init__(self, config_entry: ConfigEntry, device) -> None: + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize a Ring Chime siren.""" - super().__init__(config_entry.entry_id, device) + super().__init__(device, coordinator) # Entity class attributes self._attr_unique_id = f"{self._device.id}-siren" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 074dfee9bd6..1f06f06e32e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -4,6 +4,8 @@ import logging from typing import Any import requests +from ring_doorbell import RingStickUpCam +from ring_doorbell.generic import RingGeneric from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -11,8 +13,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN, RING_DEVICES -from .entity import RingEntityMixin +from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR +from .coordinator import RingDataCoordinator +from .entity import RingEntity _LOGGER = logging.getLogger(__name__) @@ -34,21 +37,26 @@ async def async_setup_entry( ) -> None: """Create the switches for the Ring devices.""" devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES] + coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][ + RING_DEVICES_COORDINATOR + ] switches = [] for device in devices["stickup_cams"]: if device.has_capability("siren"): - switches.append(SirenSwitch(config_entry.entry_id, device)) + switches.append(SirenSwitch(device, coordinator)) async_add_entities(switches) -class BaseRingSwitch(RingEntityMixin, SwitchEntity): +class BaseRingSwitch(RingEntity, SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" - def __init__(self, config_entry_id, device, device_type): + def __init__( + self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str + ) -> None: """Initialize the switch.""" - super().__init__(config_entry_id, device) + super().__init__(device, coordinator) self._device_type = device_type self._attr_unique_id = f"{self._device.id}-{self._device_type}" @@ -59,20 +67,23 @@ class SirenSwitch(BaseRingSwitch): _attr_translation_key = "siren" _attr_icon = SIREN_ICON - def __init__(self, config_entry_id, device): + def __init__(self, device: RingGeneric, coordinator: RingDataCoordinator) -> None: """Initialize the switch for a device with a siren.""" - super().__init__(config_entry_id, device, "siren") + super().__init__(device, coordinator, "siren") self._no_updates_until = dt_util.utcnow() self._attr_is_on = device.siren > 0 @callback - def _update_callback(self): + def _handle_coordinator_update(self): """Call update method.""" if self._no_updates_until > dt_util.utcnow(): return - self._attr_is_on = self._device.siren > 0 - self.async_write_ha_state() + if (device := self._get_coordinator_device()) and isinstance( + device, RingStickUpCam + ): + self._attr_is_on = device.siren > 0 + super()._handle_coordinator_update() def _set_switch(self, new_state): """Update switch state, and causes Home Assistant to correctly update.""" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 6ad79623a12..8d169002e38 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -60,6 +60,49 @@ async def test_auth_failed_on_setup( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + ("error_type", "log_msg"), + [ + ( + RingTimeout, + "Timeout communicating with API: ", + ), + ( + RingError, + "Error communicating with API: ", + ), + ], + ids=["timeout-error", "other-error"], +) +async def test_error_on_setup( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + mock_config_entry: MockConfigEntry, + caplog, + error_type, + log_msg, +) -> None: + """Test auth failure on setup entry.""" + mock_config_entry.add_to_hass(hass) + with patch( + "ring_doorbell.Ring.update_data", + side_effect=error_type, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert [ + record.message + for record in caplog.records + if record.levelname == "DEBUG" + and record.name == "homeassistant.config_entries" + and log_msg in record.message + and DOMAIN in record.message + ] + + async def test_auth_failure_on_global_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker, @@ -78,8 +121,11 @@ async def test_auth_failure_on_global_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid, need to re-authenticate" in [ - record.message for record in caplog.records if record.levelname == "WARNING" + assert "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -91,7 +137,7 @@ async def test_auth_failure_on_device_update( mock_config_entry: MockConfigEntry, caplog, ) -> None: - """Test authentication failure on global data update.""" + """Test authentication failure on device data update.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -103,8 +149,11 @@ async def test_auth_failure_on_device_update( async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() - assert "Ring access token is no longer valid, need to re-authenticate" in [ - record.message for record in caplog.records if record.levelname == "WARNING" + assert "Authentication failed while fetching devices data: " in [ + record.message + for record in caplog.records + if record.levelname == "ERROR" + and record.name == "homeassistant.components.ring.coordinator" ] assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @@ -115,11 +164,11 @@ async def test_auth_failure_on_device_update( [ ( RingTimeout, - "Time out fetching Ring device data", + "Error fetching devices data: Timeout communicating with API: ", ), ( RingError, - "Error fetching Ring device data: ", + "Error fetching devices data: Error communicating with API: ", ), ], ids=["timeout-error", "other-error"], @@ -145,7 +194,7 @@ async def test_error_on_global_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] @@ -156,11 +205,11 @@ async def test_error_on_global_update( [ ( RingTimeout, - "Time out fetching Ring history data for device aacdef123", + "Error fetching devices data: Timeout communicating with API for device Front: ", ), ( RingError, - "Error fetching Ring history data for device aacdef123: ", + "Error fetching devices data: Error communicating with API for device Front: ", ), ], ids=["timeout-error", "other-error"], @@ -186,6 +235,6 @@ async def test_error_on_device_update( await hass.async_block_till_done() assert log_msg in [ - record.message for record in caplog.records if record.levelname == "WARNING" + record.message for record in caplog.records if record.levelname == "ERROR" ] assert mock_config_entry.entry_id in hass.data[DOMAIN] diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 468b4f0d0ec..b856a2f850c 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,10 @@ """The tests for the Ring switch platform.""" import requests_mock -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .common import setup_platform @@ -84,7 +85,13 @@ async def test_updates_work( text=load_fixture("devices_updated.json", "ring"), ) - await hass.services.async_call("ring", "update", {}, blocking=True) + await async_setup_component(hass, "homeassistant", {}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.front_siren"]}, + blocking=True, + ) await hass.async_block_till_done() From 0c83fd089750da2c092bb06d0fb490b74d3b12c6 Mon Sep 17 00:00:00 2001 From: Manuel Dipolt Date: Wed, 31 Jan 2024 10:48:44 +0100 Subject: [PATCH 1230/1544] Add romy vacuum integration (#93750) Co-authored-by: Erik Montnemery Co-authored-by: Robert Resch Co-authored-by: Allen Porter --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/romy/__init__.py | 42 ++++ homeassistant/components/romy/config_flow.py | 148 +++++++++++ homeassistant/components/romy/const.py | 11 + homeassistant/components/romy/coordinator.py | 22 ++ homeassistant/components/romy/manifest.json | 10 + homeassistant/components/romy/strings.json | 51 ++++ homeassistant/components/romy/vacuum.py | 116 +++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/romy/__init__.py | 1 + tests/components/romy/test_config_flow.py | 248 +++++++++++++++++++ 18 files changed, 683 insertions(+) create mode 100644 homeassistant/components/romy/__init__.py create mode 100644 homeassistant/components/romy/config_flow.py create mode 100644 homeassistant/components/romy/const.py create mode 100644 homeassistant/components/romy/coordinator.py create mode 100644 homeassistant/components/romy/manifest.json create mode 100644 homeassistant/components/romy/strings.json create mode 100644 homeassistant/components/romy/vacuum.py create mode 100644 tests/components/romy/__init__.py create mode 100644 tests/components/romy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 184617a2882..829b0fd9391 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1103,6 +1103,9 @@ omit = homeassistant/components/ripple/sensor.py homeassistant/components/roborock/coordinator.py homeassistant/components/rocketchat/notify.py + homeassistant/components/romy/__init__.py + homeassistant/components/romy/coordinator.py + homeassistant/components/romy/vacuum.py homeassistant/components/roomba/__init__.py homeassistant/components/roomba/binary_sensor.py homeassistant/components/roomba/braava.py diff --git a/.strict-typing b/.strict-typing index d725a2920a4..bd92da2fc50 100644 --- a/.strict-typing +++ b/.strict-typing @@ -361,6 +361,7 @@ homeassistant.components.rhasspy.* homeassistant.components.ridwell.* homeassistant.components.rituals_perfume_genie.* homeassistant.components.roku.* +homeassistant.components.romy.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.rtsp_to_webrtc.* diff --git a/CODEOWNERS b/CODEOWNERS index 1fb5f2d7e55..34e77892a95 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1122,6 +1122,8 @@ build.json @home-assistant/supervisor /tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington +/homeassistant/components/romy/ @xeniter +/tests/components/romy/ @xeniter /homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /homeassistant/components/roon/ @pavoni diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py new file mode 100644 index 00000000000..352f5f3715a --- /dev/null +++ b/homeassistant/components/romy/__init__.py @@ -0,0 +1,42 @@ +"""ROMY Integration.""" + +import romy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import RomyVacuumCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Initialize the ROMY platform via config entry.""" + + new_romy = await romy.create_romy( + config_entry.data[CONF_HOST], config_entry.data.get(CONF_PASSWORD, "") + ) + + coordinator = RomyVacuumCoordinator(hass, new_romy) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle options update.""" + LOGGER.debug("update_listener") + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py new file mode 100644 index 00000000000..6bc96c9878c --- /dev/null +++ b/homeassistant/components/romy/config_flow.py @@ -0,0 +1,148 @@ +"""Config flow for ROMY integration.""" +from __future__ import annotations + +import romy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER + + +class RomyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for ROMY.""" + + VERSION = 1 + + def __init__(self) -> None: + """Handle a config flow for ROMY.""" + self.host: str = "" + self.password: str = "" + self.robot_name_given_by_user: str = "" + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + + if user_input: + self.host = user_input[CONF_HOST] + + new_romy = await romy.create_romy(self.host, "") + + if not new_romy.is_initialized: + errors[CONF_HOST] = "cannot_connect" + else: + await self.async_set_unique_id(new_romy.unique_id) + self._abort_if_unique_id_configured() + + self.robot_name_given_by_user = new_romy.user_name + + if not new_romy.is_unlocked: + return await self.async_step_password() + return await self._async_step_finish_config() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + }, + ), + errors=errors, + ) + + async def async_step_password( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Unlock the robots local http interface with password.""" + errors: dict[str, str] = {} + + if user_input: + self.password = user_input[CONF_PASSWORD] + new_romy = await romy.create_romy(self.host, self.password) + + if not new_romy.is_initialized: + errors[CONF_PASSWORD] = "cannot_connect" + elif not new_romy.is_unlocked: + errors[CONF_PASSWORD] = "invalid_auth" + + if not errors: + return await self._async_step_finish_config() + + return self.async_show_form( + step_id="password", + data_schema=vol.Schema( + {vol.Required(CONF_PASSWORD): vol.All(cv.string, vol.Length(8))}, + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + LOGGER.debug("Zeroconf discovery_info: %s", discovery_info) + + # connect and gather information from your ROMY + self.host = discovery_info.host + LOGGER.debug("ZeroConf Host: %s", self.host) + + new_discovered_romy = await romy.create_romy(self.host, "") + + self.robot_name_given_by_user = new_discovered_romy.user_name + LOGGER.debug("ZeroConf Name: %s", self.robot_name_given_by_user) + + # get unique id and stop discovery if robot is already added + unique_id = new_discovered_romy.unique_id + LOGGER.debug("ZeroConf Unique_id: %s", unique_id) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.context.update( + { + "title_placeholders": { + "name": f"{self.robot_name_given_by_user} ({self.host} / {unique_id})" + }, + "configuration_url": f"http://{self.host}:{new_discovered_romy.port}", + } + ) + + # if robot got already unlocked with password add it directly + if not new_discovered_romy.is_initialized: + return self.async_abort(reason="cannot_connect") + + if new_discovered_romy.is_unlocked: + return await self.async_step_zeroconf_confirm() + + return await self.async_step_password() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": self.robot_name_given_by_user, + "host": self.host, + }, + ) + return await self._async_step_finish_config() + + async def _async_step_finish_config(self) -> FlowResult: + """Finish the configuration setup.""" + return self.async_create_entry( + title=self.robot_name_given_by_user, + data={ + CONF_HOST: self.host, + CONF_PASSWORD: self.password, + }, + ) diff --git a/homeassistant/components/romy/const.py b/homeassistant/components/romy/const.py new file mode 100644 index 00000000000..5d42380902b --- /dev/null +++ b/homeassistant/components/romy/const.py @@ -0,0 +1,11 @@ +"""Constants for the ROMY integration.""" + +from datetime import timedelta +import logging + +from homeassistant.const import Platform + +DOMAIN = "romy" +PLATFORMS = [Platform.VACUUM] +UPDATE_INTERVAL = timedelta(seconds=5) +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py new file mode 100644 index 00000000000..5868eae70e2 --- /dev/null +++ b/homeassistant/components/romy/coordinator.py @@ -0,0 +1,22 @@ +"""ROMY coordinator.""" + +from romy import RomyRobot + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + + +class RomyVacuumCoordinator(DataUpdateCoordinator[None]): + """ROMY Vacuum Coordinator.""" + + def __init__(self, hass: HomeAssistant, romy: RomyRobot) -> None: + """Initialize.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + self.hass = hass + self.romy = romy + + async def _async_update_data(self) -> None: + """Update ROMY Vacuum Cleaner data.""" + await self.romy.async_update() diff --git a/homeassistant/components/romy/manifest.json b/homeassistant/components/romy/manifest.json new file mode 100644 index 00000000000..1257c2d1d60 --- /dev/null +++ b/homeassistant/components/romy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "romy", + "name": "ROMY Vacuum Cleaner", + "codeowners": ["@xeniter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/romy", + "iot_class": "local_polling", + "requirements": ["romy==0.0.7"], + "zeroconf": ["_aicu-http._tcp.local."] +} diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json new file mode 100644 index 00000000000..26dc60a2e84 --- /dev/null +++ b/homeassistant/components/romy/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "flow_title": "{name}", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "password": { + "title": "Password required", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "(8 characters, see QR Code under the dustbin)." + } + }, + "zeroconf_confirm": { + "description": "Do you want to add ROMY Vacuum Cleaner {name} to Home Assistant?" + } + } + }, + "entity": { + "vacuum": { + "romy": { + "state_attributes": { + "fan_speed": { + "state": { + "default": "Default", + "normal": "Normal", + "silent": "Silent", + "intensive": "Intensive", + "super_silent": "Super silent", + "high": "High", + "auto": "Auto" + } + } + } + } + } + } +} diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py new file mode 100644 index 00000000000..0670c2a49f6 --- /dev/null +++ b/homeassistant/components/romy/vacuum.py @@ -0,0 +1,116 @@ +"""Support for Wi-Fi enabled ROMY vacuum cleaner robots. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/vacuum.romy/. +""" + + +from typing import Any + +from romy import RomyRobot + +from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import RomyVacuumCoordinator + +ICON = "mdi:robot-vacuum" + +FAN_SPEED_NONE = "default" +FAN_SPEED_NORMAL = "normal" +FAN_SPEED_SILENT = "silent" +FAN_SPEED_INTENSIVE = "intensive" +FAN_SPEED_SUPER_SILENT = "super_silent" +FAN_SPEED_HIGH = "high" +FAN_SPEED_AUTO = "auto" + +FAN_SPEEDS: list[str] = [ + FAN_SPEED_NONE, + FAN_SPEED_NORMAL, + FAN_SPEED_SILENT, + FAN_SPEED_INTENSIVE, + FAN_SPEED_SUPER_SILENT, + FAN_SPEED_HIGH, + FAN_SPEED_AUTO, +] + +# Commonly supported features +SUPPORT_ROMY_ROBOT = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.FAN_SPEED +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up ROMY vacuum cleaner.""" + + coordinator: RomyVacuumCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([RomyVacuumEntity(coordinator, coordinator.romy)], True) + + +class RomyVacuumEntity(CoordinatorEntity[RomyVacuumCoordinator], StateVacuumEntity): + """Representation of a ROMY vacuum cleaner robot.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = SUPPORT_ROMY_ROBOT + _attr_fan_speed_list = FAN_SPEEDS + _attr_icon = ICON + + def __init__( + self, + coordinator: RomyVacuumCoordinator, + romy: RomyRobot, + ) -> None: + """Initialize the ROMY Robot.""" + super().__init__(coordinator) + self.romy = romy + self._attr_unique_id = self.romy.unique_id + self._device_info = DeviceInfo( + identifiers={(DOMAIN, romy.unique_id)}, + manufacturer="ROMY", + name=romy.name, + model=romy.model, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] + self._attr_battery_level = self.romy.battery_level + self._attr_state = self.romy.status + + self.async_write_ha_state() + + async def async_start(self, **kwargs: Any) -> None: + """Turn the vacuum on.""" + LOGGER.debug("async_start") + await self.romy.async_clean_start_or_continue() + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + LOGGER.debug("async_stop") + await self.romy.async_stop() + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return vacuum back to base.""" + LOGGER.debug("async_return_to_base") + await self.romy.async_return_to_base() + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + LOGGER.debug("async_set_fan_speed to %s", fan_speed) + await self.romy.async_set_fan_speed(FAN_SPEEDS.index(fan_speed)) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7c3e8a78940..edaba2250e8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -429,6 +429,7 @@ FLOWS = { "rituals_perfume_genie", "roborock", "roku", + "romy", "roomba", "roon", "rpi_power", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index aa2ba3eae9c..1046536c660 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4974,6 +4974,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "romy": { + "name": "ROMY Vacuum Cleaner", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "roomba": { "name": "iRobot Roomba and Braava", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 36b6aac8a7f..a66efa6dded 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -248,6 +248,11 @@ ZEROCONF = { "domain": "volumio", }, ], + "_aicu-http._tcp.local.": [ + { + "domain": "romy", + }, + ], "_airplay._tcp.local.": [ { "domain": "apple_tv", diff --git a/mypy.ini b/mypy.ini index 7fb00178d1a..6bafe51e1a0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3371,6 +3371,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.romy.*] +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.rpi_power.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 11ad645c36f..395f1caf5da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2439,6 +2439,9 @@ rocketchat-API==0.6.1 # homeassistant.components.roku rokuecp==0.18.1 +# homeassistant.components.romy +romy==0.0.7 + # homeassistant.components.roomba roombapy==1.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a107f1d2a88..c456c228fea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1858,6 +1858,9 @@ ring-doorbell[listen]==0.8.5 # homeassistant.components.roku rokuecp==0.18.1 +# homeassistant.components.romy +romy==0.0.7 + # homeassistant.components.roomba roombapy==1.6.10 diff --git a/tests/components/romy/__init__.py b/tests/components/romy/__init__.py new file mode 100644 index 00000000000..0e2e035f0f4 --- /dev/null +++ b/tests/components/romy/__init__.py @@ -0,0 +1 @@ +"""Tests for the ROMY integration.""" diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py new file mode 100644 index 00000000000..a24a3f46bfa --- /dev/null +++ b/tests/components/romy/test_config_flow.py @@ -0,0 +1,248 @@ +"""Test the ROMY config flow.""" +from ipaddress import ip_address +from unittest.mock import Mock, PropertyMock, patch + +from romy import RomyRobot + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import zeroconf +from homeassistant.components.romy.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + + +def _create_mocked_romy( + is_initialized, + is_unlocked, + name="Agon", + user_name="MyROMY", + unique_id="aicu-aicgsbksisfapcjqmqjq", + model="005:000:000:000:005", + port=8080, +): + mocked_romy = Mock(spec_set=RomyRobot) + type(mocked_romy).is_initialized = PropertyMock(return_value=is_initialized) + type(mocked_romy).is_unlocked = PropertyMock(return_value=is_unlocked) + type(mocked_romy).name = PropertyMock(return_value=name) + type(mocked_romy).user_name = PropertyMock(return_value=user_name) + type(mocked_romy).unique_id = PropertyMock(return_value=unique_id) + type(mocked_romy).port = PropertyMock(return_value=port) + type(mocked_romy).model = PropertyMock(return_value=model) + + return mocked_romy + + +CONFIG = {CONF_HOST: "1.2.3.4", CONF_PASSWORD: "12345678"} + +INPUT_CONFIG_HOST = { + CONF_HOST: CONFIG[CONF_HOST], +} + + +async def test_show_user_form_robot_is_offline_and_locked(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + # Robot not reachable + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + assert result1["errors"].get("host") == "cannot_connect" + assert result1["step_id"] == "user" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is locked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"host": "1.2.3.4"} + ) + + assert result2["step_id"] == "password" + assert result2["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is initialized and unlocked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result3 + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_show_user_form_robot_unlock_with_password(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"password": "12345678"} + ) + + assert result2["errors"] == {"password": "invalid_auth"} + assert result2["step_id"] == "password" + assert result2["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"password": "12345678"} + ) + + assert result3["errors"] == {"password": "cannot_connect"} + assert result3["step_id"] == "password" + assert result3["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result4 + assert result4["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None: + """Test that the user set up form with config.""" + + # Robot not reachable + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=INPUT_CONFIG_HOST, + ) + + assert result1["errors"].get("host") == "cannot_connect" + assert result1["step_id"] == "user" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Robot is locked + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"host": "1.2.3.4"} + ) + + assert "errors" not in result2 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], + port=8080, + hostname="aicu-aicgsbksisfapcjqmqjq.local", + type="mock_type", + name="myROMY", + properties={zeroconf.ATTR_PROPERTIES_ID: "aicu-aicgsbksisfapcjqmqjqZERO"}, +) + + +async def test_zero_conf_locked_interface_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered locked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, False), + ): + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result1["step_id"] == "password" + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {"password": "12345678"} + ) + + assert "errors" not in result2 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_zero_conf_uninitialized_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered locked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(False, False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["reason"] == "cannot_connect" + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + +async def test_zero_conf_unlocked_interface_robot(hass: HomeAssistant) -> None: + """Test zerconf which discovered already unlocked robot.""" + + with patch( + "homeassistant.components.romy.config_flow.romy.create_romy", + return_value=_create_mocked_romy(True, True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "1.2.3.4"}, + ) + + assert result["data"] + assert result["data"][CONF_HOST] == "1.2.3.4" + + assert result["result"] + assert result["result"].unique_id == "aicu-aicgsbksisfapcjqmqjq" + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY From 60fbb8b698d51ec0dc4b20e65d865030aeaddba7 Mon Sep 17 00:00:00 2001 From: Mandar Patil Date: Wed, 31 Jan 2024 02:00:04 -0800 Subject: [PATCH 1231/1544] Add session energy sensor for Tesla Wall Connector (#102635) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tesla_wall_connector/sensor.py | 7 +++++++ homeassistant/components/tesla_wall_connector/strings.json | 3 +++ tests/components/tesla_wall_connector/test_sensor.py | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index d1c108aaf95..6dcc2669789 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -125,6 +125,13 @@ WALL_CONNECTOR_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + WallConnectorSensorDescription( + key="session_energy_wh", + translation_key="session_energy_wh", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].session_energy_wh, + state_class=SensorStateClass.MEASUREMENT, + ), WallConnectorSensorDescription( key="energy_kWh", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 97bac988d16..88ff6e6791d 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -59,6 +59,9 @@ }, "voltage_c_v": { "name": "Phase C voltage" + }, + "session_energy_wh": { + "name": "Session energy" } } } diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 06cd5a8ef83..3279ddad12e 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -44,6 +44,9 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_c_voltage", "232.1", "230" ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_session_energy", "1234.56", "112.2" + ), ] mock_vitals_first_update = get_vitals_mock() @@ -57,6 +60,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_first_update.currentA_a = 10 mock_vitals_first_update.currentB_a = 11.1 mock_vitals_first_update.currentC_a = 12 + mock_vitals_first_update.session_energy_wh = 1234.56 mock_vitals_second_update = get_vitals_mock() mock_vitals_second_update.evse_state = 2 @@ -69,6 +73,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.currentA_a = 7 mock_vitals_second_update.currentB_a = 8 mock_vitals_second_update.currentC_a = 9 + mock_vitals_second_update.session_energy_wh = 112.2 lifetime_mock_first_update = get_lifetime_mock() lifetime_mock_first_update.energy_wh = 988022 From a3352ce45793171ccb5e659bbf9ed9fd09c2c1bb Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Wed, 31 Jan 2024 05:22:25 -0500 Subject: [PATCH 1232/1544] Minor fixes to A. O. Smith integration (#107421) --- .../components/aosmith/water_heater.py | 37 ++++++++---- tests/components/aosmith/conftest.py | 56 +++++++++++++------ .../aosmith/snapshots/test_water_heater.ambr | 22 +++++++- tests/components/aosmith/test_init.py | 9 ++- tests/components/aosmith/test_water_heater.py | 41 +++++++++++++- 5 files changed, 131 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index 9522d06e062..dceba13ba34 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -15,6 +15,7 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AOSmithData @@ -35,13 +36,13 @@ MODE_AOSMITH_TO_HA = { AOSmithOperationMode.VACATION: STATE_OFF, } -# Operation mode to use when exiting away mode -DEFAULT_OPERATION_MODE = AOSmithOperationMode.HYBRID - -DEFAULT_SUPPORT_FLAGS = ( - WaterHeaterEntityFeature.TARGET_TEMPERATURE - | WaterHeaterEntityFeature.OPERATION_MODE -) +# Priority list for operation mode to use when exiting away mode +# Will use the first mode that is supported by the device +DEFAULT_OPERATION_MODE_PRIORITY = [ + AOSmithOperationMode.HYBRID, + AOSmithOperationMode.HEAT_PUMP, + AOSmithOperationMode.ELECTRIC, +] async def async_setup_entry( @@ -93,10 +94,16 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): for supported_mode in self.device.supported_modes ) - if supports_vacation_mode: - return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + support_flags = WaterHeaterEntityFeature.TARGET_TEMPERATURE - return DEFAULT_SUPPORT_FLAGS + # Operation mode only supported if there is more than one mode + if len(self.operation_list) > 1: + support_flags |= WaterHeaterEntityFeature.OPERATION_MODE + + if supports_vacation_mode: + support_flags |= WaterHeaterEntityFeature.AWAY_MODE + + return support_flags @property def target_temperature(self) -> float | None: @@ -120,6 +127,9 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" + if operation_mode not in self.operation_list: + raise HomeAssistantError("Operation mode not supported") + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) if aosmith_mode is not None: await self.client.update_mode(self.junction_id, aosmith_mode) @@ -142,6 +152,9 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + supported_aosmith_modes = [x.mode for x in self.device.supported_modes] - await self.coordinator.async_request_refresh() + for mode in DEFAULT_OPERATION_MODE_PRIORITY: + if mode in supported_aosmith_modes: + await self.client.update_mode(self.junction_id, mode) + break diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 157b58cb902..fe35f6b337d 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -29,20 +29,10 @@ FIXTURE_USER_INPUT = { def build_device_fixture( - mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool + heat_pump: bool, mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool ): """Build a fixture for a device.""" supported_modes: list[SupportedOperationModeInfo] = [ - SupportedOperationModeInfo( - mode=OperationMode.HYBRID, - original_name="HYBRID", - has_day_selection=False, - ), - SupportedOperationModeInfo( - mode=OperationMode.HEAT_PUMP, - original_name="HEAT_PUMP", - has_day_selection=False, - ), SupportedOperationModeInfo( mode=OperationMode.ELECTRIC, original_name="ELECTRIC", @@ -50,6 +40,22 @@ def build_device_fixture( ), ] + if heat_pump: + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.HYBRID, + original_name="HYBRID", + has_day_selection=False, + ) + ) + supported_modes.append( + SupportedOperationModeInfo( + mode=OperationMode.HEAT_PUMP, + original_name="HEAT_PUMP", + has_day_selection=False, + ) + ) + if has_vacation_mode: supported_modes.append( SupportedOperationModeInfo( @@ -59,10 +65,18 @@ def build_device_fixture( ) ) + device_type = ( + DeviceType.NEXT_GEN_HEAT_PUMP if heat_pump else DeviceType.RE3_CONNECTED + ) + + current_mode = OperationMode.HEAT_PUMP if heat_pump else OperationMode.ELECTRIC + + model = "HPTS-50 200 202172000" if heat_pump else "EE12-50H55DVF 100,3806368" + return Device( brand="aosmith", - model="HPTS-50 200 202172000", - device_type=DeviceType.NEXT_GEN_HEAT_PUMP, + model=model, + device_type=device_type, dsn="dsn", junction_id="junctionId", name="My water heater", @@ -72,7 +86,7 @@ def build_device_fixture( status=DeviceStatus( firmware_version="2.14", is_online=True, - current_mode=OperationMode.HEAT_PUMP, + current_mode=current_mode, mode_change_pending=mode_pending, temperature_setpoint=130, temperature_setpoint_pending=setpoint_pending, @@ -121,6 +135,12 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry +@pytest.fixture +def get_devices_fixture_heat_pump() -> bool: + """Return whether the device in the get_devices fixture should be a heat pump water heater.""" + return True + + @pytest.fixture def get_devices_fixture_mode_pending() -> bool: """Return whether to set mode_pending in the get_devices fixture.""" @@ -141,6 +161,7 @@ def get_devices_fixture_has_vacation_mode() -> bool: @pytest.fixture async def mock_client( + get_devices_fixture_heat_pump: bool, get_devices_fixture_mode_pending: bool, get_devices_fixture_setpoint_pending: bool, get_devices_fixture_has_vacation_mode: bool, @@ -148,9 +169,10 @@ async def mock_client( """Return a mocked client.""" get_devices_fixture = [ build_device_fixture( - get_devices_fixture_mode_pending, - get_devices_fixture_setpoint_pending, - get_devices_fixture_has_vacation_mode, + heat_pump=get_devices_fixture_heat_pump, + mode_pending=get_devices_fixture_mode_pending, + setpoint_pending=get_devices_fixture_setpoint_pending, + has_vacation_mode=get_devices_fixture_has_vacation_mode, ) ] get_all_device_info_fixture = load_json_object_fixture( diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr index 2293a6c7b65..a4be3d107f3 100644 --- a/tests/components/aosmith/snapshots/test_water_heater.ambr +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -8,9 +8,9 @@ 'max_temp': 130, 'min_temp': 95, 'operation_list': list([ + 'electric', 'eco', 'heat_pump', - 'electric', ]), 'operation_mode': 'heat_pump', 'supported_features': , @@ -25,3 +25,23 @@ 'state': 'heat_pump', }) # --- +# name: test_state_non_heat_pump[False] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'electric', + }) +# --- diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py index 7ff75ce1105..7e081686790 100644 --- a/tests/components/aosmith/test_init.py +++ b/tests/components/aosmith/test_init.py @@ -50,7 +50,14 @@ async def test_config_entry_not_ready_get_energy_use_data_error( """Test the config entry not ready when get_energy_use_data fails.""" mock_config_entry.add_to_hass(hass) - get_devices_fixture = [build_device_fixture(False, False, True)] + get_devices_fixture = [ + build_device_fixture( + heat_pump=True, + mode_pending=False, + setpoint_pending=False, + has_vacation_mode=True, + ) + ] with patch( "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py index a66b5db35e6..a256f720c0a 100644 --- a/tests/components/aosmith/test_water_heater.py +++ b/tests/components/aosmith/test_water_heater.py @@ -25,6 +25,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -53,6 +54,20 @@ async def test_state( assert state == snapshot +@pytest.mark.parametrize( + ("get_devices_fixture_heat_pump"), + [ + False, + ], +) +async def test_state_non_heat_pump( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity for a non heat pump device.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + @pytest.mark.parametrize( ("get_devices_fixture_has_vacation_mode"), [False], @@ -98,6 +113,24 @@ async def test_set_operation_mode( mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) +async def test_unsupported_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the operation mode with an unsupported mode.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: "unsupported_mode", + }, + blocking=True, + ) + + async def test_set_temperature( hass: HomeAssistant, mock_client: MagicMock, @@ -115,10 +148,12 @@ async def test_set_temperature( @pytest.mark.parametrize( - ("hass_away_mode", "aosmith_mode"), + ("get_devices_fixture_heat_pump", "hass_away_mode", "aosmith_mode"), [ - (True, OperationMode.VACATION), - (False, OperationMode.HYBRID), + (True, True, OperationMode.VACATION), + (True, False, OperationMode.HYBRID), + (False, True, OperationMode.VACATION), + (False, False, OperationMode.ELECTRIC), ], ) async def test_away_mode( From c587c699158d36dbf0b61690187e4463b612d2c7 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 11:40:23 +0100 Subject: [PATCH 1233/1544] Migrate QNAP to has entity name (#107232) * Migrate QNAP to has entity name * Update homeassistant/components/qnap/strings.json Co-authored-by: disforw * Apply suggestions from code review Co-authored-by: disforw --------- Co-authored-by: disforw --- homeassistant/components/qnap/sensor.py | 50 ++++++++-------------- homeassistant/components/qnap/strings.json | 49 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index e84124a96bd..f0d34f7c697 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -40,12 +40,12 @@ ATTR_VOLUME_SIZE = "Volume Size" _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", - name="Status", + translation_key="status", icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="system_temp", - name="System Temperature", + translation_key="system_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, icon="mdi:thermometer", @@ -55,7 +55,7 @@ _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="cpu_temp", - name="CPU Temperature", + translation_key="cpu_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, icon="mdi:checkbox-marked-circle-outline", @@ -64,7 +64,7 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="cpu_usage", - name="CPU Usage", + translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, @@ -74,7 +74,7 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="memory_free", - name="Memory Available", + translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -85,7 +85,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="memory_used", - name="Memory Used", + translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", @@ -96,7 +96,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="memory_percent_used", - name="Memory Usage", + translation_key="memory_percent_used", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, @@ -106,12 +106,12 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_link_status", - name="Network Link", + translation_key="network_link_status", icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="network_tx", - name="Network Up", + translation_key="network_tx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -122,7 +122,7 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="network_rx", - name="Network Down", + translation_key="network_rx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -135,13 +135,13 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="drive_smart_status", - name="SMART Status", + translation_key="drive_smart_status", icon="mdi:checkbox-marked-circle-outline", entity_registry_enabled_default=False, ), SensorEntityDescription( key="drive_temp", - name="Temperature", + translation_key="drive_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, icon="mdi:thermometer", @@ -152,7 +152,7 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="volume_size_used", - name="Used Space", + translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -163,7 +163,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="volume_size_free", - name="Free Space", + translation_key="volume_size_free", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", @@ -174,7 +174,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="volume_percentage_used", - name="Volume Used", + translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", state_class=SensorStateClass.MEASUREMENT, @@ -259,6 +259,8 @@ async def async_setup_entry( class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): """Base class for a QNAP sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: QnapCoordinator, @@ -274,6 +276,7 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): self._attr_unique_id = f"{unique_id}_{description.key}" if monitor_device: self._attr_unique_id = f"{self._attr_unique_id}_{monitor_device}" + self._attr_translation_placeholders = {"monitor_device": monitor_device} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, serial_number=unique_id, @@ -283,13 +286,6 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): manufacturer="QNAP", ) - @property - def name(self): - """Return the name of the sensor, if any.""" - if self.monitor_device is not None: - return f"{self.device_name} {self.entity_description.name} ({self.monitor_device})" - return f"{self.device_name} {self.entity_description.name}" - class QNAPCPUSensor(QNAPSensor): """A QNAP sensor that monitors CPU stats.""" @@ -405,16 +401,6 @@ class QNAPDriveSensor(QNAPSensor): if self.entity_description.key == "drive_temp": return int(data["temp_c"]) if data["temp_c"] is not None else 0 - @property - def name(self): - """Return the name of the sensor, if any.""" - server_name = self.coordinator.data["system_stats"]["system"]["name"] - - return ( - f"{server_name} {self.entity_description.name} (Drive" - f" {self.monitor_device})" - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index d535b9f0e87..ddceb487e2d 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -22,5 +22,54 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "system_temp": { + "name": "System temperature" + }, + "cpu_temp": { + "name": "CPU temperature" + }, + "cpu_usage": { + "name": "CPU usage" + }, + "memory_free": { + "name": "Memory available" + }, + "memory_used": { + "name": "Memory used" + }, + "memory_percent_used": { + "name": "Memory usage" + }, + "network_link_status": { + "name": "{monitor_device} link" + }, + "network_tx": { + "name": "{monitor_device} upload" + }, + "network_rx": { + "name": "{monitor_device} download" + }, + "drive_smart_status": { + "name": "Drive {monitor_device} status" + }, + "drive_temp": { + "name": "Drive {monitor_device} temperature" + }, + "volume_size_used": { + "name": "Used space ({monitor_device})" + }, + "volume_size_free": { + "name": "Free space ({monitor_device})" + }, + "volume_percentage_used": { + "name": "Volume used ({monitor_device})" + } + } } } From 30c5baf5228eb5adb5fa2a65078c4775c27b5d67 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 31 Jan 2024 12:47:23 +0100 Subject: [PATCH 1234/1544] Add configflow to Proximity integration (#103894) * add config flow * fix tests * adjust and fix tests * fix tests * config_zones as fixture * add config flow tests * use coordinator.async_config_entry_first_refresh * use entry.entry_id for hass.data * fix doc string * remove unused unit_of_measurement string key * don't store friendly_name, just use self.name * abort on matching entiry * break out legacy setup into seperate function * make tracked entites required * move _asnyc_setup_legacy to module level * use zone name as config entry title * add entity_used_in helper * check entry source if imported * create repair issue for removed tracked entities * separate state change from registry change event handling * migrate unique ids after tracked entity renamed * use full words for the variable names * use defaultdict * add test * remove unnecessary if not in check * use unique_id of tracked entity * use the entity registry entry id * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/proximity/__init__.py | 179 ++++++--- .../components/proximity/config_flow.py | 133 +++++++ homeassistant/components/proximity/const.py | 1 + .../components/proximity/coordinator.py | 93 ++++- homeassistant/components/proximity/helpers.py | 11 + .../components/proximity/manifest.json | 1 + homeassistant/components/proximity/sensor.py | 92 +++-- .../components/proximity/strings.json | 40 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/proximity/conftest.py | 20 + .../components/proximity/test_config_flow.py | 187 +++++++++ tests/components/proximity/test_init.py | 366 +++++++++++++----- 13 files changed, 919 insertions(+), 207 deletions(-) create mode 100644 homeassistant/components/proximity/config_flow.py create mode 100644 homeassistant/components/proximity/helpers.py create mode 100644 tests/components/proximity/conftest.py create mode 100644 tests/components/proximity/test_config_flow.py diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index fabbcaec51a..3f28028d703 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,18 +5,20 @@ import logging import voluptuous as vol -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICES, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, + Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, + async_track_state_change, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,12 +29,14 @@ from .const import ( ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_PROXIMITY_ZONE, DEFAULT_TOLERANCE, DOMAIN, UNITS, ) from .coordinator import ProximityDataUpdateCoordinator +from .helpers import entity_used_in _LOGGER = logging.getLogger(__name__) @@ -49,63 +53,134 @@ ZONE_SCHEMA = vol.Schema( ) CONFIG_SCHEMA = vol.Schema( - {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, extra=vol.ALLOW_EXTRA + vol.All( + cv.deprecated(DOMAIN), + {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, + ), + extra=vol.ALLOW_EXTRA, ) +async def _async_setup_legacy( + hass: HomeAssistant, entry: ConfigEntry, coordinator: ProximityDataUpdateCoordinator +) -> None: + """Legacy proximity entity handling, can be removed in 2024.8.""" + friendly_name = entry.data[CONF_NAME] + proximity = Proximity(hass, friendly_name, coordinator) + await proximity.async_added_to_hass() + proximity.async_write_ha_state() + + if used_in := entity_used_in(hass, f"{DOMAIN}.{friendly_name}"): + async_create_issue( + hass, + DOMAIN, + f"deprecated_proximity_entity_{friendly_name}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_proximity_entity", + translation_placeholders={ + "entity": f"{DOMAIN}.{friendly_name}", + "used_in": "\n- ".join([f"`{x}`" for x in used_in]), + }, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" - hass.data.setdefault(DOMAIN, {}) - for friendly_name, proximity_config in config[DOMAIN].items(): - _LOGGER.debug("setup %s with config:%s", friendly_name, proximity_config) - - coordinator = ProximityDataUpdateCoordinator( - hass, friendly_name, proximity_config - ) - - async_track_state_change( - hass, - proximity_config[CONF_DEVICES], - coordinator.async_check_proximity_state_change, - ) - - await coordinator.async_refresh() - hass.data[DOMAIN][friendly_name] = coordinator - - proximity = Proximity(hass, friendly_name, coordinator) - await proximity.async_added_to_hass() - proximity.async_write_ha_state() - - await async_load_platform( - hass, - "sensor", - DOMAIN, - {CONF_NAME: friendly_name, **proximity_config}, - config, - ) - - # deprecate proximity entity - can be removed in 2024.8 - used_in = automations_with_entity(hass, f"{DOMAIN}.{friendly_name}") - used_in += scripts_with_entity(hass, f"{DOMAIN}.{friendly_name}") - if used_in: - async_create_issue( - hass, - DOMAIN, - f"deprecated_proximity_entity_{friendly_name}", - breaks_in_ha_version="2024.8.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_proximity_entity", - translation_placeholders={ - "entity": f"{DOMAIN}.{friendly_name}", - "used_in": "\n- ".join([f"`{x}`" for x in used_in]), - }, + if DOMAIN in config: + for friendly_name, proximity_config in config[DOMAIN].items(): + _LOGGER.debug("import %s with config:%s", friendly_name, proximity_config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: friendly_name, + CONF_ZONE: f"zone.{proximity_config[CONF_ZONE]}", + CONF_TRACKED_ENTITIES: proximity_config[CONF_DEVICES], + CONF_IGNORED_ZONES: [ + f"zone.{zone}" + for zone in proximity_config[CONF_IGNORED_ZONES] + ], + CONF_TOLERANCE: proximity_config[CONF_TOLERANCE], + CONF_UNIT_OF_MEASUREMENT: proximity_config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit + ), + }, + ) ) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Proximity", + }, + ) + return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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( + async_track_state_change( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_proximity_state_change, + ) + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_tracked_entity_change, + ) + ) + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + if entry.source == SOURCE_IMPORT: + await _async_setup_legacy(hass, entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +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 + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): """Representation of a Proximity.""" diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py new file mode 100644 index 00000000000..231a50c6c00 --- /dev/null +++ b/homeassistant/components/proximity/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for proximity.""" +from __future__ import annotations + +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_ZONE +from homeassistant.core import State, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, +) + +from .const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DEFAULT_PROXIMITY_ZONE, + DEFAULT_TOLERANCE, + DOMAIN, +) + +RESULT_SUCCESS = "success" + + +def _base_schema(user_input: dict[str, Any]) -> vol.Schema: + return { + vol.Required( + CONF_TRACKED_ENTITIES, default=user_input.get(CONF_TRACKED_ENTITIES, []) + ): EntitySelector( + EntitySelectorConfig( + domain=[DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN], multiple=True + ), + ), + vol.Optional( + CONF_IGNORED_ZONES, default=user_input.get(CONF_IGNORED_ZONES, []) + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN, multiple=True), + ), + vol.Required( + CONF_TOLERANCE, + default=user_input.get(CONF_TOLERANCE, DEFAULT_TOLERANCE), + ): NumberSelector( + NumberSelectorConfig(min=1, max=100, step=1), + ), + } + + +class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a proximity config flow.""" + + VERSION = 1 + + def _user_form_schema(self, user_input: dict[str, Any] | None = None) -> vol.Schema: + if user_input is None: + user_input = {} + return vol.Schema( + { + vol.Required( + CONF_ZONE, + default=user_input.get( + CONF_ZONE, f"{ZONE_DOMAIN}.{DEFAULT_PROXIMITY_ZONE}" + ), + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN), + ), + **_base_schema(user_input), + } + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return ProximityOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if user_input is not None: + self._async_abort_entries_match(user_input) + + zone = self.hass.states.get(user_input[CONF_ZONE]) + + return self.async_create_entry( + title=cast(State, zone).name, data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self._user_form_schema(user_input), + ) + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Import a yaml config entry.""" + return await self.async_step_user(user_input) + + +class ProximityOptionsFlow(OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema(_base_schema(user_input)) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.config_entry, data={**self.config_entry.data, **user_input} + ) + return self.async_create_entry(title=self.config_entry.title, data={}) + + return self.async_show_form( + step_id="init", + data_schema=self._user_form_schema(dict(self.config_entry.data)), + ) diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index 166029fef37..7627d550e1f 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -13,6 +13,7 @@ ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" CONF_TOLERANCE = "tolerance" +CONF_TRACKED_ENTITIES = "tracked_entities" DEFAULT_DIR_OF_TRAVEL = "not set" DEFAULT_DIST_TO_ZONE = "not set" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 05561bd0406..4ae923276cc 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -1,19 +1,23 @@ """Data update coordinator for the Proximity integration.""" +from collections import defaultdict from dataclasses import dataclass import logging +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, - CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter @@ -25,9 +29,11 @@ from .const import ( ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_DIR_OF_TRAVEL, DEFAULT_DIST_TO_ZONE, DEFAULT_NEAREST, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -63,18 +69,21 @@ DEFAULT_DATA = ProximityData( class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): """Proximity data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, friendly_name: str, config: ConfigType ) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zones: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_DEVICES] + self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone: str = config[CONF_ZONE] + self.proximity_zone_id: str = config[CONF_ZONE] + self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] self.unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - self.friendly_name = friendly_name + self.entity_mapping: dict[str, list[str]] = defaultdict(list) super().__init__( hass, @@ -87,6 +96,11 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.state_change_data: StateChangedData | None = None + @callback + def async_add_entity_mapping(self, tracked_entity_id: str, entity_id: str) -> None: + """Add an tracked entity to proximity entity mapping.""" + self.entity_mapping[tracked_entity_id].append(entity_id) + async def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None ) -> None: @@ -94,6 +108,31 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.state_change_data = StateChangedData(entity, old_state, new_state) await self.async_refresh() + async def async_check_tracked_entity_change( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: + """Fetch and process tracked entity change event.""" + data = event.data + if data["action"] == "remove": + self._create_removed_tracked_entity_issue(data["entity_id"]) + + if data["action"] == "update" and "entity_id" in data["changes"]: + old_tracked_entity_id = data["old_entity_id"] + new_tracked_entity_id = data["entity_id"] + + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_TRACKED_ENTITIES: [ + tracked_entity + for tracked_entity in self.tracked_entities + + [new_tracked_entity_id] + if tracked_entity != old_tracked_entity_id + ], + }, + ) + def _convert(self, value: float | str) -> float | str: """Round and convert given distance value.""" if isinstance(value, str): @@ -113,10 +152,10 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): latitude: float | None, longitude: float | None, ) -> int | None: - if device.state.lower() == self.proximity_zone.lower(): + if device.state.lower() == self.proximity_zone_name.lower(): _LOGGER.debug( "%s: %s in zone -> distance=0", - self.friendly_name, + self.name, device.entity_id, ) return 0 @@ -124,7 +163,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if latitude is None or longitude is None: _LOGGER.debug( "%s: %s has no coordinates -> distance=None", - self.friendly_name, + self.name, device.entity_id, ) return None @@ -149,10 +188,10 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): new_latitude: float | None, new_longitude: float | None, ) -> str | None: - if device.state.lower() == self.proximity_zone.lower(): + if device.state.lower() == self.proximity_zone_name.lower(): _LOGGER.debug( "%s: %s in zone -> direction_of_travel=arrived", - self.friendly_name, + self.name, device.entity_id, ) return "arrived" @@ -193,11 +232,11 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): async def _async_update_data(self) -> ProximityData: """Calculate Proximity data.""" - if (zone_state := self.hass.states.get(f"zone.{self.proximity_zone}")) is None: + if (zone_state := self.hass.states.get(self.proximity_zone_id)) is None: _LOGGER.debug( "%s: zone %s does not exist -> reset", - self.friendly_name, - self.proximity_zone, + self.name, + self.proximity_zone_id, ) return DEFAULT_DATA @@ -208,12 +247,12 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if (tracked_entity_state := self.hass.states.get(entity_id)) is None: if entities_data.pop(entity_id, None) is not None: _LOGGER.debug( - "%s: %s does not exist -> remove", self.friendly_name, entity_id + "%s: %s does not exist -> remove", self.name, entity_id ) continue if entity_id not in entities_data: - _LOGGER.debug("%s: %s is new -> add", self.friendly_name, entity_id) + _LOGGER.debug("%s: %s is new -> add", self.name, entity_id) entities_data[entity_id] = { ATTR_DIST_TO: None, ATTR_DIR_OF_TRAVEL: None, @@ -221,7 +260,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ATTR_IN_IGNORED_ZONE: False, } entities_data[entity_id][ATTR_IN_IGNORED_ZONE] = ( - tracked_entity_state.state.lower() in self.ignored_zones + f"{ZONE_DOMAIN}.{tracked_entity_state.state.lower()}" + in self.ignored_zone_ids ) entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( zone_state, @@ -232,7 +272,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if entities_data[entity_id][ATTR_DIST_TO] is None: _LOGGER.debug( "%s: %s has unknown distance got -> direction_of_travel=None", - self.friendly_name, + self.name, entity_id, ) entities_data[entity_id][ATTR_DIR_OF_TRAVEL] = None @@ -243,7 +283,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) is not None: _LOGGER.debug( "%s: calculate direction of travel for %s", - self.friendly_name, + self.name, state_change_data.entity_id, ) @@ -304,3 +344,16 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): proximity_data[ATTR_DIST_TO] = self._convert(proximity_data[ATTR_DIST_TO]) return ProximityData(proximity_data, entities_data) + + def _create_removed_tracked_entity_issue(self, entity_id: str) -> None: + """Create a repair issue for a removed tracked entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"tracked_entity_removed_{entity_id}", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="tracked_entity_removed", + translation_placeholders={"entity_id": entity_id, "name": self.name}, + ) diff --git a/homeassistant/components/proximity/helpers.py b/homeassistant/components/proximity/helpers.py new file mode 100644 index 00000000000..9c0787538e5 --- /dev/null +++ b/homeassistant/components/proximity/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for proximity.""" +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant + + +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json index 3f1ea950d0e..b29a0f495b8 100644 --- a/homeassistant/components/proximity/manifest.json +++ b/homeassistant/components/proximity/manifest.json @@ -2,6 +2,7 @@ "domain": "proximity", "name": "Proximity", "codeowners": ["@mib1185"], + "config_flow": true, "dependencies": ["device_tracker", "zone"], "documentation": "https://www.home-assistant.io/integrations/proximity", "iot_class": "calculated", diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 44121dcacb4..a1bd4d33914 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -7,10 +7,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_NAME, UnitOfLength +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN @@ -48,29 +50,51 @@ SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ ] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Proximity sensor platform.""" - if discovery_info is None: - return +def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=coordinator.config_entry.title, + entry_type=DeviceEntryType.SERVICE, + ) - coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][ - discovery_info[CONF_NAME] - ] + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the proximity sensors.""" + + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[ProximitySensor | ProximityTrackedEntitySensor] = [ ProximitySensor(description, coordinator) for description in SENSORS_PER_PROXIMITY ] + tracked_entity_descriptors = [] + + entity_reg = er.async_get(hass) + for tracked_entity_id in coordinator.tracked_entities: + if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: + tracked_entity_descriptors.append( + { + "entity_id": tracked_entity_id, + "identifier": entity_entry.id, + } + ) + else: + tracked_entity_descriptors.append( + { + "entity_id": tracked_entity_id, + "identifier": tracked_entity_id, + } + ) + entities += [ - ProximityTrackedEntitySensor(description, coordinator, tracked_entity_id) + ProximityTrackedEntitySensor( + description, coordinator, tracked_entity_descriptor + ) for description in SENSORS_PER_ENTITY - for tracked_entity_id in coordinator.tracked_entities + for tracked_entity_descriptor in tracked_entity_descriptors ] async_add_entities(entities) @@ -91,9 +115,8 @@ class ProximitySensor(CoordinatorEntity[ProximityDataUpdateCoordinator], SensorE self.entity_description = description - # entity name will be removed as soon as we have a config entry - # and can follow the entity naming guidelines - self._attr_name = f"{coordinator.friendly_name} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = _device_info(coordinator) @property def native_value(self) -> str | float | None: @@ -116,23 +139,38 @@ class ProximityTrackedEntitySensor( self, description: SensorEntityDescription, coordinator: ProximityDataUpdateCoordinator, - tracked_entity_id: str, + tracked_entity_descriptor: dict[str, str], ) -> None: """Initialize the proximity.""" super().__init__(coordinator) self.entity_description = description - self.tracked_entity_id = tracked_entity_id + self.tracked_entity_id = tracked_entity_descriptor["entity_id"] - # entity name will be removed as soon as we have a config entry - # and can follow the entity naming guidelines - self._attr_name = ( - f"{coordinator.friendly_name} {tracked_entity_id} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor['identifier']}_{description.key}" + self._attr_name = f"{self.tracked_entity_id.split('.')[-1]} {description.name}" + self._attr_device_info = _device_info(coordinator) + + async def async_added_to_hass(self) -> None: + """Register entity mapping.""" + await super().async_added_to_hass() + self.coordinator.async_add_entity_mapping( + self.tracked_entity_id, self.entity_id ) + @property + def data(self) -> dict[str, str | int | None] | None: + """Get data from coordinator.""" + return self.coordinator.data.entities.get(self.tracked_entity_id) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.data is not None + @property def native_value(self) -> str | float | None: """Return native sensor value.""" - if (data := self.coordinator.data.entities.get(self.tracked_entity_id)) is None: + if self.data is None: return None - return data.get(self.entity_description.key) + return self.data.get(self.entity_description.key) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index de2c3443998..f52f3d03516 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,5 +1,34 @@ { "title": "Proximity", + "config": { + "flow_title": "Proximity", + "step": { + "user": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "success": "Changes saved" + } + }, + "options": { + "step": { + "init": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + } + }, "entity": { "sensor": { "dir_of_travel": { @@ -25,6 +54,17 @@ } } } + }, + "tracked_entity_removed": { + "title": "Tracked entity has been removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::proximity::issues::tracked_entity_removed::title%]", + "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed." + } + } + } } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index edaba2250e8..186dd41165a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -393,6 +393,7 @@ FLOWS = { "profiler", "progettihwsw", "prosegur", + "proximity", "prusalink", "ps4", "pure_energie", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1046536c660..16023bc1fca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4562,7 +4562,7 @@ }, "proximity": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "calculated" }, "proxmoxve": { diff --git a/tests/components/proximity/conftest.py b/tests/components/proximity/conftest.py new file mode 100644 index 00000000000..960ab6cf916 --- /dev/null +++ b/tests/components/proximity/conftest.py @@ -0,0 +1,20 @@ +"""Config test for proximity.""" +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(autouse=True) +def config_zones(hass: HomeAssistant): + """Set up zones for test.""" + hass.config.components.add("zone") + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + hass.states.async_set( + "zone.work", + "zoning", + {"name": "Work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + ) diff --git a/tests/components/proximity/test_config_flow.py b/tests/components/proximity/test_config_flow.py new file mode 100644 index 00000000000..92b924be1ce --- /dev/null +++ b/tests/components/proximity/test_config_flow.py @@ -0,0 +1,187 @@ +"""Test proximity config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "expected_result"), + [ + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ), + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + ), + ], +) +async def test_user_flow( + hass: HomeAssistant, user_input: dict, expected_result: dict +) -> None: + """Test starting a flow by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == expected_result + + zone = hass.states.get(user_input[CONF_ZONE]) + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + assert mock_setup_entry.called + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config.data == { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + } + + +async def test_import_flow(hass: HomeAssistant) -> None: + """Test import of yaml configuration.""" + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + } + + zone = hass.states.get("zone.home") + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_abort_duplicated_entry(hass: HomeAssistant) -> None: + """Test if we abort on duplicate user input data.""" + DATA = { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + } + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data=DATA, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index b3a83624952..059ba2658ee 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -4,39 +4,51 @@ import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity -from homeassistant.components.proximity import DOMAIN +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) from homeassistant.components.script import scripts_with_entity -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import slugify +from tests.common import MockConfigEntry -@pytest.mark.parametrize(("friendly_name"), ["home", "home_test2", "work"]) -async def test_proximities(hass: HomeAssistant, friendly_name: str) -> None: - """Test a list of proximities.""" - config = { - "proximity": { - "home": { + +@pytest.mark.parametrize( + ("friendly_name", "config"), + [ + ( + "home", + { "ignored_zones": ["work"], "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "home_test2": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - }, - "work": { + ), + ( + "work", + { "devices": ["device_tracker.test1"], "tolerance": "1", "zone": "work", }, - } - } - - assert await async_setup_component(hass, DOMAIN, config) + ), + ], +) +async def test_proximities( + hass: HomeAssistant, friendly_name: str, config: dict +) -> None: + """Test a list of proximities.""" + assert await async_setup_component( + hass, DOMAIN, {"proximity": {friendly_name: config}} + ) await hass.async_block_till_done() # proximity entity @@ -50,31 +62,47 @@ async def test_proximities(hass: HomeAssistant, friendly_name: str) -> None: assert state.state == "0" # sensor entities - state = hass.states.get(f"sensor.{friendly_name}_nearest") + state = hass.states.get(f"sensor.{friendly_name}_nearest_device") assert state.state == STATE_UNKNOWN - for device in config["proximity"][friendly_name]["devices"]: - entity_base_name = f"sensor.{friendly_name}_{slugify(device)}" + for device in config["devices"]: + entity_base_name = f"sensor.{friendly_name}_{slugify(device.split('.')[-1])}" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE -async def test_proximities_setup(hass: HomeAssistant) -> None: - """Test a list of proximities with missing devices.""" +async def test_legacy_setup(hass: HomeAssistant) -> None: + """Test legacy setup only on imported entries.""" config = { "proximity": { "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], + "devices": ["device_tracker.test1"], "tolerance": "1", }, - "work": {"tolerance": "1", "zone": "work"}, } } - assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert hass.states.get("proximity.home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="work", + data={ + CONF_ZONE: "zone.work", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_work", + ) + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert not hass.states.get("proximity.work") async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: @@ -105,10 +133,10 @@ async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "arrived" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "0" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -143,20 +171,21 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN -async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: +async def test_device_tracker_test1_awayfurther( + hass: HomeAssistant, config_zones +) -> None: """Test for tracker state away further.""" - config_zones(hass) await hass.async_block_till_done() config = { @@ -184,10 +213,10 @@ async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -206,19 +235,20 @@ async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "away_from" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" -async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: +async def test_device_tracker_test1_awaycloser( + hass: HomeAssistant, config_zones +) -> None: """Test for tracker state away closer.""" - config_zones(hass) await hass.async_block_till_done() config = { @@ -246,10 +276,10 @@ async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -268,10 +298,10 @@ async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "towards" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -302,10 +332,10 @@ async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "not set" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -337,10 +367,10 @@ async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "not set" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -377,12 +407,12 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -399,12 +429,12 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No assert state.attributes.get("dir_of_travel") == "stationary" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -445,11 +475,11 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "arrived" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1, test2" - for device in ["device_tracker.test1", "device_tracker.test2"]: - entity_base_name = f"sensor.home_{slugify(device)}" + for device in ["test1", "test2"]: + entity_base_name = f"sensor.home_{device}" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "0" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -457,10 +487,9 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: async def test_device_tracker_test1_awayfurther_than_test2_first_test1( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -500,16 +529,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -528,16 +557,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -545,10 +574,9 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( async def test_device_tracker_test1_awayfurther_than_test2_first_test2( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -586,16 +614,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -614,16 +642,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -667,16 +695,16 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -684,10 +712,9 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( async def test_device_tracker_test1_awayfurther_test2_first( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker state.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -750,16 +777,16 @@ async def test_device_tracker_test1_awayfurther_test2_first( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -767,10 +794,9 @@ async def test_device_tracker_test1_awayfurther_test2_first( async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker states.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -809,16 +835,16 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -837,16 +863,16 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "989156" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -865,23 +891,23 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "1364567" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" -async def test_create_issue( +async def test_create_deprecated_proximity_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry, ) -> None: @@ -946,16 +972,142 @@ async def test_create_issue( ) -def config_zones(hass): - """Set up zones for test.""" - hass.config.components.add("zone") - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, +async def test_create_removed_tracked_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we create an issue for removed tracked entities.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" ) - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + t2 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test2" + ) + + hass.states.async_set(t1.entity_id, "not_home") + hass.states.async_set(t2.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, t2.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + sensor_t2 = f"sensor.home_{t2.entity_id.split('.')[-1]}_distance" + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNKNOWN + + hass.states.async_remove(t2.entity_id) + entity_registry.async_remove(t2.entity_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + DOMAIN, f"tracked_entity_removed_{t2.entity_id}" + ) + + +async def test_track_renamed_tracked_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + + hass.states.async_set(t1.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entity_registry.async_update_entity( + t1.entity_id, new_entity_id=f"{t1.entity_id}_renamed" + ) + await hass.async_block_till_done() + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entry = hass.config_entries.async_get_entry(mock_config.entry_id) + assert entry + assert entry.data[CONF_TRACKED_ENTITIES] == [f"{t1.entity_id}_renamed"] + + +async def test_sensor_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + hass.states.async_set(t1.entity_id, "not_home") + + hass.states.async_set("device_tracker.test2", "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, "device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entity = entity_registry.async_get("sensor.home_test2_distance") + assert entity + assert ( + entity.unique_id == f"{mock_config.entry_id}_device_tracker.test2_dist_to_zone" ) From 2dbc59fbea4d05d2f1c0bd43acfb2c5d7f2ad919 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 31 Jan 2024 13:01:28 +0100 Subject: [PATCH 1235/1544] Use home/sleep preset in ViCare climate entity (#105636) * use home/sleep preset * avoid setting reduced --- homeassistant/components/vicare/climate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index cc3f7e9f047..7c47629530a 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -20,7 +20,8 @@ import voluptuous as vol from homeassistant.components.climate import ( PRESET_COMFORT, PRESET_ECO, - PRESET_NONE, + PRESET_HOME, + PRESET_SLEEP, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -85,13 +86,15 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_TO_HA_PRESET_HEATING = { VICARE_PROGRAM_COMFORT: PRESET_COMFORT, VICARE_PROGRAM_ECO: PRESET_ECO, - VICARE_PROGRAM_NORMAL: PRESET_NONE, + VICARE_PROGRAM_NORMAL: PRESET_HOME, + VICARE_PROGRAM_REDUCED: PRESET_SLEEP, } HA_TO_VICARE_PRESET_HEATING = { PRESET_COMFORT: VICARE_PROGRAM_COMFORT, PRESET_ECO: VICARE_PROGRAM_ECO, - PRESET_NONE: VICARE_PROGRAM_NORMAL, + PRESET_HOME: VICARE_PROGRAM_NORMAL, + PRESET_SLEEP: VICARE_PROGRAM_REDUCED, } From dcd677fea8854f1b35768713b21d58024c8dd82e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 31 Jan 2024 13:12:26 +0100 Subject: [PATCH 1236/1544] Make google_assistant report_state test timezone aware (#109200) --- tests/components/google_assistant/test_report_state.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 4ec61b75171..29ac7c3b48d 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -1,7 +1,6 @@ """Test Google report state.""" from datetime import datetime, timedelta from http import HTTPStatus -from time import mktime from unittest.mock import AsyncMock, patch import pytest @@ -136,7 +135,7 @@ async def test_report_state( assert len(mock_report.mock_calls) == 0 -@pytest.mark.freeze_time("2023-08-01 00:00:00") +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") async def test_report_notifications( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -172,7 +171,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus(200) ) as mock_report_state: event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T00:02:57+00:00", @@ -211,7 +210,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus(500) ) as mock_report_state: event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T01:02:57+00:00", @@ -247,7 +246,7 @@ async def test_report_notifications( config, "async_report_state", return_value=HTTPStatus.NOT_FOUND ) as mock_report_state, patch.object(config, "async_disconnect_agent_user"): event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00") - epoc_event_time = int(mktime(event_time.timetuple())) + epoc_event_time = event_time.timestamp() hass.states.async_set( "event.doorbell", "2023-08-01T01:03:57+00:00", From 18a6ae081a0ce90fe87b3bff8efed93482776ea6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 31 Jan 2024 13:13:25 +0100 Subject: [PATCH 1237/1544] Apply late review comments for Comelit climate (#109114) * Apply late review comments for Comelit climate * remove logging --- homeassistant/components/comelit/climate.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 6f45968be4f..2c2d31514a5 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN +from .const import DOMAIN from .coordinator import ComelitSerialBridge @@ -115,7 +115,7 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity @property def _clima(self) -> list[Any]: """Return clima device data.""" - # CLIMATE has 2 turple: + # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier return self.coordinator.data[CLIMATE][self._device.index].val[0] @@ -159,7 +159,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity if self._api_mode in API_STATUS: return API_STATUS[self._api_mode]["hvac_mode"] - _LOGGER.warning("Unknown API mode '%s' in hvac_mode", self._api_mode) return None @property @@ -175,7 +174,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity if self._api_mode in API_STATUS: return API_STATUS[self._api_mode]["hvac_action"] - _LOGGER.warning("Unknown API mode '%s' in hvac_action", self._api_mode) return None async def async_set_temperature(self, **kwargs: Any) -> None: From 0bca72839560118d7c498c226029a96e05fe3dcf Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 13:14:26 +0100 Subject: [PATCH 1238/1544] Add Qnap icon translations (#108484) * Add Qnap icon translations * Update homeassistant/components/qnap/icons.json Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/qnap/icons.json | 42 ++++++++++++++++++++++++ homeassistant/components/qnap/sensor.py | 15 --------- 2 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/qnap/icons.json diff --git a/homeassistant/components/qnap/icons.json b/homeassistant/components/qnap/icons.json new file mode 100644 index 00000000000..c83962c3089 --- /dev/null +++ b/homeassistant/components/qnap/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "sensor": { + "status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "cpu_usage": { + "default": "mdi:chip" + }, + "memory_free": { + "default": "mdi:memory" + }, + "memory_used": { + "default": "mdi:memory" + }, + "memory_percent_used": { + "default": "mdi:memory" + }, + "network_link_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "network_tx": { + "default": "mdi:upload" + }, + "network_rx": { + "default": "mdi:download" + }, + "drive_smart_status": { + "default": "mdi:checkbox-marked-circle-outline" + }, + "volume_size_used": { + "default": "mdi:chart-pie" + }, + "volume_size_free": { + "default": "mdi:chart-pie" + }, + "volume_percentage_used": { + "default": "mdi:chart-pie" + } + } + } +} diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index f0d34f7c697..7f4731b80e1 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -41,14 +41,12 @@ _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", translation_key="status", - icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="system_temp", translation_key="system_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer", state_class=SensorStateClass.MEASUREMENT, ), ) @@ -58,7 +56,6 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="cpu_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:checkbox-marked-circle-outline", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -66,7 +63,6 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( key="cpu_usage", translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -77,7 +73,6 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -88,7 +83,6 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -98,7 +92,6 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( key="memory_percent_used", translation_key="memory_percent_used", native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -107,14 +100,12 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_link_status", translation_key="network_link_status", - icon="mdi:checkbox-marked-circle-outline", ), SensorEntityDescription( key="network_tx", translation_key="network_tx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -125,7 +116,6 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="network_rx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -136,7 +126,6 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="drive_smart_status", translation_key="drive_smart_status", - icon="mdi:checkbox-marked-circle-outline", entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -144,7 +133,6 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="drive_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - icon="mdi:thermometer", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -155,7 +143,6 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:chart-pie", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -166,7 +153,6 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="volume_size_free", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:chart-pie", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -176,7 +162,6 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( key="volume_percentage_used", translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, - icon="mdi:chart-pie", state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), From f77e4b24e643c944c01f9eb14eba9f17ce297e83 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 31 Jan 2024 13:15:26 +0100 Subject: [PATCH 1239/1544] Code quality for Vodafone tests (#109078) vodafone pylance fixes --- tests/components/vodafone_station/test_config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index c04b8364f93..a50bde8de64 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -61,8 +61,8 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" with patch( "aiovodafone.api.VodafoneStationSercommApi.login", @@ -74,6 +74,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" + assert result["errors"] is not None assert result["errors"]["base"] == error # Should be recoverable after hits error @@ -184,6 +185,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" + assert result["errors"] is not None assert result["errors"]["base"] == error # Should be recoverable after hits error From 4bad88b42c093f3992e241326ec363bc0ba5538e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 31 Jan 2024 13:17:00 +0100 Subject: [PATCH 1240/1544] Update Ecovacs config_flow to support self-hosted instances (#108944) * Update Ecovacs config_flow to support self-hosted instances * Selfhosted should add their instance urls * Improve config flow * Improve and adapt to version bump * Add test for self-hosted * Make ruff happy * Update homeassistant/components/ecovacs/strings.json Co-authored-by: Joost Lekkerkerker * Implement suggestions * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Implement suggestions * Remove , --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .../components/ecovacs/config_flow.py | 165 +++++++-- homeassistant/components/ecovacs/const.py | 12 + .../components/ecovacs/controller.py | 17 + .../components/ecovacs/diagnostics.py | 10 +- homeassistant/components/ecovacs/strings.json | 30 +- tests/components/ecovacs/conftest.py | 25 +- tests/components/ecovacs/const.py | 23 +- .../ecovacs/snapshots/test_diagnostics.ambr | 53 ++- tests/components/ecovacs/test_config_flow.py | 322 ++++++++++++++++-- tests/components/ecovacs/test_diagnostics.py | 9 + tests/components/ecovacs/test_init.py | 2 + 11 files changed, 596 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 7b56417f93e..39c61b3ce23 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -2,39 +2,81 @@ from __future__ import annotations import logging +import ssl from typing import Any, cast +from urllib.parse import urlparse from aiohttp import ClientError from deebot_client.authentication import Authenticator, create_rest_config -from deebot_client.exceptions import InvalidAuthenticationError +from deebot_client.const import UNDEFINED, UndefinedType +from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.mqtt_client import MqttClient, create_mqtt_config from deebot_client.util import md5 from deebot_client.util.continents import COUNTRIES_TO_CONTINENTS, get_continent import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client, selector from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.loader import async_get_issue_tracker +from homeassistant.util.ssl import get_default_no_verify_context -from .const import CONF_CONTINENT, DOMAIN +from .const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, + DOMAIN, + InstanceMode, +) from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) +def _validate_url( + value: str, + field_name: str, + schema_list: set[str], +) -> dict[str, str]: + """Validate an URL and return error dictionary.""" + if urlparse(value).scheme not in schema_list: + return {field_name: f"invalid_url_schema_{field_name}"} + try: + vol.Schema(vol.Url())(value) + except vol.Invalid: + return {field_name: "invalid_url"} + return {} + + async def _validate_input( hass: HomeAssistant, user_input: dict[str, Any] ) -> dict[str, str]: """Validate user input.""" errors: dict[str, str] = {} + if rest_url := user_input.get(CONF_OVERRIDE_REST_URL): + errors.update( + _validate_url(rest_url, CONF_OVERRIDE_REST_URL, {"http", "https"}) + ) + if mqtt_url := user_input.get(CONF_OVERRIDE_MQTT_URL): + errors.update( + _validate_url(mqtt_url, CONF_OVERRIDE_MQTT_URL, {"mqtt", "mqtts"}) + ) + + if errors: + return errors + + device_id = get_client_device_id() + country = user_input[CONF_COUNTRY] rest_config = create_rest_config( aiohttp_client.async_get_clientsession(hass), - device_id=get_client_device_id(), - country=user_input[CONF_COUNTRY], + device_id=device_id, + country=country, + override_rest_url=rest_url, ) authenticator = Authenticator( @@ -54,6 +96,34 @@ async def _validate_input( _LOGGER.exception("Unexpected exception during login") errors["base"] = "unknown" + if errors: + return errors + + ssl_context: UndefinedType | ssl.SSLContext = UNDEFINED + if not user_input.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url: + ssl_context = get_default_no_verify_context() + + mqtt_config = create_mqtt_config( + device_id=device_id, + country=country, + override_mqtt_url=mqtt_url, + ssl_context=ssl_context, + ) + + client = MqttClient(mqtt_config, authenticator) + cannot_connect_field = CONF_OVERRIDE_MQTT_URL if mqtt_url else "base" + + try: + await client.verify_config() + except MqttError: + _LOGGER.debug("Cannot connect", exc_info=True) + errors[cannot_connect_field] = "cannot_connect" + except InvalidAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception during mqtt connection verification") + errors["base"] = "unknown" + return errors @@ -62,10 +132,42 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _mode: InstanceMode = InstanceMode.CLOUD + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + + if not self.show_advanced_options: + return await self.async_step_auth() + + if user_input: + self._mode = user_input[CONF_MODE] + return await self.async_step_auth() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODE, default=InstanceMode.CLOUD + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(InstanceMode), + translation_key="installation_mode", + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + last_step=False, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the auth step.""" errors = {} if user_input: @@ -78,30 +180,41 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_USERNAME], data=user_input ) + schema = { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_COUNTRY): selector.CountrySelector(), + } + if self._mode == InstanceMode.SELF_HOSTED: + schema.update( + { + vol.Required(CONF_OVERRIDE_REST_URL): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.URL) + ), + vol.Required(CONF_OVERRIDE_MQTT_URL): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.URL) + ), + } + ) + if errors: + schema[vol.Optional(CONF_VERIFY_MQTT_CERTIFICATE, default=True)] = bool + + if not user_input: + user_input = { + CONF_COUNTRY: self.hass.config.country, + } + return self.async_show_form( - step_id="user", + step_id="auth", data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.TEXT - ) - ), - vol.Required(CONF_PASSWORD): selector.TextSelector( - selector.TextSelectorConfig( - type=selector.TextSelectorType.PASSWORD - ) - ), - vol.Required(CONF_COUNTRY): selector.CountrySelector(), - } - ), - suggested_values=user_input - or { - CONF_COUNTRY: self.hass.config.country, - }, + data_schema=vol.Schema(schema), suggested_values=user_input ), errors=errors, + last_step=True, ) async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: @@ -181,7 +294,7 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): # Remove the continent from the user input as it is not needed anymore user_input.pop(CONF_CONTINENT) try: - result = await self.async_step_user(user_input) + result = await self.async_step_auth(user_input) except AbortFlow as ex: if ex.reason == "already_configured": create_repair() diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index 5edbe11c265..dc055cee519 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -1,12 +1,24 @@ """Ecovacs constants.""" +from enum import StrEnum + from deebot_client.events import LifeSpan DOMAIN = "ecovacs" CONF_CONTINENT = "continent" +CONF_OVERRIDE_REST_URL = "override_rest_url" +CONF_OVERRIDE_MQTT_URL = "override_mqtt_url" +CONF_VERIFY_MQTT_CERTIFICATE = "verify_mqtt_certificate" SUPPORTED_LIFESPANS = ( LifeSpan.BRUSH, LifeSpan.FILTER, LifeSpan.SIDE_BRUSH, ) + + +class InstanceMode(StrEnum): + """Instance mode.""" + + CLOUD = "cloud" + SELF_HOSTED = "self_hosted" diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index e0c3497c178..06e3a1acccd 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections.abc import Mapping import logging +import ssl from typing import Any from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.const import UNDEFINED, UndefinedType from deebot_client.device import Device from deebot_client.exceptions import DeebotError, InvalidAuthenticationError from deebot_client.models import DeviceInfo @@ -19,7 +21,13 @@ from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_no_verify_context +from .const import ( + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, +) from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) @@ -42,15 +50,24 @@ class EcovacsController: aiohttp_client.async_get_clientsession(self._hass), device_id=self._device_id, country=country, + override_rest_url=config.get(CONF_OVERRIDE_REST_URL), ), config[CONF_USERNAME], md5(config[CONF_PASSWORD]), ) self._api_client = ApiClient(self._authenticator) + + mqtt_url = config.get(CONF_OVERRIDE_MQTT_URL) + ssl_context: UndefinedType | ssl.SSLContext = UNDEFINED + if not config.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url: + ssl_context = get_default_no_verify_context() + self._mqtt = MqttClient( create_mqtt_config( device_id=self._device_id, country=country, + override_mqtt_url=mqtt_url, + ssl_context=ssl_context, ), self._authenticator, ) diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py index fa7d85ed52a..d961e231631 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -8,10 +8,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL, DOMAIN from .controller import EcovacsController -REDACT_CONFIG = {CONF_USERNAME, CONF_PASSWORD, "title"} +REDACT_CONFIG = { + CONF_USERNAME, + CONF_PASSWORD, + "title", + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, +} REDACT_DEVICE = {"did", CONF_NAME, "homeId"} diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 56e3ec1f866..d15e8a67062 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -6,14 +6,32 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "Invalid URL", + "invalid_url_schema_override_rest_url": "Invalid REST URL scheme.\nThe URL should start with `http://` or `https://`.", + "invalid_url_schema_override_mqtt_url": "Invalid MQTT URL scheme.\nThe URL should start with `mqtt://` or `mqtts://`.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { - "user": { + "auth": { "data": { "country": "Country", + "override_rest_url": "REST URL", + "override_mqtt_url": "MQTT URL", "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "verify_mqtt_certificate": "Verify MQTT SSL certificate" + }, + "data_description": { + "override_rest_url": "Enter the REST URL of your self-hosted instance including the scheme (http/https).", + "override_mqtt_url": "Enter the MQTT URL of your self-hosted instance including the scheme (mqtt/mqtts)." + } + }, + "user": { + "data": { + "mode": "[%key:common::config_flow::data::mode%]" + }, + "data_description": { + "mode": "Select the mode you want to use to connect to Ecovacs. If you are unsure, select 'Cloud'.\n\nSelect 'Self-hosted' only if you have a working self-hosted instance." } } } @@ -157,5 +175,13 @@ "title": "The Ecovacs YAML configuration import failed", "description": "Configuring Ecovacs using YAML is being removed but there is an unexpected continent specified in the YAML configuration.\n\nFrom the given country, the continent '{continent}' is expected. Change the continent and restart Home Assistant to try again or remove the Ecovacs YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually.\n\nIf the contintent '{continent}' is not applicable, please open an issue on [GitHub]({github_issue_url})." } + }, + "selector": { + "installation_mode": { + "options": { + "cloud": "Cloud", + "self_hosted": "Self-hosted" + } + } } } diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 74e4d30a16d..d0f0668cc8c 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -12,10 +12,10 @@ import pytest from homeassistant.components.ecovacs import PLATFORMS from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.const import Platform +from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import VALID_ENTRY_DATA +from .const import VALID_ENTRY_DATA_CLOUD from tests.common import MockConfigEntry, load_json_object_fixture @@ -30,12 +30,18 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry_data() -> dict[str, Any]: + """Return the default mocked config entry data.""" + return VALID_ENTRY_DATA_CLOUD + + +@pytest.fixture +def mock_config_entry(mock_config_entry_data: dict[str, Any]) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title="username", + title=mock_config_entry_data[CONF_USERNAME], domain=DOMAIN, - data=VALID_ENTRY_DATA, + data=mock_config_entry_data, ) @@ -62,7 +68,7 @@ def mock_authenticator(device_fixture: str) -> Generator[Mock, None, None]: load_json_object_fixture(f"devices/{device_fixture}/device.json", DOMAIN) ] - def post_authenticated( + async def post_authenticated( path: str, json: dict[str, Any], *, @@ -89,8 +95,11 @@ def mock_mqtt_client(mock_authenticator: Mock) -> Mock: with patch( "homeassistant.components.ecovacs.controller.MqttClient", autospec=True, - ) as mock_mqtt_client: - client = mock_mqtt_client.return_value + ) as mock, patch( + "homeassistant.components.ecovacs.config_flow.MqttClient", + new=mock, + ): + client = mock.return_value client._authenticator = mock_authenticator client.subscribe.return_value = lambda: None yield client diff --git a/tests/components/ecovacs/const.py b/tests/components/ecovacs/const.py index f5100e69ee2..237c7fa5c85 100644 --- a/tests/components/ecovacs/const.py +++ b/tests/components/ecovacs/const.py @@ -1,13 +1,28 @@ """Test ecovacs constants.""" -from homeassistant.components.ecovacs.const import CONF_CONTINENT +from homeassistant.components.ecovacs.const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, +) from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME -VALID_ENTRY_DATA = { - CONF_USERNAME: "username", +VALID_ENTRY_DATA_CLOUD = { + CONF_USERNAME: "username@cloud", CONF_PASSWORD: "password", CONF_COUNTRY: "IT", } -IMPORT_DATA = VALID_ENTRY_DATA | {CONF_CONTINENT: "EU"} +VALID_ENTRY_DATA_SELF_HOSTED = VALID_ENTRY_DATA_CLOUD | { + CONF_USERNAME: "username@self-hosted", + CONF_OVERRIDE_REST_URL: "http://localhost:8000", + CONF_OVERRIDE_MQTT_URL: "mqtt://localhost:1883", +} + +VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT = VALID_ENTRY_DATA_SELF_HOSTED | { + CONF_VERIFY_MQTT_CERTIFICATE: True, +} + +IMPORT_DATA = VALID_ENTRY_DATA_CLOUD | {CONF_CONTINENT: "EU"} diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 9b27883745b..a4291f9fe25 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_diagnostics +# name: test_diagnostics[username@cloud] dict({ 'config': dict({ 'data': dict({ @@ -48,3 +48,54 @@ ]), }) # --- +# name: test_diagnostics[username@self-hosted] + dict({ + 'config': dict({ + 'data': dict({ + 'country': 'IT', + 'override_mqtt_url': '**REDACTED**', + 'override_rest_url': '**REDACTED**', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ecovacs', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + 'devices': list([ + dict({ + 'UILogicId': 'DX_9G', + 'class': 'yna5xi', + 'company': 'eco-ng', + 'deviceName': 'DEEBOT OZMO 950 Series', + 'did': '**REDACTED**', + 'homeSort': 9999, + 'icon': 'https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1', + 'materialNo': '110-1820-0101', + 'model': 'DX9G', + 'name': '**REDACTED**', + 'nick': 'Ozmo 950', + 'otaUpgrade': dict({ + }), + 'pid': '5c19a91ca1e6ee000178224a', + 'product_category': 'DEEBOT', + 'resource': 'upQ6', + 'service': dict({ + 'jmq': 'jmq-ngiot-eu.dc.ww.ecouser.net', + 'mqs': 'api-ngiot.dc-as.ww.ecouser.net', + }), + 'status': 1, + }), + ]), + 'legacy_devices': list([ + ]), + }) +# --- diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index 64f0758dc1f..5e02ec7dede 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -1,86 +1,307 @@ """Test Ecovacs config flow.""" +from collections.abc import Awaitable, Callable +import ssl from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError -from deebot_client.exceptions import InvalidAuthenticationError +from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.mqtt_client import create_mqtt_config import pytest -from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.const import ( + CONF_CONTINENT, + CONF_OVERRIDE_MQTT_URL, + CONF_OVERRIDE_REST_URL, + CONF_VERIFY_MQTT_CERTIFICATE, + DOMAIN, + InstanceMode, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir -from .const import IMPORT_DATA, VALID_ENTRY_DATA +from .const import ( + IMPORT_DATA, + VALID_ENTRY_DATA_CLOUD, + VALID_ENTRY_DATA_SELF_HOSTED, + VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, +) from tests.common import MockConfigEntry +_USER_STEP_SELF_HOSTED = {CONF_MODE: InstanceMode.SELF_HOSTED} -async def _test_user_flow(hass: HomeAssistant) -> dict[str, Any]: +_TEST_FN_AUTH_ARG = "user_input_auth" +_TEST_FN_USER_ARG = "user_input_user" + + +async def _test_user_flow( + hass: HomeAssistant, + user_input_auth: dict[str, Any], +) -> dict[str, Any]: """Test config flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert not result["errors"] + return await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=VALID_ENTRY_DATA, + user_input=user_input_auth, ) +async def _test_user_flow_show_advanced_options( + hass: HomeAssistant, + *, + user_input_auth: dict[str, Any], + user_input_user: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Test config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_user or {}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert not result["errors"] + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, + ) + + +@pytest.mark.parametrize( + ("test_fn", "test_fn_args", "entry_data"), + [ + ( + _test_user_flow_show_advanced_options, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ( + _test_user_flow_show_advanced_options, + { + _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, + _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, + }, + VALID_ENTRY_DATA_SELF_HOSTED, + ), + ( + _test_user_flow, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ], + ids=["advanced_cloud", "advanced_self_hosted", "cloud"], +) async def test_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] + | Callable[ + [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] + ], + test_fn_args: dict[str, Any], + entry_data: dict[str, Any], ) -> None: """Test the user config flow.""" - result = await _test_user_flow(hass) + result = await test_fn( + hass, + **test_fn_args, + ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] - assert result["data"] == VALID_ENTRY_DATA + assert result["title"] == entry_data[CONF_USERNAME] + assert result["data"] == entry_data mock_setup_entry.assert_called() mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +def _cannot_connect_error(user_input: dict[str, Any]) -> str: + field = "base" + if CONF_OVERRIDE_MQTT_URL in user_input: + field = CONF_OVERRIDE_MQTT_URL + + return {field: "cannot_connect"} @pytest.mark.parametrize( - ("side_effect", "reason"), + ("side_effect_mqtt", "errors_mqtt"), + [ + (MqttError, _cannot_connect_error), + (InvalidAuthenticationError, lambda _: {"base": "invalid_auth"}), + (Exception, lambda _: {"base": "unknown"}), + ], + ids=["cannot_connect", "invalid_auth", "unknown"], +) +@pytest.mark.parametrize( + ("side_effect_rest", "reason_rest"), [ (ClientError, "cannot_connect"), (InvalidAuthenticationError, "invalid_auth"), (Exception, "unknown"), ], + ids=["cannot_connect", "invalid_auth", "unknown"], ) -async def test_user_flow_error( +@pytest.mark.parametrize( + ("test_fn", "test_fn_args", "entry_data"), + [ + ( + _test_user_flow_show_advanced_options, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ( + _test_user_flow_show_advanced_options, + { + _TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_SELF_HOSTED, + _TEST_FN_USER_ARG: _USER_STEP_SELF_HOSTED, + }, + VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, + ), + ( + _test_user_flow, + {_TEST_FN_AUTH_ARG: VALID_ENTRY_DATA_CLOUD}, + VALID_ENTRY_DATA_CLOUD, + ), + ], + ids=["advanced_cloud", "advanced_self_hosted", "cloud"], +) +async def test_user_flow_raise_error( hass: HomeAssistant, - side_effect: Exception, - reason: str, mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + side_effect_rest: Exception, + reason_rest: str, + side_effect_mqtt: Exception, + errors_mqtt: Callable[[dict[str, Any]], str], + test_fn: Callable[[HomeAssistant, dict[str, Any]], Awaitable[dict[str, Any]]] + | Callable[ + [HomeAssistant, dict[str, Any], dict[str, Any]], Awaitable[dict[str, Any]] + ], + test_fn_args: dict[str, Any], + entry_data: dict[str, Any], ) -> None: - """Test handling invalid connection.""" + """Test handling error on library calls.""" + user_input_auth = test_fn_args[_TEST_FN_AUTH_ARG] - mock_authenticator_authenticate.side_effect = side_effect - - result = await _test_user_flow(hass) + # Authenticator raises error + mock_authenticator_authenticate.side_effect = side_effect_rest + result = await test_fn( + hass, + **test_fn_args, + ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": reason} + assert result["step_id"] == "auth" + assert result["errors"] == {"base": reason_rest} mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_not_called() mock_setup_entry.assert_not_called() mock_authenticator_authenticate.reset_mock(side_effect=True) + + # MQTT raises error + mock_mqtt_client.verify_config.side_effect = side_effect_mqtt result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=VALID_ENTRY_DATA, + user_input=user_input_auth, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == errors_mqtt(user_input_auth) + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + mock_setup_entry.assert_not_called() + + mock_authenticator_authenticate.reset_mock(side_effect=True) + mock_mqtt_client.verify_config.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input_auth, ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] - assert result["data"] == VALID_ENTRY_DATA + assert result["title"] == entry_data[CONF_USERNAME] + assert result["data"] == entry_data mock_setup_entry.assert_called() mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() + + +async def test_user_flow_self_hosted_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, +) -> None: + """Test handling selfhosted errors and custom ssl context.""" + + result = await _test_user_flow_show_advanced_options( + hass, + user_input_auth=VALID_ENTRY_DATA_SELF_HOSTED + | { + CONF_OVERRIDE_REST_URL: "bla://localhost:8000", + CONF_OVERRIDE_MQTT_URL: "mqtt://", + }, + user_input_user=_USER_STEP_SELF_HOSTED, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == { + CONF_OVERRIDE_REST_URL: "invalid_url_schema_override_rest_url", + CONF_OVERRIDE_MQTT_URL: "invalid_url", + } + mock_authenticator_authenticate.assert_not_called() + mock_mqtt_client.verify_config.assert_not_called() + mock_setup_entry.assert_not_called() + + # Check that the schema includes select box to disable ssl verification of mqtt + assert CONF_VERIFY_MQTT_CERTIFICATE in result["data_schema"].schema + + data = VALID_ENTRY_DATA_SELF_HOSTED | {CONF_VERIFY_MQTT_CERTIFICATE: False} + with patch( + "homeassistant.components.ecovacs.config_flow.create_mqtt_config", + wraps=create_mqtt_config, + ) as mock_create_mqtt_config: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=data, + ) + mock_create_mqtt_config.assert_called_once() + ssl_context = mock_create_mqtt_config.call_args[1]["ssl_context"] + assert isinstance(ssl_context, ssl.SSLContext) + assert ssl_context.verify_mode == ssl.CERT_NONE + assert ssl_context.check_hostname is False + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == data[CONF_USERNAME] + assert result["data"] == data + mock_setup_entry.assert_called() + mock_authenticator_authenticate.assert_called() + mock_mqtt_client.verify_config.assert_called() async def test_import_flow( @@ -88,6 +309,7 @@ async def test_import_flow( issue_registry: ir.IssueRegistry, mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, ) -> None: """Test importing yaml config.""" result = await hass.config_entries.flow.async_init( @@ -98,17 +320,18 @@ async def test_import_flow( mock_authenticator_authenticate.assert_called() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == VALID_ENTRY_DATA[CONF_USERNAME] - assert result["data"] == VALID_ENTRY_DATA + assert result["title"] == VALID_ENTRY_DATA_CLOUD[CONF_USERNAME] + assert result["data"] == VALID_ENTRY_DATA_CLOUD assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues mock_setup_entry.assert_called() + mock_mqtt_client.verify_config.assert_called() async def test_import_flow_already_configured( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test importing yaml config where entry already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=VALID_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_ENTRY_DATA_CLOUD) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -121,6 +344,7 @@ async def test_import_flow_already_configured( assert (HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}") in issue_registry.issues +@pytest.mark.parametrize("show_advanced_options", [True, False]) @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -131,17 +355,22 @@ async def test_import_flow_already_configured( ) async def test_import_flow_error( hass: HomeAssistant, - side_effect: Exception, - reason: str, issue_registry: ir.IssueRegistry, mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, + side_effect: Exception, + reason: str, + show_advanced_options: bool, ) -> None: """Test handling invalid connection.""" mock_authenticator_authenticate.side_effect = side_effect result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_IMPORT}, + context={ + "source": SOURCE_IMPORT, + "show_advanced_options": show_advanced_options, + }, data=IMPORT_DATA.copy(), ) assert result["type"] == FlowResultType.ABORT @@ -151,3 +380,38 @@ async def test_import_flow_error( f"deprecated_yaml_import_issue_{reason}", ) in issue_registry.issues mock_authenticator_authenticate.assert_called() + + +@pytest.mark.parametrize("show_advanced_options", [True, False]) +@pytest.mark.parametrize( + ("reason", "user_input"), + [ + ("invalid_country_length", IMPORT_DATA | {CONF_COUNTRY: "too_long"}), + ("invalid_country_length", IMPORT_DATA | {CONF_COUNTRY: "a"}), # too short + ("invalid_continent_length", IMPORT_DATA | {CONF_CONTINENT: "too_long"}), + ("invalid_continent_length", IMPORT_DATA | {CONF_CONTINENT: "a"}), # too short + ("continent_not_match", IMPORT_DATA | {CONF_CONTINENT: "AA"}), + ], +) +async def test_import_flow_invalid_data( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + reason: str, + user_input: dict[str, Any], + show_advanced_options: bool, +) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + "show_advanced_options": show_advanced_options, + }, + data=user_input, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + assert ( + DOMAIN, + f"deprecated_yaml_import_issue_{reason}", + ) in issue_registry.issues diff --git a/tests/components/ecovacs/test_diagnostics.py b/tests/components/ecovacs/test_diagnostics.py index 8244efd7fec..b025db43cc0 100644 --- a/tests/components/ecovacs/test_diagnostics.py +++ b/tests/components/ecovacs/test_diagnostics.py @@ -1,15 +1,24 @@ """Tests for diagnostics data.""" +import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant +from .const import VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED + from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + "mock_config_entry_data", + [VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED], + ids=lambda data: data[CONF_USERNAME], +) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 11fe403ca9c..3a344609961 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -87,6 +87,7 @@ async def test_async_setup_import( config_entries_expected: int, mock_setup_entry: AsyncMock, mock_authenticator_authenticate: AsyncMock, + mock_mqtt_client: Mock, ) -> None: """Test async_setup config import.""" assert len(hass.config_entries.async_entries(DOMAIN)) == 0 @@ -95,6 +96,7 @@ async def test_async_setup_import( assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected assert mock_setup_entry.call_count == config_entries_expected assert mock_authenticator_authenticate.call_count == config_entries_expected + assert mock_mqtt_client.verify_config.call_count == config_entries_expected async def test_devices_in_dr( From a61b18155b50ba33a3245e4bece037da2cd89bd1 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 31 Jan 2024 13:19:23 +0100 Subject: [PATCH 1241/1544] Make flexit bacnet switch more generic and prepare for more switches (#109154) Make switch more generic and prepare for more switches --- homeassistant/components/flexit_bacnet/switch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index 151bd9d96ec..b3751c90f7d 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -1,6 +1,6 @@ """The Flexit Nordic (BACnet) integration.""" import asyncio.exceptions -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any @@ -27,6 +27,8 @@ class FlexitSwitchEntityDescription(SwitchEntityDescription): """Describes a Flexit switch entity.""" is_on_fn: Callable[[FlexitBACnet], bool] + turn_on_fn: Callable[[FlexitBACnet], Awaitable[None]] + turn_off_fn: Callable[[FlexitBACnet], Awaitable[None]] SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( @@ -35,6 +37,8 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( translation_key="electric_heater", icon="mdi:radiator", is_on_fn=lambda data: data.electric_heater, + turn_on_fn=lambda data: data.enable_electric_heater(), + turn_off_fn=lambda data: data.disable_electric_heater(), ), ) @@ -80,7 +84,7 @@ class FlexitSwitch(FlexitEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn electric heater on.""" try: - await self.device.enable_electric_heater() + await self.entity_description.turn_on_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc finally: @@ -89,7 +93,7 @@ class FlexitSwitch(FlexitEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn electric heater off.""" try: - await self.device.disable_electric_heater() + await self.entity_description.turn_off_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise HomeAssistantError from exc finally: From 2f312f56f610ede5ddacd53a369721aa9d58e9ee Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 31 Jan 2024 13:23:51 +0100 Subject: [PATCH 1242/1544] Add fuelcell gas consumption sensors to ViCare integration (#105461) * add fuel cell gas consumption sensors * add total gas consumption sensors * add uom * Update strings.json * reorder * Revert "reorder" This reverts commit 0bcaa3b4e61eaa852228b1e6e5ae8302843f92a7. * Revert "add uom" This reverts commit b3c0dc47563e79ee30c730ba0f6e99cb5e5004af. * Update sensor.py --- homeassistant/components/vicare/sensor.py | 62 ++++++++++++++++++++ homeassistant/components/vicare/strings.json | 24 ++++++++ 2 files changed, 86 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 142e3cbabfa..39b4bd032dc 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -210,6 +210,68 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_today", + translation_key="gas_consumption_fuelcell_today", + value_getter=lambda api: api.getFuelCellGasConsumptionToday(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_week", + translation_key="gas_consumption_fuelcell_this_week", + value_getter=lambda api: api.getFuelCellGasConsumptionThisWeek(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_month", + translation_key="gas_consumption_fuelcell_this_month", + value_getter=lambda api: api.getFuelCellGasConsumptionThisMonth(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_fuelcell_this_year", + translation_key="gas_consumption_fuelcell_this_year", + value_getter=lambda api: api.getFuelCellGasConsumptionThisYear(), + unit_getter=lambda api: api.getFuelCellGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_today", + translation_key="gas_consumption_total_today", + value_getter=lambda api: api.getGasConsumptionTotalToday(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_week", + translation_key="gas_consumption_total_this_week", + value_getter=lambda api: api.getGasConsumptionTotalThisWeek(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_month", + translation_key="gas_consumption_total_this_month", + value_getter=lambda api: api.getGasConsumptionTotalThisMonth(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="gas_consumption_total_this_year", + translation_key="gas_consumption_total_this_year", + value_getter=lambda api: api.getGasConsumptionTotalThisYear(), + unit_getter=lambda api: api.getGasConsumptionUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentday", translation_key="gas_summary_consumption_heating_currentday", diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 87b5bb6cc14..96e43be6818 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -140,6 +140,30 @@ "gas_consumption_heating_this_year": { "name": "Heating gas consumption this year" }, + "gas_consumption_fuelcell_today": { + "name": "Fuel cell gas consumption today" + }, + "gas_consumption_fuelcell_this_week": { + "name": "Fuel cell gas consumption this week" + }, + "gas_consumption_fuelcell_this_month": { + "name": "Fuel cell gas consumption this month" + }, + "gas_consumption_fuelcell_this_year": { + "name": "Fuel cell gas consumption this year" + }, + "gas_consumption_total_today": { + "name": "Gas consumption today" + }, + "gas_consumption_total_this_week": { + "name": "Gas consumption this week" + }, + "gas_consumption_total_this_month": { + "name": "Gas consumption this month" + }, + "gas_consumption_total_this_year": { + "name": "Gas consumption this year" + }, "gas_summary_consumption_heating_currentday": { "name": "Heating gas consumption today" }, From fb04451c082cd5ce416c466ea9989ba3f3de325a Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 13:34:52 +0100 Subject: [PATCH 1243/1544] Set entity category for QNAP sensors (#109207) --- homeassistant/components/qnap/sensor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 7f4731b80e1..348759012ac 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_NAME, PERCENTAGE, + EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, @@ -41,12 +42,14 @@ _SYSTEM_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="status", translation_key="status", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="system_temp", translation_key="system_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), ) @@ -56,6 +59,7 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="cpu_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -63,6 +67,7 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( key="cpu_usage", translation_key="cpu_usage", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -73,6 +78,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -83,6 +89,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -92,6 +99,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( key="memory_percent_used", translation_key="memory_percent_used", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), @@ -100,12 +108,14 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_link_status", translation_key="network_link_status", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="network_tx", translation_key="network_tx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -116,6 +126,7 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="network_rx", native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -126,6 +137,7 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="drive_smart_status", translation_key="drive_smart_status", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), SensorEntityDescription( @@ -133,6 +145,7 @@ _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="drive_temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -143,6 +156,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="volume_size_used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -153,6 +167,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( translation_key="volume_size_free", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -162,6 +177,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( key="volume_percentage_used", translation_key="volume_percentage_used", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), From 68c633c31704f3977556b11c3b373ae3d045a1a7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 31 Jan 2024 14:15:56 +0100 Subject: [PATCH 1244/1544] Add Matter Websocket commands for node actions and diagnostics (#109127) * bump python-matter-server to version 5.3.0 * Add all node related websocket services * remove open_commissioning_window service as it wasnt working anyways * use device id instead of node id * tests * add decorator to get node * add some tests for invalid device id * add test for unknown node * add explicit exception * adjust test * move exceptions * remove the additional config entry check for now to be picked up in follow up pR --- homeassistant/components/matter/__init__.py | 38 +- homeassistant/components/matter/api.py | 160 ++++++++- homeassistant/components/matter/helpers.py | 7 +- homeassistant/components/matter/services.yaml | 7 - tests/components/matter/test_api.py | 325 +++++++++++++++++- 5 files changed, 491 insertions(+), 46 deletions(-) delete mode 100644 homeassistant/components/matter/services.yaml diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index b58c4562994..3a82e466888 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -7,14 +7,13 @@ from functools import cache from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion -from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists -import voluptuous as vol +from matter_server.common.errors import MatterError, NodeNotExists from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import ( @@ -22,7 +21,6 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) -from homeassistant.helpers.service import async_register_admin_service from .adapter import MatterAdapter from .addon import get_addon_manager @@ -117,7 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DOMAIN not in hass.data: hass.data[DOMAIN] = {} - _async_init_services(hass) # create an intermediate layer (adapter) which keeps track of the nodes # and discovery of platform entities from the node attributes @@ -237,35 +234,6 @@ async def async_remove_config_entry_device( return True -@callback -def _async_init_services(hass: HomeAssistant) -> None: - """Init services.""" - - async def open_commissioning_window(call: ServiceCall) -> None: - """Open commissioning window on specific node.""" - node = node_from_ha_device_id(hass, call.data["device_id"]) - - if node is None: - raise HomeAssistantError("This is not a Matter device") - - matter_client = get_matter(hass).matter_client - - # We are sending device ID . - - try: - await matter_client.open_commissioning_window(node.node_id) - except NodeCommissionFailed as err: - raise HomeAssistantError(str(err)) from err - - async_register_admin_service( - hass, - DOMAIN, - "open_commissioning_window", - open_commissioning_window, - vol.Schema({"device_id": str}), - ) - - async def _async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Matter Server add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 2df21d8f7a2..21445e469aa 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -5,7 +5,9 @@ from collections.abc import Callable, Coroutine from functools import wraps from typing import Any, Concatenate, ParamSpec +from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError +from matter_server.common.helpers.util import dataclass_to_dict import voluptuous as vol from homeassistant.components import websocket_api @@ -13,12 +15,16 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter -from .helpers import get_matter +from .helpers import MissingNode, get_matter, node_from_ha_device_id _P = ParamSpec("_P") ID = "id" TYPE = "type" +DEVICE_ID = "device_id" + + +ERROR_NODE_NOT_FOUND = "node_not_found" @callback @@ -28,6 +34,40 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_commission_on_network) websocket_api.async_register_command(hass, websocket_set_thread_dataset) websocket_api.async_register_command(hass, websocket_set_wifi_credentials) + websocket_api.async_register_command(hass, websocket_node_diagnostics) + websocket_api.async_register_command(hass, websocket_ping_node) + websocket_api.async_register_command(hass, websocket_open_commissioning_window) + websocket_api.async_register_command(hass, websocket_remove_matter_fabric) + websocket_api.async_register_command(hass, websocket_interview_node) + + +def async_get_node( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter, MatterNode], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], +]: + """Decorate async function to get node.""" + + @wraps(func) + async def async_get_node_func( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + ) -> None: + """Provide user specific data and store to function.""" + node = node_from_ha_device_id(hass, msg[DEVICE_ID]) + if not node: + raise MissingNode( + f"Could not resolve Matter node from device id {msg[DEVICE_ID]}" + ) + await func(hass, connection, msg, matter, node) + + return async_get_node_func def async_get_matter_adapter( @@ -76,6 +116,8 @@ def async_handle_failed_command( await func(hass, connection, msg, *args, **kwargs) except MatterError as err: connection.send_error(msg[ID], str(err.error_code), err.args[0]) + except MissingNode as err: + connection.send_error(msg[ID], ERROR_NODE_NOT_FOUND, err.args[0]) return async_handle_failed_command_func @@ -173,3 +215,119 @@ async def websocket_set_wifi_credentials( ssid=msg["network_name"], credentials=msg["password"] ) connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/node_diagnostics", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_node_diagnostics( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Gather diagnostics for the given node.""" + result = await matter.matter_client.node_diagnostics(node_id=node.node_id) + connection.send_result(msg[ID], dataclass_to_dict(result)) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/ping_node", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_ping_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Ping node on the currently known IP-adress(es).""" + result = await matter.matter_client.ping_node(node_id=node.node_id) + connection.send_result(msg[ID], result) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/open_commissioning_window", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_open_commissioning_window( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Open a commissioning window to commission a device present on this controller to another.""" + result = await matter.matter_client.open_commissioning_window(node_id=node.node_id) + connection.send_result(msg[ID], dataclass_to_dict(result)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/remove_matter_fabric", + vol.Required(DEVICE_ID): str, + vol.Required("fabric_index"): int, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_remove_matter_fabric( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Remove Matter fabric from a device.""" + await matter.matter_client.remove_matter_fabric( + node_id=node.node_id, fabric_index=msg["fabric_index"] + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "matter/interview_node", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_matter_adapter +@async_get_node +async def websocket_interview_node( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + matter: MatterAdapter, + node: MatterNode, +) -> None: + """Interview a node.""" + await matter.matter_client.interview_node(node_id=node.node_id) + connection.send_result(msg[ID]) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 446d5dc3591..8f7f3d81883 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID @@ -17,6 +18,10 @@ if TYPE_CHECKING: from .adapter import MatterAdapter +class MissingNode(HomeAssistantError): + """Exception raised when we can't find a node.""" + + @dataclass class MatterEntryData: """Hold Matter data for the config entry.""" @@ -72,7 +77,7 @@ def node_from_ha_device_id(hass: HomeAssistant, ha_device_id: str) -> MatterNode dev_reg = dr.async_get(hass) device = dev_reg.async_get(ha_device_id) if device is None: - raise ValueError("Invalid device ID") + raise MissingNode(f"Invalid device ID: {ha_device_id}") return get_node_from_device_entry(hass, device) diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml deleted file mode 100644 index c72187b2ffe..00000000000 --- a/homeassistant/components/matter/services.yaml +++ /dev/null @@ -1,7 +0,0 @@ -open_commissioning_window: - fields: - device_id: - required: true - selector: - device: - integration: matter diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 24dac910d33..892f935ebab 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -1,11 +1,28 @@ """Test the api module.""" -from unittest.mock import MagicMock, call +from unittest.mock import AsyncMock, MagicMock, call +from matter_server.client.models.node import ( + MatterFabricData, + NetworkType, + NodeDiagnostics, + NodeType, +) from matter_server.common.errors import InvalidCommand, NodeCommissionFailed +from matter_server.common.helpers.util import dataclass_to_dict +from matter_server.common.models import CommissioningParameters import pytest -from homeassistant.components.matter.api import ID, TYPE +from homeassistant.components.matter.api import ( + DEVICE_ID, + ERROR_NODE_NOT_FOUND, + ID, + TYPE, +) +from homeassistant.components.matter.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .common import setup_integration_with_node_fixture from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -177,3 +194,307 @@ async def test_set_wifi_credentials( assert matter_client.set_wifi_credentials.call_args == call( ssid="test_network", credentials="test_password" ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_node_diagnostics( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the node diagnostics command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create a mock NodeDiagnostics + mock_diagnostics = NodeDiagnostics( + node_id=1, + network_type=NetworkType.WIFI, + node_type=NodeType.END_DEVICE, + network_name="SuperCoolWiFi", + ip_adresses=["192.168.1.1", "fe80::260:97ff:fe02:6ea5"], + mac_address="00:11:22:33:44:55", + available=True, + active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")], + ) + matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/node_diagnostics", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + diag_res = dataclass_to_dict(mock_diagnostics) + # dataclass to dict allows enums which are converted to string when serializing + diag_res["network_type"] = diag_res["network_type"].value + diag_res["node_type"] = diag_res["node_type"].value + assert msg["result"] == diag_res + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/node_diagnostics", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_ping_node( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the ping_node command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create a mocked ping result + ping_result = {"192.168.1.1": False, "fe80::260:97ff:fe02:6ea5": True} + matter_client.ping_node = AsyncMock(return_value=ping_result) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/ping_node", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + assert msg["result"] == ping_result + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/ping_node", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_open_commissioning_window( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the open_commissioning_window command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # create mocked CommissioningParameters + commissioning_parameters = CommissioningParameters( + setup_pin_code=51590642, + setup_manual_code="36296231484", + setup_qr_code="MT:00000CQM008-WE3G310", + ) + matter_client.open_commissioning_window = AsyncMock( + return_value=commissioning_parameters + ) + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/open_commissioning_window", + DEVICE_ID: entry.id, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["type"] == "result" + assert msg["result"] == dataclass_to_dict(commissioning_parameters) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/open_commissioning_window", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_remove_matter_fabric( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the remove_matter_fabric command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + ID: 1, + TYPE: "matter/remove_matter_fabric", + DEVICE_ID: entry.id, + "fabric_index": 3, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + matter_client.remove_matter_fabric.assert_called_once_with(1, 3) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/remove_matter_fabric", + DEVICE_ID: new_entry.id, + "fabric_index": 3, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_interview_node( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + matter_client: MagicMock, +) -> None: + """Test the interview_node command.""" + # setup (mock) integration with a random node fixture + await setup_integration_with_node_fixture( + hass, + "onoff-light", + matter_client, + ) + # get the device registry entry for the mocked node + dev_reg = dr.async_get(hass) + entry = dev_reg.async_get_device( + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } + ) + assert entry is not None + # issue command on the ws api + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + {ID: 1, TYPE: "matter/interview_node", DEVICE_ID: entry.id} + ) + msg = await ws_client.receive_json() + assert msg["success"] + matter_client.interview_node.assert_called_once_with(1) + + # repeat test with a device id that does not have a node attached + new_entry = dev_reg.async_get_or_create( + config_entry_id=list(entry.config_entries)[0], + identifiers={(DOMAIN, "MatterNodeDevice")}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "matter/interview_node", + DEVICE_ID: new_entry.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERROR_NODE_NOT_FOUND From 4f4d79137e75b4c462bff446c027a51264621fca Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 31 Jan 2024 14:43:35 +0100 Subject: [PATCH 1245/1544] Add Ecovacs number entities (#109209) --- .coveragerc | 1 + homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/icons.json | 11 ++ homeassistant/components/ecovacs/number.py | 103 ++++++++++++ homeassistant/components/ecovacs/strings.json | 8 + .../ecovacs/snapshots/test_number.ambr | 53 ++++++ tests/components/ecovacs/test_init.py | 2 +- tests/components/ecovacs/test_number.py | 156 ++++++++++++++++++ 8 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ecovacs/number.py create mode 100644 tests/components/ecovacs/snapshots/test_number.ambr create mode 100644 tests/components/ecovacs/test_number.py diff --git a/.coveragerc b/.coveragerc index 829b0fd9391..03428e8e305 100644 --- a/.coveragerc +++ b/.coveragerc @@ -287,6 +287,7 @@ omit = homeassistant/components/ecovacs/controller.py homeassistant/components/ecovacs/entity.py homeassistant/components/ecovacs/image.py + homeassistant/components/ecovacs/number.py homeassistant/components/ecovacs/util.py homeassistant/components/ecovacs/vacuum.py homeassistant/components/ecowitt/__init__.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 53c85d6d96f..c008e74471c 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.IMAGE, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.VACUUM, diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 86b3dc70dc1..276786ea8ac 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -22,6 +22,17 @@ "default": "mdi:broom" } }, + "number": { + "clean_count": { + "default": "mdi:counter" + }, + "volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + } + }, "sensor": { "error": { "default": "mdi:alert-circle" diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py new file mode 100644 index 00000000000..45250ab69b1 --- /dev/null +++ b/homeassistant/components/ecovacs/number.py @@ -0,0 +1,103 @@ +"""Ecovacs number module.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from deebot_client.capabilities import CapabilitySet +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 .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, + EventT, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsNumberEntityDescription( + NumberEntityDescription, + EcovacsCapabilityEntityDescription, + Generic[EventT], +): + """Ecovacs number entity description.""" + + native_max_value_fn: Callable[[EventT], float | int | None] = lambda _: None + value_fn: Callable[[EventT], float | None] + + +ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( + EcovacsNumberEntityDescription[VolumeEvent]( + capability_fn=lambda caps: caps.settings.volume, + value_fn=lambda e: e.volume, + native_max_value_fn=lambda e: e.maximum, + key="volume", + translation_key="volume", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=10, + native_step=1.0, + ), + EcovacsNumberEntityDescription[CleanCountEvent]( + capability_fn=lambda caps: caps.clean.count, + value_fn=lambda e: e.count, + key="clean_count", + translation_key="clean_count", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_min_value=1, + native_max_value=4, + native_step=1.0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsNumberEntity( + EcovacsDescriptionEntity[CapabilitySet[EventT, int]], + NumberEntity, +): + """Ecovacs number entity.""" + + entity_description: EcovacsNumberEntityDescription + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EventT) -> None: + self._attr_native_value = self.entity_description.value_fn(event) + if maximum := self.entity_description.native_max_value_fn(event): + self._attr_native_max_value = maximum + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self._device.execute_command(self._capability.set(int(value))) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index d15e8a67062..520c2ce65ca 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -61,6 +61,14 @@ "name": "Map" } }, + "number": { + "clean_count": { + "name": "Clean count" + }, + "volume": { + "name": "Volume" + } + }, "sensor": { "error": { "name": "Error", diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr new file mode 100644 index 00000000000..bb0d0b35f6a --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_number_entities[yna5x1][number.ozmo_950_volume:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.ozmo_950_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'E1234567890000000001_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[yna5x1][number.ozmo_950_volume:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.ozmo_950_volume', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3a344609961..3b43de6164e 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -118,7 +118,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 21), + ("yna5x1", 22), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py new file mode 100644 index 00000000000..096c62751c0 --- /dev/null +++ b/tests/components/ecovacs/test_number.py @@ -0,0 +1,156 @@ +"""Tests for Ecovacs select entities.""" + +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.commands.json import SetVolume +from deebot_client.event_bus import EventBus +from deebot_client.events import Event, VolumeEvent +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.number.const import ( + ATTR_VALUE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.NUMBER + + +async def notify_events(hass: HomeAssistant, event_bus: EventBus): + """Notify events.""" + event_bus.notify(VolumeEvent(5, 11)) + await block_till_done(hass, event_bus) + + +@dataclass(frozen=True) +class NumberTestCase: + """Number test.""" + + entity_id: str + event: Event + current_state: str + set_value: int + command: Command + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + NumberTestCase( + "number.ozmo_950_volume", VolumeEvent(5, 11), "5", 10, SetVolume(10) + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[NumberTestCase], +) -> None: + """Test that number entity snapshots match.""" + device = controller.devices[0] + event_bus = device.events + + assert sorted(hass.states.async_entity_ids()) == sorted( + test.entity_id for test in tests + ) + for test_case in tests: + entity_id = test_case.entity_id + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_UNKNOWN + + event_bus.notify(test_case.event) + await block_till_done(hass, event_bus) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + assert state.state == test_case.current_state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: test_case.set_value}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + ["number.ozmo_950_volume"], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_number_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default number entities.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_volume_maximum( + hass: HomeAssistant, + controller: EcovacsController, +) -> None: + """Test volume maximum.""" + device = controller.devices[0] + event_bus = device.events + entity_id = "number.ozmo_950_volume" + assert (state := hass.states.get(entity_id)) + assert state.attributes["max"] == 10 + + event_bus.notify(VolumeEvent(5, 20)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "5" + assert state.attributes["max"] == 20 + + event_bus.notify(VolumeEvent(10, None)) + await block_till_done(hass, event_bus) + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + assert state.attributes["max"] == 20 From 52a692df3ef5f6520c0a599701559a28d1522284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 31 Jan 2024 14:47:37 +0100 Subject: [PATCH 1246/1544] Add Elvia integration (#107405) --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/elvia/__init__.py | 49 ++++ homeassistant/components/elvia/config_flow.py | 119 +++++++++ homeassistant/components/elvia/const.py | 7 + homeassistant/components/elvia/importer.py | 129 ++++++++++ homeassistant/components/elvia/manifest.json | 10 + homeassistant/components/elvia/strings.json | 25 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/elvia/__init__.py | 1 + tests/components/elvia/conftest.py | 14 ++ tests/components/elvia/test_config_flow.py | 237 ++++++++++++++++++ 15 files changed, 608 insertions(+) create mode 100644 homeassistant/components/elvia/__init__.py create mode 100644 homeassistant/components/elvia/config_flow.py create mode 100644 homeassistant/components/elvia/const.py create mode 100644 homeassistant/components/elvia/importer.py create mode 100644 homeassistant/components/elvia/manifest.json create mode 100644 homeassistant/components/elvia/strings.json create mode 100644 tests/components/elvia/__init__.py create mode 100644 tests/components/elvia/conftest.py create mode 100644 tests/components/elvia/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 03428e8e305..0b6eaecfb31 100644 --- a/.coveragerc +++ b/.coveragerc @@ -320,6 +320,8 @@ omit = homeassistant/components/elmax/cover.py homeassistant/components/elmax/switch.py homeassistant/components/elv/* + homeassistant/components/elvia/__init__.py + homeassistant/components/elvia/importer.py homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* diff --git a/CODEOWNERS b/CODEOWNERS index 34e77892a95..83fcf1e6d00 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,8 @@ build.json @home-assistant/supervisor /homeassistant/components/elmax/ @albertogeniola /tests/components/elmax/ @albertogeniola /homeassistant/components/elv/ @majuss +/homeassistant/components/elvia/ @ludeeus +/tests/components/elvia/ @ludeeus /homeassistant/components/emby/ @mezz64 /homeassistant/components/emoncms/ @borpin /homeassistant/components/emonitor/ @bdraco diff --git a/homeassistant/components/elvia/__init__.py b/homeassistant/components/elvia/__init__.py new file mode 100644 index 00000000000..1f85fe720a7 --- /dev/null +++ b/homeassistant/components/elvia/__init__.py @@ -0,0 +1,49 @@ +"""The Elvia integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from elvia import error as ElviaError + +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_METERING_POINT_ID, LOGGER +from .importer import ElviaImporter + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Elvia from a config entry.""" + importer = ElviaImporter( + hass=hass, + api_token=entry.data[CONF_API_TOKEN], + metering_point_id=entry.data[CONF_METERING_POINT_ID], + ) + + async def _import_meter_values(_: datetime | None = None) -> None: + """Import meter values.""" + try: + await importer.import_meter_values() + except ElviaError.ElviaException as exception: + LOGGER.exception("Unknown error %s", exception) + + try: + await importer.import_meter_values() + except ElviaError.ElviaException as exception: + LOGGER.exception("Unknown error %s", exception) + return False + + entry.async_on_unload( + async_track_time_interval( + hass, + _import_meter_values, + timedelta(minutes=60), + ) + ) + + return True diff --git a/homeassistant/components/elvia/config_flow.py b/homeassistant/components/elvia/config_flow.py new file mode 100644 index 00000000000..e65c93b09a6 --- /dev/null +++ b/homeassistant/components/elvia/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for Elvia integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Any + +from elvia import Elvia, error as ElviaError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.util import dt as dt_util + +from .const import CONF_METERING_POINT_ID, DOMAIN, LOGGER + +if TYPE_CHECKING: + from homeassistant.data_entry_flow import FlowResult + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Elvia.""" + + def __init__(self) -> None: + """Initialize.""" + self._api_token: str | None = None + self._metering_point_ids: list[str] | None = None + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._api_token = api_token = user_input[CONF_API_TOKEN] + client = Elvia(meter_value_token=api_token).meter_value() + try: + results = await client.get_meter_values( + start_time=(dt_util.now() - timedelta(hours=1)).isoformat() + ) + + except ElviaError.AuthError as exception: + LOGGER.error("Authentication error %s", exception) + errors["base"] = "invalid_auth" + except ElviaError.ElviaException as exception: + LOGGER.error("Unknown error %s", exception) + errors["base"] = "unknown" + else: + try: + self._metering_point_ids = metering_point_ids = [ + x["meteringPointId"] for x in results["meteringpoints"] + ] + except KeyError: + return self.async_abort(reason="no_metering_points") + + if (meter_count := len(metering_point_ids)) > 1: + return await self.async_step_select_meter() + if meter_count == 1: + return await self._create_config_entry( + api_token=api_token, + metering_point_id=metering_point_ids[0], + ) + + return self.async_abort(reason="no_metering_points") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } + ), + errors=errors, + ) + + async def async_step_select_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle selecting a metering point ID.""" + if TYPE_CHECKING: + assert self._metering_point_ids is not None + assert self._api_token is not None + + if user_input is not None: + return await self._create_config_entry( + api_token=self._api_token, + metering_point_id=user_input[CONF_METERING_POINT_ID], + ) + + return self.async_show_form( + step_id="select_meter", + data_schema=vol.Schema( + { + vol.Required( + CONF_METERING_POINT_ID, + default=self._metering_point_ids[0], + ): vol.In(self._metering_point_ids), + } + ), + ) + + async def _create_config_entry( + self, + api_token: str, + metering_point_id: str, + ) -> FlowResult: + """Store metering point ID and API token.""" + if (await self.async_set_unique_id(metering_point_id)) is not None: + return self.async_abort( + reason="metering_point_id_already_configured", + description_placeholders={"metering_point_id": metering_point_id}, + ) + return self.async_create_entry( + title=metering_point_id, + data={ + CONF_API_TOKEN: api_token, + CONF_METERING_POINT_ID: metering_point_id, + }, + ) diff --git a/homeassistant/components/elvia/const.py b/homeassistant/components/elvia/const.py new file mode 100644 index 00000000000..c4b8e40e73f --- /dev/null +++ b/homeassistant/components/elvia/const.py @@ -0,0 +1,7 @@ +"""Constants for the Elvia integration.""" +from logging import getLogger + +DOMAIN = "elvia" +LOGGER = getLogger(__package__) + +CONF_METERING_POINT_ID = "metering_point_id" diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py new file mode 100644 index 00000000000..3fc79240254 --- /dev/null +++ b/homeassistant/components/elvia/importer.py @@ -0,0 +1,129 @@ +"""Importer for the Elvia integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, cast + +from elvia import Elvia + +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.components.recorder.util import get_instance +from homeassistant.const import UnitOfEnergy +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from elvia.types.meter_value_types import MeterValueTimeSeries + + from homeassistant.core import HomeAssistant + + +class ElviaImporter: + """Class to import data from Elvia.""" + + def __init__( + self, + hass: HomeAssistant, + api_token: str, + metering_point_id: str, + ) -> None: + """Initialize.""" + self.hass = hass + self.client = Elvia(meter_value_token=api_token).meter_value() + self.metering_point_id = metering_point_id + + async def _fetch_hourly_data(self, since: datetime) -> list[MeterValueTimeSeries]: + """Fetch hourly data.""" + LOGGER.debug("Fetching hourly data since %s", since) + all_data = await self.client.get_meter_values( + start_time=since.isoformat(), + metering_point_ids=[self.metering_point_id], + ) + return all_data["meteringpoints"][0]["metervalue"]["timeSeries"] + + async def import_meter_values(self) -> None: + """Import meter values.""" + statistics: list[StatisticData] = [] + statistic_id = f"{DOMAIN}:{self.metering_point_id}_consumption" + last_stats = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 1, + statistic_id, + True, + {"sum"}, + ) + + if not last_stats: + # First time we insert 1 years of data (if available) + hourly_data = await self._fetch_hourly_data( + since=dt_util.now() - timedelta(days=365) + ) + if hourly_data is None or len(hourly_data) == 0: + return + last_stats_time = None + _sum = 0.0 + else: + hourly_data = await self._fetch_hourly_data( + since=dt_util.utc_from_timestamp(last_stats[statistic_id][0]["end"]) + ) + + if ( + hourly_data is None + or len(hourly_data) == 0 + or not hourly_data[-1]["verified"] + or (from_time := dt_util.parse_datetime(hourly_data[0]["startTime"])) + is None + ): + return + + curr_stat = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + from_time - timedelta(hours=1), + None, + {statistic_id}, + "hour", + None, + {"sum"}, + ) + first_stat = curr_stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] + + last_stats_time_dt = ( + dt_util.utc_from_timestamp(last_stats_time) if last_stats_time else None + ) + + for entry in hourly_data: + from_time = dt_util.parse_datetime(entry["startTime"]) + if from_time is None or ( + last_stats_time_dt is not None and from_time <= last_stats_time_dt + ): + continue + + _sum += entry["value"] + + statistics.append( + StatisticData(start=from_time, state=entry["value"], sum=_sum) + ) + + async_add_external_statistics( + hass=self.hass, + metadata=StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{self.metering_point_id} Consumption", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + statistics=statistics, + ) + LOGGER.debug("Imported %s statistics", len(statistics)) diff --git a/homeassistant/components/elvia/manifest.json b/homeassistant/components/elvia/manifest.json new file mode 100644 index 00000000000..abb4f846f00 --- /dev/null +++ b/homeassistant/components/elvia/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "elvia", + "name": "Elvia", + "codeowners": ["@ludeeus"], + "config_flow": true, + "dependencies": ["recorder"], + "documentation": "https://www.home-assistant.io/integrations/elvia", + "iot_class": "cloud_polling", + "requirements": ["elvia==0.1.0"] +} diff --git a/homeassistant/components/elvia/strings.json b/homeassistant/components/elvia/strings.json new file mode 100644 index 00000000000..888a5ab8e76 --- /dev/null +++ b/homeassistant/components/elvia/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your meter value API token from Elvia", + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + } + }, + "select_meter": { + "data": { + "metering_point_id": "Select your metering point ID" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.", + "no_metering_points": "The provived API token has no metering points." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 186dd41165a..2a3a9bea392 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -139,6 +139,7 @@ FLOWS = { "elgato", "elkm1", "elmax", + "elvia", "emonitor", "emulated_roku", "energyzero", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 16023bc1fca..cf70feca4eb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1502,6 +1502,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "elvia": { + "name": "Elvia", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "emby": { "name": "Emby", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 395f1caf5da..7d6fea865c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -766,6 +766,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.elvia +elvia==0.1.0 + # homeassistant.components.xmpp emoji==2.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c456c228fea..39fcb8183b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -623,6 +623,9 @@ elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 +# homeassistant.components.elvia +elvia==0.1.0 + # homeassistant.components.emulated_roku emulated-roku==0.2.1 diff --git a/tests/components/elvia/__init__.py b/tests/components/elvia/__init__.py new file mode 100644 index 00000000000..4a0d145e730 --- /dev/null +++ b/tests/components/elvia/__init__.py @@ -0,0 +1 @@ +"""Tests for the Elvia integration.""" diff --git a/tests/components/elvia/conftest.py b/tests/components/elvia/conftest.py new file mode 100644 index 00000000000..a2a10e67893 --- /dev/null +++ b/tests/components/elvia/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Elvia tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.elvia.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/elvia/test_config_flow.py b/tests/components/elvia/test_config_flow.py new file mode 100644 index 00000000000..630aca4f16c --- /dev/null +++ b/tests/components/elvia/test_config_flow.py @@ -0,0 +1,237 @@ +"""Test the Elvia config flow.""" +from unittest.mock import AsyncMock, patch + +from elvia import error as ElviaError +import pytest + +from homeassistant import config_entries +from homeassistant.components.elvia.const import CONF_METERING_POINT_ID, DOMAIN +from homeassistant.components.recorder.core import Recorder +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType, UnknownFlow + +from tests.common import MockConfigEntry + +TEST_API_TOKEN = "xxx-xxx-xxx-xxx" + + +async def test_single_metering_point( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with a single metering point.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": [{"meteringPointId": "1234"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "1234" + assert result["data"] == { + CONF_API_TOKEN: TEST_API_TOKEN, + CONF_METERING_POINT_ID: "1234", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_metering_points( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with multiple metering points.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={ + "meteringpoints": [ + {"meteringPointId": "1234"}, + {"meteringPointId": "5678"}, + ] + }, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_meter" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_METERING_POINT_ID: "5678", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "5678" + assert result["data"] == { + CONF_API_TOKEN: TEST_API_TOKEN, + CONF_METERING_POINT_ID: "5678", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_metering_points( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with no metering points.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": []}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_metering_points" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_bad_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test using the config flow with no metering points.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_metering_points" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_abort_when_metering_point_id_exist( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort when the metering point ID exist.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + return_value={"meteringpoints": [{"meteringPointId": "1234"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "metering_point_id_already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("side_effect", "base_error"), + ( + (ElviaError.ElviaException("Boom"), "unknown"), + (ElviaError.AuthError("Boom", 403, {}, ""), "invalid_auth"), + (ElviaError.ElviaServerException("Boom", 500, {}, ""), "unknown"), + (ElviaError.ElviaClientException("Boom"), "unknown"), + ), +) +async def test_form_exceptions( + recorder_mock: Recorder, + hass: HomeAssistant, + side_effect: Exception, + base_error: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "elvia.meter_value.MeterValue.get_meter_values", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: TEST_API_TOKEN, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + # Simulate that the user gives up and closes the window... + hass.config_entries.flow._async_remove_flow_progress(result["flow_id"]) + await hass.async_block_till_done() + + with pytest.raises(UnknownFlow): + hass.config_entries.flow.async_get(result["flow_id"]) From 640463c55908a6f366aa9e321b85929132294159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 31 Jan 2024 14:50:18 +0100 Subject: [PATCH 1247/1544] Add Traccar server integration (#109002) * Add Traccar server integration * Add explination * Update homeassistant/components/traccar_server/coordinator.py Co-authored-by: Joost Lekkerkerker * Add data_description --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 5 + CODEOWNERS | 2 + .../components/traccar_server/__init__.py | 70 +++++++ .../components/traccar_server/config_flow.py | 168 ++++++++++++++++ .../components/traccar_server/const.py | 39 ++++ .../components/traccar_server/coordinator.py | 165 +++++++++++++++ .../traccar_server/device_tracker.py | 85 ++++++++ .../components/traccar_server/entity.py | 59 ++++++ .../components/traccar_server/helpers.py | 23 +++ .../components/traccar_server/manifest.json | 9 + .../components/traccar_server/strings.json | 45 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/traccar_server/__init__.py | 1 + tests/components/traccar_server/conftest.py | 14 ++ .../traccar_server/test_config_flow.py | 189 ++++++++++++++++++ 18 files changed, 883 insertions(+) create mode 100644 homeassistant/components/traccar_server/__init__.py create mode 100644 homeassistant/components/traccar_server/config_flow.py create mode 100644 homeassistant/components/traccar_server/const.py create mode 100644 homeassistant/components/traccar_server/coordinator.py create mode 100644 homeassistant/components/traccar_server/device_tracker.py create mode 100644 homeassistant/components/traccar_server/entity.py create mode 100644 homeassistant/components/traccar_server/helpers.py create mode 100644 homeassistant/components/traccar_server/manifest.json create mode 100644 homeassistant/components/traccar_server/strings.json create mode 100644 tests/components/traccar_server/__init__.py create mode 100644 tests/components/traccar_server/conftest.py create mode 100644 tests/components/traccar_server/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0b6eaecfb31..34b6dde9854 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1424,6 +1424,11 @@ omit = homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py + homeassistant/components/traccar_server/__init__.py + homeassistant/components/traccar_server/coordinator.py + homeassistant/components/traccar_server/device_tracker.py + homeassistant/components/traccar_server/entity.py + homeassistant/components/traccar_server/helpers.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py homeassistant/components/tractive/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index 83fcf1e6d00..09f18ae3476 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1394,6 +1394,8 @@ build.json @home-assistant/supervisor /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus +/homeassistant/components/traccar_server/ @ludeeus +/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py new file mode 100644 index 00000000000..53770757c81 --- /dev/null +++ b/homeassistant/components/traccar_server/__init__.py @@ -0,0 +1,70 @@ +"""The Traccar Server integration.""" +from __future__ import annotations + +from pytraccar import ApiClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, +) +from .coordinator import TraccarServerCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Traccar Server from a config entry.""" + coordinator = TraccarServerCoordinator( + hass=hass, + client=ApiClient( + client_session=async_get_clientsession(hass), + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ssl=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ), + events=entry.options.get(CONF_EVENTS, []), + max_accuracy=entry.options.get(CONF_MAX_ACCURACY, 0.0), + skip_accuracy_filter_for=entry.options.get(CONF_SKIP_ACCURACY_FILTER_FOR, []), + custom_attributes=entry.options.get(CONF_CUSTOM_ATTRIBUTES, []), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py new file mode 100644 index 00000000000..11a23b21bf6 --- /dev/null +++ b/homeassistant/components/traccar_server/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow for Traccar Server integration.""" +from __future__ import annotations + +from typing import Any + +from pytraccar import ApiClient, ServerModel, TraccarException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, + EVENTS, + LOGGER, +) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_PORT, default="8082"): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL, default=False): BooleanSelector(BooleanSelectorConfig()), + vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector( + BooleanSelectorConfig() + ), + } +) + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=vol.Schema( + { + vol.Optional(CONF_MAX_ACCURACY, default=0.0): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=0.0, + ) + ), + vol.Optional(CONF_CUSTOM_ATTRIBUTES, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=[], + ) + ), + vol.Optional(CONF_SKIP_ACCURACY_FILTER_FOR, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=[], + ) + ), + vol.Optional(CONF_EVENTS, default=[]): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + sort=True, + custom_value=True, + options=list(EVENTS), + ) + ), + } + ) + ), +} + + +class TraccarServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Traccar Server.""" + + async def _get_server_info(self, user_input: dict[str, Any]) -> ServerModel: + """Get server info.""" + client = ApiClient( + client_session=async_get_clientsession(self.hass), + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ssl=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + return await client.get_server() + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + try: + await self._get_server_info(user_input) + except TraccarException as exception: + LOGGER.error("Unable to connect to Traccar Server: %s", exception) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> SchemaOptionsFlowHandler: + """Get the options flow for this handler.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/traccar_server/const.py b/homeassistant/components/traccar_server/const.py new file mode 100644 index 00000000000..ca95e706d61 --- /dev/null +++ b/homeassistant/components/traccar_server/const.py @@ -0,0 +1,39 @@ +"""Constants for the Traccar Server integration.""" +from logging import getLogger + +DOMAIN = "traccar_server" +LOGGER = getLogger(__package__) + +ATTR_ADDRESS = "address" +ATTR_ALTITUDE = "altitude" +ATTR_CATEGORY = "category" +ATTR_GEOFENCE = "geofence" +ATTR_MOTION = "motion" +ATTR_SPEED = "speed" +ATTR_STATUS = "status" +ATTR_TRACKER = "tracker" +ATTR_TRACCAR_ID = "traccar_id" + +CONF_MAX_ACCURACY = "max_accuracy" +CONF_CUSTOM_ATTRIBUTES = "custom_attributes" +CONF_EVENTS = "events" +CONF_SKIP_ACCURACY_FILTER_FOR = "skip_accuracy_filter_for" + +EVENTS = { + "deviceMoving": "device_moving", + "commandResult": "command_result", + "deviceFuelDrop": "device_fuel_drop", + "geofenceEnter": "geofence_enter", + "deviceOffline": "device_offline", + "driverChanged": "driver_changed", + "geofenceExit": "geofence_exit", + "deviceOverspeed": "device_overspeed", + "deviceOnline": "device_online", + "deviceStopped": "device_stopped", + "maintenance": "maintenance", + "alarm": "alarm", + "textMessage": "text_message", + "deviceUnknown": "device_unknown", + "ignitionOff": "ignition_off", + "ignitionOn": "ignition_on", +} diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py new file mode 100644 index 00000000000..337d0dcafbb --- /dev/null +++ b/homeassistant/components/traccar_server/coordinator.py @@ -0,0 +1,165 @@ +"""Data update coordinator for Traccar Server.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, TypedDict + +from pytraccar import ( + ApiClient, + DeviceModel, + GeofenceModel, + PositionModel, + TraccarException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, EVENTS, LOGGER +from .helpers import get_device, get_first_geofence + + +class TraccarServerCoordinatorDataDevice(TypedDict): + """Traccar Server coordinator data.""" + + device: DeviceModel + geofence: GeofenceModel | None + position: PositionModel + attributes: dict[str, Any] + + +TraccarServerCoordinatorData = dict[str, TraccarServerCoordinatorDataDevice] + + +class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]): + """Class to manage fetching Traccar Server data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: ApiClient, + *, + events: list[str], + max_accuracy: float, + skip_accuracy_filter_for: list[str], + custom_attributes: list[str], + ) -> None: + """Initialize global Traccar Server data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = client + self.custom_attributes = custom_attributes + self.events = events + self.max_accuracy = max_accuracy + self.skip_accuracy_filter_for = skip_accuracy_filter_for + self._last_event_import: datetime | None = None + + async def _async_update_data(self) -> TraccarServerCoordinatorData: + """Fetch data from Traccar Server.""" + LOGGER.debug("Updating device data") + data: TraccarServerCoordinatorData = {} + try: + ( + devices, + positions, + geofences, + ) = await asyncio.gather( + self.client.get_devices(), + self.client.get_positions(), + self.client.get_geofences(), + ) + except TraccarException as ex: + raise UpdateFailed("Error while updating device data: %s") from ex + + if TYPE_CHECKING: + assert isinstance(devices, list[DeviceModel]) # type: ignore[misc] + assert isinstance(positions, list[PositionModel]) # type: ignore[misc] + assert isinstance(geofences, list[GeofenceModel]) # type: ignore[misc] + + for position in positions: + if (device := get_device(position["deviceId"], devices)) is None: + continue + + attr = {} + skip_accuracy_filter = False + + for custom_attr in self.custom_attributes: + attr[custom_attr] = getattr( + device["attributes"], + custom_attr, + getattr(position["attributes"], custom_attr, None), + ) + if custom_attr in self.skip_accuracy_filter_for: + skip_accuracy_filter = True + + accuracy = position["accuracy"] or 0.0 + if ( + not skip_accuracy_filter + and self.max_accuracy > 0 + and accuracy > self.max_accuracy + ): + LOGGER.debug( + "Excluded position by accuracy filter: %f (%s)", + accuracy, + device["id"], + ) + continue + + data[device["uniqueId"]] = { + "device": device, + "geofence": get_first_geofence( + geofences, + position["geofenceIds"] or [], + ), + "position": position, + "attributes": attr, + } + + if self.events: + self.hass.async_create_task(self.import_events(devices)) + + return data + + async def import_events(self, devices: list[DeviceModel]) -> None: + """Import events from Traccar.""" + start_time = dt_util.utcnow().replace(tzinfo=None) + end_time = None + + if self._last_event_import is not None: + end_time = start_time - (start_time - self._last_event_import) + + events = await self.client.get_reports_events( + devices=[device["id"] for device in devices], + start_time=start_time, + end_time=end_time, + event_types=self.events, + ) + if not events: + return + + self._last_event_import = start_time + for event in events: + device = get_device(event["deviceId"], devices) + self.hass.bus.async_fire( + # This goes against two of the HA core guidelines: + # 1. Event names should be prefixed with the domain name of the integration + # 2. This should be event entities + # However, to not break it for those who currently use the "old" integration, this is kept as is. + f"traccar_{EVENTS[event['type']]}", + { + "device_traccar_id": event["deviceId"], + "device_name": getattr(device, "name", None), + "type": event["type"], + "serverTime": event["eventTime"], + "attributes": event["attributes"], + }, + ) diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py new file mode 100644 index 00000000000..2abcc6398fb --- /dev/null +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -0,0 +1,85 @@ +"""Support for Traccar server device tracking.""" +from __future__ import annotations + +from typing import Any + +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 ( + ATTR_ADDRESS, + ATTR_ALTITUDE, + ATTR_CATEGORY, + ATTR_GEOFENCE, + ATTR_MOTION, + ATTR_SPEED, + ATTR_STATUS, + ATTR_TRACCAR_ID, + ATTR_TRACKER, + DOMAIN, +) +from .coordinator import TraccarServerCoordinator +from .entity import TraccarServerEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up device tracker entities.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TraccarServerDeviceTracker(coordinator, entry["device"]) + for entry in coordinator.data.values() + ) + + +class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): + """Represent a tracked device.""" + + _attr_has_entity_name = True + _attr_name = None + + @property + def battery_level(self) -> int: + """Return battery value of the device.""" + return self.traccar_position["attributes"].get("batteryLevel", -1) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + return { + **self.traccar_attributes, + ATTR_ADDRESS: self.traccar_position["address"], + ATTR_ALTITUDE: self.traccar_position["altitude"], + ATTR_CATEGORY: self.traccar_device["category"], + ATTR_GEOFENCE: getattr(self.traccar_geofence, "name", None), + ATTR_MOTION: self.traccar_position["attributes"].get("motion", False), + ATTR_SPEED: self.traccar_position["speed"], + ATTR_STATUS: self.traccar_device["status"], + ATTR_TRACCAR_ID: self.traccar_device["id"], + ATTR_TRACKER: DOMAIN, + } + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return self.traccar_position["latitude"] + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return self.traccar_position["longitude"] + + @property + def location_accuracy(self) -> int: + """Return the gps accuracy of the device.""" + return self.traccar_position["accuracy"] + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.GPS diff --git a/homeassistant/components/traccar_server/entity.py b/homeassistant/components/traccar_server/entity.py new file mode 100644 index 00000000000..d44c78cafae --- /dev/null +++ b/homeassistant/components/traccar_server/entity.py @@ -0,0 +1,59 @@ +"""Base entity for Traccar Server.""" +from __future__ import annotations + +from typing import Any + +from pytraccar import DeviceModel, GeofenceModel, PositionModel + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TraccarServerCoordinator + + +class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]): + """Base entity for Traccar Server.""" + + def __init__( + self, + coordinator: TraccarServerCoordinator, + device: DeviceModel, + ) -> None: + """Initialize the Traccar Server entity.""" + super().__init__(coordinator) + self.device_id = device["uniqueId"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device["uniqueId"])}, + model=device["model"], + name=device["name"], + ) + self._attr_unique_id = device["uniqueId"] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.coordinator.last_update_success + and self.device_id in self.coordinator.data + ) + + @property + def traccar_device(self) -> DeviceModel: + """Return the device.""" + return self.coordinator.data[self.device_id]["device"] + + @property + def traccar_geofence(self) -> GeofenceModel | None: + """Return the geofence.""" + return self.coordinator.data[self.device_id]["geofence"] + + @property + def traccar_position(self) -> PositionModel: + """Return the position.""" + return self.coordinator.data[self.device_id]["position"] + + @property + def traccar_attributes(self) -> dict[str, Any]: + """Return the attributes.""" + return self.coordinator.data[self.device_id]["attributes"] diff --git a/homeassistant/components/traccar_server/helpers.py b/homeassistant/components/traccar_server/helpers.py new file mode 100644 index 00000000000..ee812c35b8b --- /dev/null +++ b/homeassistant/components/traccar_server/helpers.py @@ -0,0 +1,23 @@ +"""Helper functions for the Traccar Server integration.""" +from __future__ import annotations + +from pytraccar import DeviceModel, GeofenceModel + + +def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None: + """Return the device.""" + return next( + (dev for dev in devices if dev["id"] == device_id), + None, + ) + + +def get_first_geofence( + geofences: list[GeofenceModel], + target: list[int], +) -> GeofenceModel | None: + """Return the geofence.""" + return next( + (geofence for geofence in geofences if geofence["id"] in target), + None, + ) diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json new file mode 100644 index 00000000000..ca284dd02dd --- /dev/null +++ b/homeassistant/components/traccar_server/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "traccar_server", + "name": "Traccar Server", + "codeowners": ["@ludeeus"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/traccar_server", + "iot_class": "local_polling", + "requirements": ["pytraccar==2.0.0"] +} diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json new file mode 100644 index 00000000000..87da7e8cdd1 --- /dev/null +++ b/homeassistant/components/traccar_server/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your Traccar Server", + "username": "The username (email) you use to login to your Traccar Server" + } + } + }, + "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_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "max_accuracy": "Max accuracy", + "skip_accuracy_filter_for": "Position skip filter for attributes", + "custom_attributes": "Custom attributes", + "events": "Events" + }, + "data_description": { + "max_accuracy": "Any position reports with accuracy higher than this value will be ignored", + "skip_accuracy_filter_for": "Attributes defined here will bypass the accuracy filter if they are present in the update", + "custom_attributes": "Add any custom or calculated attributes here. These will be added to the device attributes", + "events": "Selected events will be fired in Home Assistant" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2a3a9bea392..80d3f7310b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -538,6 +538,7 @@ FLOWS = { "tplink", "tplink_omada", "traccar", + "traccar_server", "tractive", "tradfri", "trafikverket_camera", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cf70feca4eb..071642500ba 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6168,6 +6168,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "traccar_server": { + "name": "Traccar Server", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "tractive": { "name": "Tractive", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 7d6fea865c7..857c320e879 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2305,6 +2305,7 @@ pytomorrowio==0.3.6 pytouchline==0.7 # homeassistant.components.traccar +# homeassistant.components.traccar_server pytraccar==2.0.0 # homeassistant.components.tradfri diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39fcb8183b9..753004066c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,6 +1760,7 @@ pytile==2023.04.0 pytomorrowio==0.3.6 # homeassistant.components.traccar +# homeassistant.components.traccar_server pytraccar==2.0.0 # homeassistant.components.tradfri diff --git a/tests/components/traccar_server/__init__.py b/tests/components/traccar_server/__init__.py new file mode 100644 index 00000000000..7b7a59d3b61 --- /dev/null +++ b/tests/components/traccar_server/__init__.py @@ -0,0 +1 @@ +"""Tests for the Traccar Server integration.""" diff --git a/tests/components/traccar_server/conftest.py b/tests/components/traccar_server/conftest.py new file mode 100644 index 00000000000..4141b28849c --- /dev/null +++ b/tests/components/traccar_server/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Traccar Server tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.traccar_server.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py new file mode 100644 index 00000000000..67358078869 --- /dev/null +++ b/tests/components/traccar_server/test_config_flow.py @@ -0,0 +1,189 @@ +"""Test the Traccar Server config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pytraccar import TraccarException + +from homeassistant import config_entries +from homeassistant.components.traccar_server.const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.traccar_server.config_flow.ApiClient.get_server", + return_value={"id": "1234"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "1.1.1.1:8082" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + ( + (TraccarException, "cannot_connect"), + (Exception, "unknown"), + ), +) +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.traccar_server.config_flow.ApiClient.get_server", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "homeassistant.components.traccar_server.config_flow.ApiClient.get_server", + return_value={"id": "1234"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "1.1.1.1:8082" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert CONF_MAX_ACCURACY not in config_entry.options + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MAX_ACCURACY: 2.0}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_MAX_ACCURACY: 2.0, + CONF_EVENTS: [], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + } + + +async def test_abort_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test abort for existing server.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"}, + ) + + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 71c246016131e7b476a537b9ee15fb599b962a2c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 31 Jan 2024 14:57:08 +0100 Subject: [PATCH 1248/1544] Move tankerkoenig to new aiotankerkoenig package (#108913) * Move tankerkoenig to new aiotankerkoenig package * Fix config flow coverage * Process code review suggestions * Process code review suggestions --- CODEOWNERS | 4 +- .../components/tankerkoenig/__init__.py | 26 +--- .../components/tankerkoenig/binary_sensor.py | 33 ++--- .../components/tankerkoenig/config_flow.py | 89 ++++++++----- .../components/tankerkoenig/coordinator.py | 81 ++++-------- .../components/tankerkoenig/entity.py | 14 +- .../components/tankerkoenig/manifest.json | 6 +- .../components/tankerkoenig/sensor.py | 70 +++++----- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/tankerkoenig/conftest.py | 75 +++++++++++ tests/components/tankerkoenig/const.py | 59 +++++++++ .../snapshots/test_diagnostics.ambr | 6 +- .../tankerkoenig/test_config_flow.py | 124 +++++++++--------- .../tankerkoenig/test_diagnostics.py | 88 +------------ 15 files changed, 367 insertions(+), 320 deletions(-) create mode 100644 tests/components/tankerkoenig/conftest.py create mode 100644 tests/components/tankerkoenig/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 09f18ae3476..9691a8d72f6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1336,8 +1336,8 @@ build.json @home-assistant/supervisor /tests/components/tailwind/ @frenck /homeassistant/components/tami4/ @Guy293 /tests/components/tami4/ @Guy293 -/homeassistant/components/tankerkoenig/ @guillempages @mib1185 -/tests/components/tankerkoenig/ @guillempages @mib1185 +/homeassistant/components/tankerkoenig/ @guillempages @mib1185 @jpbede +/tests/components/tankerkoenig/ @guillempages @mib1185 @jpbede /homeassistant/components/tapsaff/ @bazwilliams /homeassistant/components/tasmota/ @emontnemery /tests/components/tasmota/ @emontnemery diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index ac93154388a..3f86ef03df7 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -1,22 +1,14 @@ """Ask tankerkoenig.de for petrol price information.""" from __future__ import annotations -import logging - -from requests.exceptions import RequestException - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import TankerkoenigDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -25,24 +17,18 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator = TankerkoenigDataUpdateCoordinator( + + coordinator = TankerkoenigDataUpdateCoordinator( hass, entry, - _LOGGER, name=entry.unique_id or DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) - - try: - setup_ok = await hass.async_add_executor_job(coordinator.setup) - except RequestException as err: - raise ConfigEntryNotReady from err - if not setup_ok: - _LOGGER.error("Could not setup integration") - return False - + await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 2cf8869fcae..640708e1cb4 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from aiotankerkoenig import PriceInfo, Station, Status + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -23,21 +25,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the tankerkoenig binary sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - stations = coordinator.stations.values() - entities = [] - for station in stations: - sensor = StationOpenBinarySensorEntity( + async_add_entities( + StationOpenBinarySensorEntity( station, coordinator, - coordinator.show_on_map, ) - entities.append(sensor) - _LOGGER.debug("Added sensors %s", entities) - - async_add_entities(entities) + for station in coordinator.stations.values() + ) class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): @@ -48,22 +44,21 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE def __init__( self, - station: dict, + station: Station, coordinator: TankerkoenigDataUpdateCoordinator, - show_on_map: bool, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, station) - self._station_id = station["id"] - self._attr_unique_id = f"{station['id']}_status" - if show_on_map: + self._station_id = station.id + self._attr_unique_id = f"{station.id}_status" + if coordinator.show_on_map: self._attr_extra_state_attributes = { - ATTR_LATITUDE: station["lat"], - ATTR_LONGITUDE: station["lng"], + ATTR_LATITUDE: station.lat, + ATTR_LONGITUDE: station.lng, } @property def is_on(self) -> bool | None: """Return true if the station is open.""" - data: dict = self.coordinator.data[self._station_id] - return data is not None and data.get("status") == "open" + data: PriceInfo = self.coordinator.data[self._station_id] + return data is not None and data.status == Status.OPEN diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 79f6349f0cb..e15bfbfeb94 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -4,7 +4,13 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pytankerkoenig import customException, getNearbyStations +from aiotankerkoenig import ( + GasType, + Sort, + Station, + Tankerkoenig, + TankerkoenigInvalidKeyError, +) import voluptuous as vol from homeassistant import config_entries @@ -18,8 +24,9 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, UnitOfLength, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( LocationSelector, @@ -31,21 +38,18 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_ async def async_get_nearby_stations( - hass: HomeAssistant, data: Mapping[str, Any] -) -> dict[str, Any]: + tankerkoenig: Tankerkoenig, data: Mapping[str, Any] +) -> list[Station]: """Fetch nearby stations.""" - try: - return await hass.async_add_executor_job( - getNearbyStations, - data[CONF_API_KEY], + return await tankerkoenig.nearby_stations( + coordinates=( data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE], - data[CONF_RADIUS], - "all", - "dist", - ) - except customException as err: - return {"ok": False, "message": err, "exception": True} + ), + radius=data[CONF_RADIUS], + gas_type=GasType.ALL, + sort=Sort.DISTANCE, + ) class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -53,11 +57,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Init the FlowHandler.""" - super().__init__() - self._data: dict[str, Any] = {} - self._stations: dict[str, str] = {} + _data: dict[str, Any] = {} + _stations: dict[str, str] = {} @staticmethod @callback @@ -79,17 +80,25 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - data = await async_get_nearby_stations(self.hass, user_input) - if not data.get("ok"): + tankerkoenig = Tankerkoenig( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + try: + stations = await async_get_nearby_stations(tankerkoenig, user_input) + except TankerkoenigInvalidKeyError: return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} ) - if len(stations := data.get("stations", [])) == 0: + + # no stations found + if len(stations) == 0: return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"}) + for station in stations: - self._stations[station["id"]] = ( - f"{station['brand']} {station['street']} {station['houseNumber']} -" - f" ({station['dist']}km)" + self._stations[station.id] = ( + f"{station.brand} {station.street} {station.house_number} -" + f" ({station.distance}km)" ) self._data = user_input @@ -128,8 +137,14 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry user_input = {**entry.data, **user_input} - data = await async_get_nearby_stations(self.hass, user_input) - if not data.get("ok"): + + tankerkoenig = Tankerkoenig( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + try: + await async_get_nearby_stations(tankerkoenig, user_input) + except TankerkoenigInvalidKeyError: return self._show_form_reauth(user_input, {CONF_API_KEY: "invalid_auth"}) self.hass.config_entries.async_update_entry(entry, data=user_input) @@ -233,14 +248,22 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ) return self.async_create_entry(title="", data=user_input) - nearby_stations = await async_get_nearby_stations( - self.hass, self.config_entry.data + tankerkoenig = Tankerkoenig( + api_key=self.config_entry.data[CONF_API_KEY], + session=async_get_clientsession(self.hass), ) - if stations := nearby_stations.get("stations"): + try: + stations = await async_get_nearby_stations( + tankerkoenig, self.config_entry.data + ) + except TankerkoenigInvalidKeyError: + return self.async_show_form(step_id="init", errors={"base": "invalid_auth"}) + + if stations: for station in stations: - self._stations[station["id"]] = ( - f"{station['brand']} {station['street']} {station['houseNumber']} -" - f" ({station['dist']}km)" + self._stations[station.id] = ( + f"{station.brand} {station.street} {station.house_number} -" + f" ({station.distance}km)" ) # add possible extra selected stations from import diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 536875f5733..f1f200a5964 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -5,13 +5,21 @@ from datetime import timedelta import logging from math import ceil -import pytankerkoenig +from aiotankerkoenig import ( + PriceInfo, + Station, + Tankerkoenig, + TankerkoenigConnectionError, + TankerkoenigError, + TankerkoenigInvalidKeyError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_FUEL_TYPES, CONF_STATIONS @@ -25,7 +33,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, entry: ConfigEntry, - logger: logging.Logger, name: str, update_interval: int, ) -> None: @@ -33,50 +40,41 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, - logger=logger, + logger=_LOGGER, name=name, update_interval=timedelta(minutes=update_interval), ) - self._api_key: str = entry.data[CONF_API_KEY] self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self.stations: dict[str, dict] = {} + self.stations: dict[str, Station] = {} self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] - def setup(self) -> bool: + self._tankerkoenig = Tankerkoenig( + api_key=entry.data[CONF_API_KEY], session=async_get_clientsession(hass) + ) + + async def async_setup(self) -> None: """Set up the tankerkoenig API.""" for station_id in self._selected_stations: try: - station_data = pytankerkoenig.getStationData(self._api_key, station_id) - except pytankerkoenig.customException as err: - if any(x in str(err).lower() for x in ("api-key", "apikey")): - raise ConfigEntryAuthFailed(err) from err - station_data = { - "ok": False, - "message": err, - "exception": True, - } + station = await self._tankerkoenig.station_details(station_id) + except TankerkoenigInvalidKeyError as err: + raise ConfigEntryAuthFailed(err) from err + except (TankerkoenigError, TankerkoenigConnectionError) as err: + raise ConfigEntryNotReady(err) from err + + self.stations[station_id] = station - if not station_data["ok"]: - _LOGGER.error( - "Error when adding station %s:\n %s", - station_id, - station_data["message"], - ) - continue - self.add_station(station_data["station"]) if len(self.stations) > 10: _LOGGER.warning( "Found more than 10 stations to check. " "This might invalidate your api-key on the long run. " "Try using a smaller radius" ) - return True - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, PriceInfo]: """Get the latest data from tankerkoenig.de.""" - _LOGGER.debug("Fetching new data from tankerkoenig.de") station_ids = list(self.stations) prices = {} @@ -84,30 +82,9 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self.hass.async_add_executor_job( - pytankerkoenig.getPriceList, - self._api_key, - station_ids[index * 10 : (index + 1) * 10], + data = await self._tankerkoenig.prices( + station_ids[index * 10 : (index + 1) * 10] ) + prices.update(data) - _LOGGER.debug("Received data: %s", data) - if not data["ok"]: - raise UpdateFailed(data["message"]) - if "prices" not in data: - raise UpdateFailed( - "Did not receive price information from tankerkoenig.de" - ) - prices.update(data["prices"]) return prices - - def add_station(self, station: dict): - """Add fuel station to the entity list.""" - station_id = station["id"] - if station_id in self.stations: - _LOGGER.warning( - "Sensor for station with id %s was already created", station_id - ) - return - - self.stations[station_id] = station - _LOGGER.debug("add_station called for station: %s", station) diff --git a/homeassistant/components/tankerkoenig/entity.py b/homeassistant/components/tankerkoenig/entity.py index 6fbd9057679..96dafa80580 100644 --- a/homeassistant/components/tankerkoenig/entity.py +++ b/homeassistant/components/tankerkoenig/entity.py @@ -1,4 +1,6 @@ """The tankerkoenig base entity.""" +from aiotankerkoenig import Station + from homeassistant.const import ATTR_ID from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -6,20 +8,22 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import TankerkoenigDataUpdateCoordinator -class TankerkoenigCoordinatorEntity(CoordinatorEntity): +class TankerkoenigCoordinatorEntity( + CoordinatorEntity[TankerkoenigDataUpdateCoordinator] +): """Tankerkoenig base entity.""" _attr_has_entity_name = True def __init__( - self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + self, coordinator: TankerkoenigDataUpdateCoordinator, station: Station ) -> None: """Initialize the Tankerkoenig base entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], + identifiers={(ATTR_ID, station.id)}, + name=f"{station.brand} {station.street} {station.house_number}", + model=station.brand, configuration_url="https://www.tankerkoenig.de", entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 39351b9dd27..bf8896196ef 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -1,10 +1,10 @@ { "domain": "tankerkoenig", "name": "Tankerkoenig", - "codeowners": ["@guillempages", "@mib1185"], + "codeowners": ["@guillempages", "@mib1185", "@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", - "loggers": ["pytankerkoenig"], - "requirements": ["pytankerkoenig==0.0.6"] + "loggers": ["aiotankerkoenig"], + "requirements": ["aiotankerkoenig==0.2.0"] } diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 9d839781990..c0394bd318f 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations 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 @@ -30,26 +32,28 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the tankerkoenig sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - stations = coordinator.stations.values() entities = [] - for station in stations: - for fuel in coordinator.fuel_types: - if fuel not in station: - _LOGGER.warning( - "Station %s does not offer %s fuel", station["id"], fuel + for station in coordinator.stations.values(): + for fuel in (GasType.E10, GasType.E5, GasType.DIESEL): + if getattr(station, fuel) is None: + _LOGGER.debug( + "Station %s %s (%s) does not offer %s fuel, skipping", + station.brand, + station.name, + station.id, + fuel, ) continue - sensor = FuelPriceSensor( - fuel, - station, - coordinator, - coordinator.show_on_map, + + entities.append( + FuelPriceSensor( + fuel, + station, + coordinator, + ) ) - entities.append(sensor) - _LOGGER.debug("Added sensors %s", entities) async_add_entities(entities) @@ -61,31 +65,35 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = CURRENCY_EURO - def __init__(self, fuel_type, station, coordinator, show_on_map): + def __init__( + self, + fuel_type: GasType, + station: Station, + coordinator: TankerkoenigDataUpdateCoordinator, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator, station) - self._station_id = station["id"] + self._station_id = station.id self._fuel_type = fuel_type self._attr_translation_key = fuel_type - self._attr_unique_id = f"{station['id']}_{fuel_type}" + self._attr_unique_id = f"{station.id}_{fuel_type}" attrs = { - ATTR_BRAND: station["brand"], + ATTR_BRAND: station.brand, ATTR_FUEL_TYPE: fuel_type, - ATTR_STATION_NAME: station["name"], - ATTR_STREET: station["street"], - ATTR_HOUSE_NUMBER: station["houseNumber"], - ATTR_POSTCODE: station["postCode"], - ATTR_CITY: station["place"], + ATTR_STATION_NAME: station.name, + ATTR_STREET: station.street, + ATTR_HOUSE_NUMBER: station.house_number, + ATTR_POSTCODE: station.post_code, + ATTR_CITY: station.place, } - if show_on_map: - attrs[ATTR_LATITUDE] = station["lat"] - attrs[ATTR_LONGITUDE] = station["lng"] + if coordinator.show_on_map: + attrs[ATTR_LATITUDE] = str(station.lat) + attrs[ATTR_LONGITUDE] = str(station.lng) self._attr_extra_state_attributes = attrs @property - def native_value(self): - """Return the state of the device.""" - # key Fuel_type is not available when the fuel station is closed, - # use "get" instead of "[]" to avoid exceptions - return self.coordinator.data[self._station_id].get(self._fuel_type) + def native_value(self) -> float: + """Return the current price for the fuel type.""" + info: PriceInfo = self.coordinator.data[self._station_id] + return getattr(info, self._fuel_type) diff --git a/requirements_all.txt b/requirements_all.txt index 857c320e879..b9d7cb8ace7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -376,6 +376,9 @@ aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tankerkoenig +aiotankerkoenig==0.2.0 + # homeassistant.components.tractive aiotractive==0.5.6 @@ -2156,9 +2159,6 @@ pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.tankerkoenig -pytankerkoenig==0.0.6 - # homeassistant.components.tautulli pytautulli==23.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 753004066c4..1a535c5cd01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -349,6 +349,9 @@ aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tankerkoenig +aiotankerkoenig==0.2.0 + # homeassistant.components.tractive aiotractive==0.5.6 @@ -1665,9 +1668,6 @@ pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 -# homeassistant.components.tankerkoenig -pytankerkoenig==0.0.6 - # homeassistant.components.tautulli pytautulli==23.1.1 diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py new file mode 100644 index 00000000000..011dcf5e7bd --- /dev/null +++ b/tests/components/tankerkoenig/conftest.py @@ -0,0 +1,75 @@ +"""Fixtures for Tankerkoenig integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_SHOW_ON_MAP, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import NEARBY_STATIONS, PRICES, STATION + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="tankerkoenig") +def mock_tankerkoenig() -> Generator[AsyncMock, None, None]: + """Mock the aiotankerkoenig client.""" + with patch( + "homeassistant.components.tankerkoenig.coordinator.Tankerkoenig", + autospec=True, + ) as mock_tankerkoenig, patch( + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig", + new=mock_tankerkoenig, + ): + mock = mock_tankerkoenig.return_value + mock.station_details.return_value = STATION + mock.prices.return_value = PRICES + mock.nearby_stations.return_value = NEARBY_STATIONS + yield mock + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + title="Mock Title", + unique_id="51.0_13.0", + entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", + options={ + CONF_SHOW_ON_MAP: True, + }, + data={ + CONF_NAME: "Home", + CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", + CONF_FUEL_TYPES: ["e5"], + CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, + CONF_RADIUS: 2.0, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + ], + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, tankerkoenig: AsyncMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py new file mode 100644 index 00000000000..9ec64eb79a9 --- /dev/null +++ b/tests/components/tankerkoenig/const.py @@ -0,0 +1,59 @@ +"""Constants for the Tankerkoenig tests.""" + +from aiotankerkoenig import PriceInfo, Station, Status + +NEARBY_STATIONS = [ + Station( + id="3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + brand="BrandA", + place="CityA", + street="Main", + house_number="1", + distance=1, + lat=51.1, + lng=13.1, + name="Station ABC", + post_code=1234, + ), + Station( + id="36b4b812-xxxx-xxxx-xxxx-c51735325858", + brand="BrandB", + place="CityB", + street="School", + house_number="2", + distance=2, + lat=51.2, + lng=13.2, + name="Station DEF", + post_code=2345, + ), +] + +STATION = Station( + id="3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + name="Station ABC", + brand="Station", + street="Somewhere Street", + house_number="1", + post_code=1234, + place="Somewhere", + opening_times=[], + overrides=[], + whole_day=True, + is_open=True, + e5=1.719, + e10=1.659, + diesel=1.659, + lat=51.1, + lng=13.1, + state="xxXX", +) + +PRICES = { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": PriceInfo( + status=Status.OPEN, + e5=1.719, + e10=1.659, + diesel=1.659, + ), +} diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index f52cb3a88a5..a27a210c46e 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -3,10 +3,8 @@ dict({ 'data': dict({ '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8': dict({ - 'diesel': 1.659, - 'e10': 1.659, - 'e5': 1.719, - 'status': 'open', + '__type': "", + 'repr': "PriceInfo(status=, e5=1.719, e10=1.659, diesel=1.659)", }), }), 'entry': dict({ diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index da34cf66894..db3d0aac222 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for Tankerkoenig config flow.""" from unittest.mock import patch -from pytankerkoenig import customException +from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError from homeassistant.components.tankerkoenig.const import ( CONF_FUEL_TYPES, @@ -21,6 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import NEARBY_STATIONS + from tests.common import MockConfigEntry MOCK_USER_DATA = { @@ -47,28 +49,6 @@ MOCK_OPTIONS_DATA = { ], } -MOCK_NEARVY_STATIONS_OK = { - "ok": True, - "stations": [ - { - "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "brand": "BrandA", - "place": "CityA", - "street": "Main", - "houseNumber": "1", - "dist": 1, - }, - { - "id": "36b4b812-xxxx-xxxx-xxxx-c51735325858", - "brand": "BrandB", - "place": "CityB", - "street": "School", - "houseNumber": "2", - "dist": 2, - }, - ], -} - async def test_user(hass: HomeAssistant) -> None: """Test starting a flow by user.""" @@ -81,8 +61,8 @@ async def test_user(hass: HomeAssistant) -> None: with patch( "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value=MOCK_NEARVY_STATIONS_OK, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=NEARBY_STATIONS, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -143,8 +123,8 @@ async def test_exception_security(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - side_effect=customException, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + side_effect=TankerkoenigInvalidKeyError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -163,8 +143,8 @@ async def test_user_no_stations(hass: HomeAssistant) -> None: assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value={"ok": True, "stations": []}, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=[], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA @@ -174,32 +154,26 @@ async def test_user_no_stations(hass: HomeAssistant) -> None: assert result["errors"][CONF_RADIUS] == "no_stations" -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test starting a flow by user to re-auth.""" - - mock_config = MockConfigEntry( - domain=DOMAIN, - data={**MOCK_USER_DATA, **MOCK_STATIONS_DATA}, - unique_id=f"{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", - ) - mock_config.add_to_hass(hass) + config_entry.add_to_hass(hass) with patch( "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", ) as mock_nearby_stations: # re-auth initialized result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, - data=mock_config.data, + context={"source": SOURCE_REAUTH, "entry_id": config_entry.entry_id}, + data=config_entry.data, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" # re-auth unsuccessful - mock_nearby_stations.return_value = {"ok": False} + mock_nearby_stations.side_effect = TankerkoenigInvalidKeyError("Booom!") result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -211,7 +185,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_API_KEY: "invalid_auth"} # re-auth successful - mock_nearby_stations.return_value = MOCK_NEARVY_STATIONS_OK + mock_nearby_stations.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -223,7 +197,7 @@ async def test_reauth(hass: HomeAssistant) -> None: mock_setup_entry.assert_called() - entry = hass.config_entries.async_get_entry(mock_config.entry_id) + entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert entry.data[CONF_API_KEY] == "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx" @@ -239,24 +213,52 @@ async def test_options_flow(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) with patch( - "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", - return_value=MOCK_NEARVY_STATIONS_OK, + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + return_value=NEARBY_STATIONS, ): - await hass.config_entries.async_setup(mock_config.entry_id) - await hass.async_block_till_done() - assert mock_setup_entry.called - result = await hass.config_entries.options.async_init(mock_config.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_SHOW_ON_MAP: False, - CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], - }, + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert not mock_config.options[CONF_SHOW_ON_MAP] + + +async def test_options_flow_error(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_OPTIONS_DATA, + options={CONF_SHOW_ON_MAP: True}, + unique_id=f"{DOMAIN}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert not mock_config.options[CONF_SHOW_ON_MAP] + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.tankerkoenig.config_flow.Tankerkoenig.nearby_stations", + side_effect=TankerkoenigInvalidKeyError("Booom!"), + ) as mock_nearby_stations: + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "invalid_auth"} + + mock_nearby_stations.return_value = NEARBY_STATIONS + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert not mock_config.options[CONF_SHOW_ON_MAP] diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py index 59f273683a2..8d7137c503a 100644 --- a/tests/components/tankerkoenig/test_diagnostics.py +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -1,103 +1,23 @@ """Tests for the Tankerkoening integration.""" from __future__ import annotations -from unittest.mock import patch - +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.tankerkoenig.const import ( - CONF_FUEL_TYPES, - CONF_STATIONS, - DOMAIN, -) -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LOCATION, - CONF_LONGITUDE, - CONF_NAME, - CONF_RADIUS, - CONF_SHOW_ON_MAP, -) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -MOCK_USER_DATA = { - CONF_NAME: "Home", - CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], - CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, - CONF_RADIUS: 2.0, - CONF_STATIONS: [ - "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - ], -} -MOCK_OPTIONS = { - CONF_SHOW_ON_MAP: True, -} - -MOCK_STATION_DATA = { - "ok": True, - "station": { - "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "name": "Station ABC", - "brand": "Station", - "street": "Somewhere Street", - "houseNumber": "1", - "postCode": "01234", - "place": "Somewhere", - "openingTimes": [], - "overrides": [], - "wholeDay": True, - "isOpen": True, - "e5": 1.719, - "e10": 1.659, - "diesel": 1.659, - "lat": 51.1, - "lng": 13.1, - "state": "xxXX", - }, -} -MOCK_STATION_PRICES = { - "ok": True, - "prices": { - "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": { - "status": "open", - "e5": 1.719, - "e10": 1.659, - "diesel": 1.659, - }, - }, -} - +@pytest.mark.usefixtures("setup_integration") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - with patch( - "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getStationData", - return_value=MOCK_STATION_DATA, - ), patch( - "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getPriceList", - return_value=MOCK_STATION_PRICES, - ): - entry = MockConfigEntry( - domain=DOMAIN, - data=MOCK_USER_DATA, - options=MOCK_OPTIONS, - unique_id="mock.tankerkoenig", - entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert result == snapshot From ffdcdaf43b566c064410be17d8ae02fce7a87bb0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 Jan 2024 15:05:52 +0100 Subject: [PATCH 1249/1544] Create issues for reauth flows (#109105) --- .../components/homeassistant/strings.json | 4 + .../components/repairs/websocket_api.py | 24 ++++++ homeassistant/config_entries.py | 44 +++++++++-- tests/components/abode/test_init.py | 1 + .../components/aussie_broadband/test_init.py | 1 + .../components/repairs/test_websocket_api.py | 75 +++++++++++++++++++ tests/components/synology_dsm/test_init.py | 1 + tests/test_config_entries.py | 68 ++++++++++++++++- 8 files changed, 209 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 862ac12cefb..e2a6fc1c9e7 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -46,6 +46,10 @@ } } } + }, + "config_entry_reauth": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Reauthentication is needed" } }, "system_health": { diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index 73ef4d624ec..78a3c10bbe4 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -29,6 +29,7 @@ from .const import DOMAIN @callback def async_setup(hass: HomeAssistant) -> None: """Set up the repairs websocket API.""" + websocket_api.async_register_command(hass, ws_get_issue_data) websocket_api.async_register_command(hass, ws_ignore_issue) websocket_api.async_register_command(hass, ws_list_issues) @@ -36,6 +37,29 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(RepairsFlowResourceView(hass.data[DOMAIN]["flow_manager"])) +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "repairs/get_issue_data", + vol.Required("domain"): str, + vol.Required("issue_id"): str, + } +) +def ws_get_issue_data( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Fix an issue.""" + issue_registry = async_get_issue_registry(hass) + if not (issue := issue_registry.async_get_issue(msg["domain"], msg["issue_id"])): + connection.send_error( + msg["id"], + "unknown_issue", + f"Issue '{msg['issue_id']}' not found", + ) + return + connection.send_result(msg["id"], {"issue_data": issue.data}) + + @callback @websocket_api.websocket_command( { diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d9cfbd08886..b0a8f952b1b 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -23,15 +23,23 @@ from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from . import data_entry_flow, loader from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform -from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback -from .data_entry_flow import FlowResult +from .core import ( + CALLBACK_TYPE, + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HassJob, + HomeAssistant, + callback, +) +from .data_entry_flow import FLOW_NOT_COMPLETE_STEPS, FlowResult from .exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, HomeAssistantError, ) -from .helpers import device_registry, entity_registry, storage +from .helpers import device_registry, entity_registry, issue_registry as ir, storage from .helpers.debounce import Debouncer from .helpers.dispatcher import async_dispatcher_send from .helpers.event import ( @@ -793,7 +801,7 @@ class ConfigEntry: if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -804,6 +812,21 @@ class ConfigEntry: | (context or {}), data=self.data | (data or {}), ) + if result["type"] not in FLOW_NOT_COMPLETE_STEPS: + return + + # Create an issue, there's no need to hold the lock when doing that + issue_id = f"config_entry_reauth_{self.domain}_{self.entry_id}" + ir.async_create_issue( + hass, + HA_DOMAIN, + issue_id, + data={"flow_id": result["flow_id"]}, + is_fixable=False, + issue_domain=self.domain, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + ) @callback def async_get_active_flows( @@ -981,6 +1004,14 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): if not self._async_has_other_discovery_flows(flow.flow_id): persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None and ( + entry := self.config_entries.async_get_entry(entry_id) + ) is not None: + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result @@ -1230,12 +1261,15 @@ class ConfigEntries: ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should - # abort any reauth flow that is active for the removed entry. + # abort any reauth flow that is active for the removed entry and + # linked issues. for progress_flow in self.hass.config_entries.flow.async_progress_by_handler( entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH} ): if "flow_id" in progress_flow: self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HA_DOMAIN, issue_id) # After we have fully removed an "ignore" config entry we can try and rediscover # it so that a user is able to immediately start configuring it. We do this by diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index d208b6302bc..ae7ed51e086 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -79,6 +79,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 1430eca3a26..e16a721f5dc 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -25,6 +25,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 1f68c9a28d3..0cf6b22dc0c 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -579,3 +579,78 @@ async def test_fix_issue_aborted( assert msg["success"] assert len(msg["result"]["issues"]) == 1 assert msg["result"]["issues"][0] == first_issue + + +@pytest.mark.freeze_time("2022-07-19 07:53:05") +async def test_get_issue_data(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get issue data.""" + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + issues = [ + { + "breaks_in_ha_version": "2022.9", + "data": None, + "domain": "test", + "is_fixable": True, + "issue_id": "issue_1", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com", + "severity": "error", + "translation_key": "abc_123", + "translation_placeholders": {"abc": "123"}, + }, + { + "breaks_in_ha_version": "2022.8", + "data": {"key": "value"}, + "domain": "test", + "is_fixable": False, + "issue_id": "issue_2", + "issue_domain": None, + "learn_more_url": "https://theuselessweb.com/abc", + "severity": "other", + "translation_key": "even_worse", + "translation_placeholders": {"def": "456"}, + }, + ] + + for issue in issues: + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + data=issue["data"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=issue["learn_more_url"], + severity=issue["severity"], + translation_key=issue["translation_key"], + translation_placeholders=issue["translation_placeholders"], + ) + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_1"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": None} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "issue_2"} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issue_data": {"key": "value"}} + + await client.send_json_auto_id( + {"type": "repairs/get_issue_data", "domain": "test", "issue_id": "unknown"} + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "unknown_issue", + "message": "Issue 'unknown' not found", + } diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 91556f459ba..8f66044a66b 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -52,6 +52,7 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", return_value={ "type": data_entry_flow.FlowResultType.FORM, + "flow_id": "mock_flow", "step_id": "reauth_confirm", }, ) as mock_async_step_reauth: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e9e1437c06c..1c67534d5df 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6,7 +6,7 @@ from collections.abc import Generator from datetime import timedelta import logging from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -19,7 +19,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + CoreState, + Event, + HomeAssistant, + callback, +) from homeassistant.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -27,7 +33,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -59,7 +65,13 @@ def mock_handlers() -> Generator[None, None, None]: async def async_step_reauth(self, data): """Mock Reauth.""" - return self.async_show_form(step_id="reauth") + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Test reauth confirm step.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return self.async_abort(reason="test") with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} @@ -425,10 +437,15 @@ async def test_remove_entry_cancels_reauth( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + issue_registry = ir.async_get(hass) + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + assert issue_registry.async_get_issue(HA_DOMAIN, issue_id) + await manager.async_remove(entry.entry_id) flows = hass.config_entries.flow.async_progress_by_handler("test") assert len(flows) == 0 + assert not issue_registry.async_get_issue(HA_DOMAIN, issue_id) async def test_remove_entry_handles_callback_error( @@ -911,6 +928,49 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert "config_entry_reconfigure" not in notifications +async def test_reauth_issue(hass: HomeAssistant) -> None: + """Test that we create/delete an issue when source is reauth.""" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 0 + + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + entry.add_to_hass(hass) + await entry.async_setup(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler("test") + assert len(flows) == 1 + + assert len(issue_registry.issues) == 1 + issue_id = f"config_entry_reauth_test_{entry.entry_id}" + issue = issue_registry.async_get_issue(HA_DOMAIN, issue_id) + assert issue == ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=ANY, + data={"flow_id": flows[0]["flow_id"]}, + dismissed_version=None, + domain=HA_DOMAIN, + is_fixable=False, + is_persistent=False, + issue_domain="test", + issue_id=issue_id, + learn_more_url=None, + severity=ir.IssueSeverity.ERROR, + translation_key="config_entry_reauth", + translation_placeholders=None, + ) + + result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test that we not create a notification when discovery is aborted.""" mock_integration(hass, MockModule("test")) From 3bd1162650d5f7b3344e8a2e9a19d98ae8622fc8 Mon Sep 17 00:00:00 2001 From: Jeroen van Ingen Schenau Date: Wed, 31 Jan 2024 15:13:48 +0100 Subject: [PATCH 1250/1544] Fix Huisbaasje negative periodic gas readings (#103457) (#108090) --- homeassistant/components/huisbaasje/sensor.py | 8 ++++---- tests/components/huisbaasje/test_sensor.py | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 82cf51d3b26..2c1d2ffde68 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:counter", precision=1, ), diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 3f0bdae8e53..e74ff04e035 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -289,7 +289,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_today.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -299,7 +302,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_week.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -309,7 +315,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_month.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -319,7 +328,10 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + assert ( + gas_this_year.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING + ) assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS From 7b9dbc2187bc84f977ca7fe746d2d705e83fd310 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 31 Jan 2024 16:03:12 +0100 Subject: [PATCH 1251/1544] Support alternative modelid for LIDL doorbell in deCONZ device triggers (#107937) Add support alternative modelid for LIDL doorbell --- homeassistant/components/deconz/device_trigger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 1b257d121b4..70d03f808c1 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -491,6 +491,7 @@ LEGRAND_ZGP_SCENE_SWITCH = { } LIDL_SILVERCREST_DOORBELL_MODEL = "HG06668" +LIDL_SILVERCREST_DOORBELL_MODEL_2 = "TS0211" LIDL_SILVERCREST_DOORBELL = { (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, } @@ -628,6 +629,7 @@ REMOTES = { LEGRAND_ZGP_TOGGLE_SWITCH_MODEL: LEGRAND_ZGP_TOGGLE_SWITCH, LEGRAND_ZGP_SCENE_SWITCH_MODEL: LEGRAND_ZGP_SCENE_SWITCH, LIDL_SILVERCREST_DOORBELL_MODEL: LIDL_SILVERCREST_DOORBELL, + LIDL_SILVERCREST_DOORBELL_MODEL_2: LIDL_SILVERCREST_DOORBELL, LIDL_SILVERCREST_BUTTON_REMOTE_MODEL: LIDL_SILVERCREST_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, From 6fc5804818dd314313ba20559b2ed05d0b7be783 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 31 Jan 2024 16:05:41 +0100 Subject: [PATCH 1252/1544] Add Ecovacs switch entities (#109216) --- homeassistant/components/ecovacs/__init__.py | 1 + homeassistant/components/ecovacs/icons.json | 17 ++ homeassistant/components/ecovacs/strings.json | 17 ++ homeassistant/components/ecovacs/switch.py | 111 ++++++++++++ .../ecovacs/snapshots/test_switch.ambr | 130 ++++++++++++++ tests/components/ecovacs/test_init.py | 2 +- tests/components/ecovacs/test_number.py | 7 - tests/components/ecovacs/test_switch.py | 159 ++++++++++++++++++ 8 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/ecovacs/switch.py create mode 100644 tests/components/ecovacs/snapshots/test_switch.ambr create mode 100644 tests/components/ecovacs/test_switch.py diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index c008e74471c..ce7222f96a2 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -31,6 +31,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 276786ea8ac..b639ff81e63 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -78,6 +78,23 @@ "work_mode": { "default": "mdi:cog" } + }, + "switch": { + "advanced_mode": { + "default": "mdi:tune" + }, + "carpet_auto_fan_boost": { + "default": "mdi:fan-auto" + }, + "clean_preference": { + "default": "mdi:broom" + }, + "continuous_cleaning": { + "default": "mdi:refresh-auto" + }, + "true_detect": { + "default": "mdi:laser-pointer" + } } } } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 520c2ce65ca..f56b65a4e46 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -132,6 +132,23 @@ } } }, + "switch": { + "advanced_mode": { + "name": "Advanced mode" + }, + "carpet_auto_fan_boost": { + "name": "Carpet auto fan speed boost" + }, + "clean_preference": { + "name": "Clean preference" + }, + "continuous_cleaning": { + "name": "Continuous cleaning" + }, + "true_detect": { + "name": "True detect" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py new file mode 100644 index 00000000000..e9e915877d8 --- /dev/null +++ b/homeassistant/components/ecovacs/switch.py @@ -0,0 +1,111 @@ +"""Ecovacs switch module.""" +from dataclasses import dataclass +from typing import Any + +from deebot_client.capabilities import CapabilitySetEnable +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 .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) +from .util import get_supported_entitites + + +@dataclass(kw_only=True, frozen=True) +class EcovacsSwitchEntityDescription( + SwitchEntityDescription, + EcovacsCapabilityEntityDescription, +): + """Ecovacs switch entity description.""" + + +ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.advanced_mode, + key="advanced_mode", + translation_key="advanced_mode", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.continuous, + key="continuous_cleaning", + translation_key="continuous_cleaning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.carpet_auto_fan_boost, + key="carpet_auto_fan_boost", + translation_key="carpet_auto_fan_boost", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.clean.preference, + key="clean_preference", + translation_key="clean_preference", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription( + capability_fn=lambda c: c.settings.true_detect, + key="true_detect", + translation_key="true_detect", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + entities: list[EcovacsEntity] = get_supported_entitites( + controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS + ) + if entities: + async_add_entities(entities) + + +class EcovacsSwitchEntity( + EcovacsDescriptionEntity[CapabilitySetEnable], + SwitchEntity, +): + """Ecovacs switch entity.""" + + entity_description: EcovacsSwitchEntityDescription + + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_event(event: EnableEvent) -> None: + self._attr_is_on = event.enable + self.async_write_ha_state() + + self._subscribe(self._capability.event, on_event) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._device.execute_command(self._capability.set(True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._device.execute_command(self._capability.set(False)) diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr new file mode 100644 index 00000000000..75441c4f918 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_advanced_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Advanced mode', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'advanced_mode', + 'unique_id': 'E1234567890000000001_advanced_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_advanced_mode:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Advanced mode', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_advanced_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Carpet auto fan speed boost', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_auto_fan_boost', + 'unique_id': 'E1234567890000000001_carpet_auto_fan_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Carpet auto fan speed boost', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.ozmo_950_continuous_cleaning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Continuous cleaning', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'continuous_cleaning', + 'unique_id': 'E1234567890000000001_continuous_cleaning', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[yna5x1][switch.ozmo_950_continuous_cleaning:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Continuous cleaning', + }), + 'context': , + 'entity_id': 'switch.ozmo_950_continuous_cleaning', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3b43de6164e..8557ccb983c 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -118,7 +118,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 22), + ("yna5x1", 25), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 096c62751c0..3d9607fc9af 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from deebot_client.command import Command from deebot_client.commands.json import SetVolume -from deebot_client.event_bus import EventBus from deebot_client.events import Event, VolumeEvent import pytest from syrupy import SnapshotAssertion @@ -31,12 +30,6 @@ def platforms() -> Platform | list[Platform]: return Platform.NUMBER -async def notify_events(hass: HomeAssistant, event_bus: EventBus): - """Notify events.""" - event_bus.notify(VolumeEvent(5, 11)) - await block_till_done(hass, event_bus) - - @dataclass(frozen=True) class NumberTestCase: """Number test.""" diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py new file mode 100644 index 00000000000..43c5d25e18f --- /dev/null +++ b/tests/components/ecovacs/test_switch.py @@ -0,0 +1,159 @@ +"""Tests for Ecovacs select entities.""" + +from dataclasses import dataclass + +from deebot_client.command import Command +from deebot_client.commands.json import ( + SetAdvancedMode, + SetCarpetAutoFanBoost, + SetContinuousCleaning, +) +from deebot_client.events import ( + AdvancedModeEvent, + CarpetAutoFanBoostEvent, + ContinuousCleaningEvent, + Event, +) +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.switch.const import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import block_till_done + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.SWITCH + + +@dataclass(frozen=True) +class SwitchTestCase: + """Switch test.""" + + entity_id: str + event: Event + command: type[Command] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "tests"), + [ + ( + "yna5x1", + [ + SwitchTestCase( + "switch.ozmo_950_advanced_mode", + AdvancedModeEvent(True), + SetAdvancedMode, + ), + SwitchTestCase( + "switch.ozmo_950_continuous_cleaning", + ContinuousCleaningEvent(True), + SetContinuousCleaning, + ), + SwitchTestCase( + "switch.ozmo_950_carpet_auto_fan_speed_boost", + CarpetAutoFanBoostEvent(True), + SetCarpetAutoFanBoost, + ), + ], + ), + ], + ids=["yna5x1"], +) +async def test_switch_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, + tests: list[SwitchTestCase], +) -> None: + """Test switch entities.""" + device = controller.devices[0] + event_bus = device.events + + assert sorted(hass.states.async_entity_ids()) == sorted( + test.entity_id for test in tests + ) + for test_case in tests: + entity_id = test_case.entity_id + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert state.state == STATE_OFF + + event_bus.notify(test_case.event) + await block_till_done(hass, event_bus) + + assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" + assert snapshot(name=f"{entity_id}:state") == state + assert state.state == STATE_ON + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info.did)} + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(False)) + + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test_case.command(True)) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "yna5x1", + [ + "switch.ozmo_950_advanced_mode", + "switch.ozmo_950_continuous_cleaning", + "switch.ozmo_950_carpet_auto_fan_speed_boost", + ], + ), + ], + ids=["yna5x1"], +) +async def test_disabled_by_default_switch_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default switch entities.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert ( + entry := entity_registry.async_get(entity_id) + ), f"Entity registry entry for {entity_id} is missing" + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From 816c2e9500b49e0f9e2631ec71d920ee22a11d2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 Jan 2024 16:28:27 +0100 Subject: [PATCH 1253/1544] Improve enabling of Google local fulfillment (#109192) * Improve enabling of Google local fulfillment * Add test * Improve test coverage --- homeassistant/components/cloud/client.py | 8 ++++ .../components/cloud/google_config.py | 45 ++++++++++++++----- .../components/google_assistant/helpers.py | 17 +++++-- .../homeassistant/exposed_entities.py | 15 +++++-- tests/components/cloud/conftest.py | 7 +++ tests/components/cloud/test_client.py | 44 ++++++++++++++++++ tests/components/cloud/test_google_config.py | 13 +++++- tests/components/cloud/test_init.py | 4 +- 8 files changed, 131 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index cef3c5f0d42..8cf79d20c5d 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -29,6 +29,8 @@ from . import alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN from .prefs import CloudPreferences +_LOGGER = logging.getLogger(__name__) + VALID_REPAIR_TRANSLATION_KEYS = { "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", @@ -149,6 +151,7 @@ class CloudClient(Interface): async def cloud_connected(self) -> None: """When cloud is connected.""" + _LOGGER.debug("cloud_connected") is_new_user = await self.prefs.async_set_username(self.cloud.username) async def enable_alexa(_: Any) -> None: @@ -196,6 +199,9 @@ class CloudClient(Interface): async def cloud_disconnected(self) -> None: """When cloud disconnected.""" + _LOGGER.debug("cloud_disconnected") + if self._google_config: + self._google_config.async_disable_local_sdk() async def cloud_started(self) -> None: """When cloud is started.""" @@ -207,6 +213,8 @@ class CloudClient(Interface): """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) + if self._google_config: + self._google_config.async_deinitialize() self._google_config = None @callback diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index b64ec558389..42f25f43ae1 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( + CALLBACK_TYPE, CoreState, Event, HomeAssistant, @@ -144,6 +145,7 @@ class CloudGoogleConfig(AbstractConfig): self._prefs = prefs self._cloud = cloud self._sync_entities_lock = asyncio.Lock() + self._on_deinitialize: list[CALLBACK_TYPE] = [] @property def enabled(self) -> bool: @@ -209,9 +211,11 @@ class CloudGoogleConfig(AbstractConfig): async def async_initialize(self) -> None: """Perform async initialization of config.""" + _LOGGER.debug("async_initialize") await super().async_initialize() async def on_hass_started(hass: HomeAssistant) -> None: + _LOGGER.debug("async_initialize on_hass_started") if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: _LOGGER.info( "Start migration of Google Assistant settings from v%s to v%s", @@ -238,16 +242,19 @@ class CloudGoogleConfig(AbstractConfig): await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) - async_listen_entity_updates( - self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + self._on_deinitialize.append( + async_listen_entity_updates( + self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated + ) ) async def on_hass_start(hass: HomeAssistant) -> None: + _LOGGER.debug("async_initialize on_hass_start") if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) - start.async_at_start(self.hass, on_hass_start) - start.async_at_started(self.hass, on_hass_started) + self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) + self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) # Remove any stored user agent id that is not ours remove_agent_user_ids = [] @@ -255,18 +262,33 @@ class CloudGoogleConfig(AbstractConfig): if agent_user_id != self.agent_user_id: remove_agent_user_ids.append(agent_user_id) + if remove_agent_user_ids: + _LOGGER.debug("remove non cloud agent_user_ids: %s", remove_agent_user_ids) for agent_user_id in remove_agent_user_ids: await self.async_disconnect_agent_user(agent_user_id) - self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, + self._on_deinitialize.append( + self._prefs.async_listen_updates(self._async_prefs_updated) ) - self.hass.bus.async_listen( - dr.EVENT_DEVICE_REGISTRY_UPDATED, - self._handle_device_registry_updated, + self._on_deinitialize.append( + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) ) + self._on_deinitialize.append( + self.hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, + self._handle_device_registry_updated, + ) + ) + + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" @@ -365,6 +387,7 @@ class CloudGoogleConfig(AbstractConfig): async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" + _LOGGER.debug("_async_prefs_updated") if not self._cloud.is_logged_in: if self.is_reporting_state: self.async_disable_report_state() diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index c89925664e0..f3d0d24f7c8 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -316,6 +316,7 @@ class AbstractConfig(ABC): @callback def async_enable_local_sdk(self) -> None: """Enable the local SDK.""" + _LOGGER.debug("async_enable_local_sdk") setup_successful = True setup_webhook_ids = [] @@ -324,11 +325,16 @@ class AbstractConfig(ABC): self._local_sdk_active = False return - for user_agent_id, _ in self._store.agent_user_ids.items(): + for user_agent_id in self._store.agent_user_ids: if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break + _LOGGER.debug( + "Register webhook handler %s for agent user id %s", + webhook_id, + user_agent_id, + ) try: webhook.async_register( self.hass, @@ -360,13 +366,18 @@ class AbstractConfig(ABC): @callback def async_disable_local_sdk(self) -> None: """Disable the local SDK.""" + _LOGGER.debug("async_disable_local_sdk") if not self._local_sdk_active: return for agent_user_id in self._store.agent_user_ids: - webhook.async_unregister( - self.hass, self.get_local_webhook_id(agent_user_id) + webhook_id = self.get_local_webhook_id(agent_user_id) + _LOGGER.debug( + "Unregister webhook handler %s for agent user id %s", + webhook_id, + agent_user_id, ) + webhook.async_unregister(self.hass, webhook_id) self._local_sdk_active = False diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index b53f28f4cee..38c7f8e8128 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -12,7 +12,7 @@ from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import get_device_class @@ -129,10 +129,17 @@ class ExposedEntities: @callback def async_listen_entity_updates( self, assistant: str, listener: Callable[[], None] - ) -> None: + ) -> CALLBACK_TYPE: """Listen for updates to entity expose settings.""" + + def unsubscribe() -> None: + """Stop listening to entity updates.""" + self._listeners[assistant].remove(listener) + self._listeners.setdefault(assistant, []).append(listener) + return unsubscribe + @callback def async_set_assistant_option( self, assistant: str, entity_id: str, key: str, value: Any @@ -484,10 +491,10 @@ def ws_expose_new_entities_set( @callback def async_listen_entity_updates( hass: HomeAssistant, assistant: str, listener: Callable[[], None] -) -> None: +) -> CALLBACK_TYPE: """Listen for updates to entity expose settings.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] - exposed_entities.async_listen_entity_updates(assistant, listener) + return exposed_entities.async_listen_entity_updates(assistant, listener) @callback diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7421914d3d4..798b169393a 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -115,6 +115,13 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: type(mock_cloud).is_connected = is_connected type(mock_cloud.iot).connected = is_connected + def mock_username() -> bool: + """Return the subscription username.""" + return "abcdefghjkl" + + username = PropertyMock(side_effect=mock_username) + type(mock_cloud).username = username + # Properties that we mock as attributes. mock_cloud.expiration_date = utcnow() mock_cloud.subscription_expired = False diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 0cd605fd755..0dfa682c07d 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -428,3 +428,47 @@ async def test_async_create_repair_issue_unknown( ) issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) assert issue is None + + +async def test_disconnected(hass: HomeAssistant) -> None: + """Test cleanup when disconnected from the cloud.""" + prefs = MagicMock( + alexa_enabled=False, + google_enabled=True, + async_set_username=AsyncMock(return_value=None), + ) + client = CloudClient(hass, prefs, None, {}, {}) + client.cloud = MagicMock(is_logged_in=True, username="mock-username") + client._google_config = Mock() + client._google_config.async_disable_local_sdk.assert_not_called() + + await client.cloud_disconnected() + client._google_config.async_disable_local_sdk.assert_called_once_with() + + +async def test_logged_out( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test cleanup when logged out from the cloud.""" + + assert await async_setup_component(hass, "cloud", {"cloud": {}}) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + + alexa_config_mock = Mock(async_enable_proactive_mode=AsyncMock()) + google_config_mock = Mock(async_sync_entities=AsyncMock()) + cloud.client._alexa_config = alexa_config_mock + cloud.client._google_config = google_config_mock + + await cloud.client.cloud_connected() + await hass.async_block_till_done() + + # Simulate logged out + await cloud.logout() + await hass.async_block_till_done() + + # Alexa is not cleaned up, Google is + assert cloud.client._alexa_config is alexa_config_mock + assert cloud.client._google_config is None + google_config_mock.async_deinitialize.assert_called_once_with() diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index bedc6b459c5..7fb2d1aff3e 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -441,8 +441,10 @@ def test_enabled_requires_valid_sub( assert not config.enabled -async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None: - """Test that we set up the integration if used.""" +async def test_setup_google_assistant( + hass: HomeAssistant, mock_conf, cloud_prefs +) -> None: + """Test that we set up the google_assistant integration if enabled in cloud.""" assert await async_setup_component(hass, "homeassistant", {}) mock_conf._cloud.subscription_expired = False @@ -473,8 +475,10 @@ async def test_google_handle_logout( "homeassistant.components.google_assistant.report_state.async_enable_report_state", ) as mock_enable: gconf.async_enable_report_state() + await hass.async_block_till_done() assert len(mock_enable.mock_calls) == 1 + assert len(gconf._on_deinitialize) == 6 # This will trigger a prefs update when we logout. await cloud_prefs.get_cloud_user() @@ -484,8 +488,13 @@ async def test_google_handle_logout( "async_check_token", side_effect=AssertionError("Should not be called"), ): + # Fake logging out; CloudClient.logout_cleanups sets username to None + # and deinitializes the Google config. await cloud_prefs.async_set_username(None) + gconf.async_deinitialize() await hass.async_block_till_done() + # Check listeners are removed: + assert not gconf._on_deinitialize assert len(mock_enable.return_value.mock_calls) == 1 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index c537169bf01..4cef8c8437e 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -103,8 +103,8 @@ async def test_remote_services( assert mock_disconnect.called is False -async def test_startup_shutdown_events(hass: HomeAssistant, mock_cloud_fixture) -> None: - """Test if the cloud will start on startup event.""" +async def test_shutdown_event(hass: HomeAssistant, mock_cloud_fixture) -> None: + """Test if the cloud will stop on shutdown event.""" with patch("hass_nabucasa.Cloud.stop") as mock_stop: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() From ddb56fe20d07c44a01eb73b3fde6b5fb21143b72 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 Jan 2024 16:29:36 +0100 Subject: [PATCH 1254/1544] Modify climate turn_on/off backwards compatibility check (#109195) * Modify climate turn_on/off backwards compatibility check * Fix logger message * Comments * Fix demo * devolo * ecobee * Some more * Fix missing feature flag * some more * and some more * Remove demo change * Add back demo change * Fix demo * Update comments --- homeassistant/components/climate/__init__.py | 73 +++++++++---------- tests/components/climate/test_init.py | 19 +---- .../snapshots/test_climate.ambr | 4 +- .../nibe_heatpump/snapshots/test_climate.ambr | 40 +++++----- tests/components/nuheat/test_climate.py | 8 +- tests/components/plugwise/test_climate.py | 6 +- tests/components/smarttub/test_climate.py | 4 +- tests/components/zwave_js/test_climate.py | 2 +- 8 files changed, 71 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 889ff8cddbd..43d98ad6bbd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -301,6 +301,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_temperature_unit: str __mod_supported_features: ClimateEntityFeature = ClimateEntityFeature(0) + # Integrations should set `_enable_turn_on_off_backwards_compatibility` to False + # once migrated and set the feature flags TURN_ON/TURN_OFF as needed. + _enable_turn_on_off_backwards_compatibility: bool = True def __getattribute__(self, __name: str) -> Any: """Get attribute. @@ -345,7 +348,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): else: message = ( "Entity %s (%s) implements HVACMode(s): %s and therefore implicitly" - " supports the %s service without setting the proper" + " supports the %s methods without setting the proper" " ClimateEntityFeature. Please %s" ) _LOGGER.warning( @@ -353,48 +356,44 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.entity_id, type(self), feature, - feature.lower(), + method, report_issue, ) # Adds ClimateEntityFeature.TURN_OFF/TURN_ON depending on service calls implemented # This should be removed in 2025.1. - if not self.supported_features & ClimateEntityFeature.TURN_OFF: - if ( - type(self).async_turn_off is not ClimateEntity.async_turn_off - or type(self).turn_off is not ClimateEntity.turn_off - ): - # turn_off implicitly supported by implementing turn_off method - _report_turn_on_off("TURN_OFF", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_OFF - ) - elif self.hvac_modes and HVACMode.OFF in self.hvac_modes: - # turn_off implicitly supported by including HVACMode.OFF - _report_turn_on_off("off", "turn_off") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_OFF - ) + if self._enable_turn_on_off_backwards_compatibility is False: + # Return if integration has migrated already + return - if not self.supported_features & ClimateEntityFeature.TURN_ON: - if ( - type(self).async_turn_on is not ClimateEntity.async_turn_on - or type(self).turn_on is not ClimateEntity.turn_on - ): - # turn_on implicitly supported by implementing turn_on method - _report_turn_on_off("TURN_ON", "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON - ) - elif self.hvac_modes and any( - _mode != HVACMode.OFF and _mode is not None for _mode in self.hvac_modes - ): - # turn_on implicitly supported by including any other HVACMode than HVACMode.OFF - _modes = [_mode for _mode in self.hvac_modes if _mode != HVACMode.OFF] - _report_turn_on_off(", ".join(_modes or []), "turn_on") - self.__mod_supported_features |= ( # pylint: disable=unused-private-member - ClimateEntityFeature.TURN_ON - ) + if not self.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 + ): + # turn_off implicitly supported by implementing turn_off method + _report_turn_on_off("TURN_OFF", "turn_off") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_OFF + ) + + if not self.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 + ): + # turn_on implicitly supported by implementing turn_on method + _report_turn_on_off("TURN_ON", "turn_on") + self.__mod_supported_features |= ( # pylint: disable=unused-private-member + ClimateEntityFeature.TURN_ON + ) + + 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] + _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 + ) @final @property diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 03d571f8529..831a8503b79 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -531,16 +531,9 @@ async def test_implicit_warning_not_implemented_turn_on_off_feature( assert ( "Entity climate.test (.MockClimateEntityTest'>)" - " implements HVACMode(s): off and therefore implicitly supports the off service without setting" - " the proper ClimateEntityFeature. Please report it to the author of the 'test' custom integration" - in caplog.text - ) - assert ( - "Entity climate.test (.MockClimateEntityTest'>)" - " implements HVACMode(s): heat and therefore implicitly supports the heat service without setting" - " the proper ClimateEntityFeature. Please report it to the author of the 'test' custom integration" - in caplog.text + " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off" + " methods without setting the proper ClimateEntityFeature. Please report it to the author" + " of the 'test' custom integration" in caplog.text ) @@ -608,10 +601,6 @@ async def test_no_warning_implemented_turn_on_off_feature( not in caplog.text ) assert ( - "implements HVACMode.off and therefore implicitly implements the off method without setting" - not in caplog.text - ) - assert ( - "implements HVACMode.heat and therefore implicitly implements the heat method without setting" + " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" not in caplog.text ) diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index 26effb7cac6..0e7c5ba547e 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -9,7 +9,7 @@ ]), 'max_temp': 24, 'min_temp': 4, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, 'temperature': 20, }), @@ -52,7 +52,7 @@ 'original_name': None, 'platform': 'devolo_home_control', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'Test', 'unit_of_measurement': None, diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr index d7fced91e68..f19fd69c47d 100644 --- a/tests/components/nibe_heatpump/snapshots/test_climate.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -12,7 +12,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -36,7 +36,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, }), 'context': , @@ -59,7 +59,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -83,7 +83,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_step': 0.5, }), 'context': , @@ -112,7 +112,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -138,7 +138,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -164,7 +164,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -190,7 +190,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -216,7 +216,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -242,7 +242,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -268,7 +268,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -294,7 +294,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -320,7 +320,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -346,7 +346,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -372,7 +372,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -398,7 +398,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -424,7 +424,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -450,7 +450,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': 30.0, 'target_temp_low': 21.0, 'target_temp_step': 0.5, @@ -476,7 +476,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, @@ -502,7 +502,7 @@ ]), 'max_temp': 35.0, 'min_temp': 5.0, - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 0.5, diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index 73a07efcc3d..7a0e21485c8 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -44,7 +44,7 @@ async def test_climate_thermostat_run(hass: HomeAssistant) -> None: "min_temp": 5.0, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 273, + "supported_features": 17, "temperature": 22.2, } # Only test for a subset of attributes in case @@ -77,7 +77,7 @@ async def test_climate_thermostat_schedule_hold_unavailable( "max_temp": 180.6, "min_temp": -6.1, "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 273, + "supported_features": 17, } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -110,7 +110,7 @@ async def test_climate_thermostat_schedule_hold_available(hass: HomeAssistant) - "min_temp": -6.1, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 273, + "supported_features": 17, "temperature": 26.1, } # Only test for a subset of attributes in case @@ -144,7 +144,7 @@ async def test_climate_thermostat_schedule_temporary_hold(hass: HomeAssistant) - "min_temp": -0.6, "preset_mode": "Run Schedule", "preset_modes": ["Run Schedule", "Temporary Hold", "Permanent Hold"], - "supported_features": 273, + "supported_features": 17, "temperature": 37.2, } # Only test for a subset of attributes in case diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 1be4cc2a34f..c5ab3a209c2 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -34,7 +34,7 @@ async def test_adam_climate_entity_attributes( assert state.attributes["current_temperature"] == 20.9 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 273 + assert state.attributes["supported_features"] == 17 assert state.attributes["temperature"] == 21.5 assert state.attributes["min_temp"] == 0.0 assert state.attributes["max_temp"] == 35.0 @@ -303,7 +303,7 @@ async def test_anna_climate_entity_attributes( assert state.attributes["current_temperature"] == 19.3 assert state.attributes["preset_mode"] == "home" - assert state.attributes["supported_features"] == 274 + assert state.attributes["supported_features"] == 18 assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 assert state.attributes["min_temp"] == 4 @@ -325,7 +325,7 @@ async def test_anna_2_climate_entity_attributes( HVACMode.AUTO, HVACMode.HEAT_COOL, ] - assert state.attributes["supported_features"] == 274 + assert state.attributes["supported_features"] == 18 assert state.attributes["target_temp_high"] == 30 assert state.attributes["target_temp_low"] == 20.5 diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index cafb156d113..40e3c05b509 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -52,9 +52,7 @@ async def test_thermostat_update( assert state.state == HVACMode.HEAT assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_ON + == ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 38 assert state.attributes[ATTR_TEMPERATURE] == 39 diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 5f75f7c8307..fdbb2ef7f4c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -332,7 +332,7 @@ async def test_setpoint_thermostat( assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] assert ( state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + == ClimateEntityFeature.TARGET_TEMPERATURE ) client.async_send_command_no_wait.reset_mock() From c8bfb288a3793dc5738b8f6e12fc7a5506ec457f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 31 Jan 2024 17:22:45 +0100 Subject: [PATCH 1255/1544] Add readable state for tesla wall connector (#107909) * Add readable state for tesla wall connector * Add test * Display raw sensor by default * Use none instead of unknown * Remove old state from tests * Rename raw state to status code * Test unknown * Update homeassistant/components/tesla_wall_connector/strings.json Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- .../components/tesla_wall_connector/sensor.py | 26 ++++++++++++++++++- .../tesla_wall_connector/strings.json | 19 ++++++++++++-- .../tesla_wall_connector/test_sensor.py | 6 +++-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 6dcc2669789..09933d628fe 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -29,6 +29,19 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITAL _LOGGER = logging.getLogger(__name__) +EVSE_STATE = { + 0: "booting", + 1: "not_connected", + 2: "connected", + 4: "ready", + 6: "negotiating", + 7: "error", + 8: "charging_finished", + 9: "waiting_car", + 10: "charging_reduced", + 11: "charging", +} + @dataclass(frozen=True) class WallConnectorSensorDescription( @@ -40,9 +53,20 @@ class WallConnectorSensorDescription( WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( key="evse_state", - translation_key="evse_state", + translation_key="status_code", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].evse_state, + entity_registry_enabled_default=False, + ), + WallConnectorSensorDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: EVSE_STATE.get( + data[WALLCONNECTOR_DATA_VITALS].evse_state + ), + options=list(EVSE_STATE.values()), + icon="mdi:ev-station", ), WallConnectorSensorDescription( key="handle_temp_c", diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 88ff6e6791d..ed1878caecb 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -30,8 +30,23 @@ } }, "sensor": { - "evse_state": { - "name": "State" + "status": { + "name": "Status", + "state": { + "booting": "Booting", + "not_connected": "Vehicle not connected", + "connected": "Vehicle connected", + "ready": "Ready to charge", + "negociating": "Negociating connection", + "error": "Error", + "charging_finished": "Charging finished", + "waiting_car": "Waiting for car", + "charging_reduced": "Charging (reduced)", + "charging": "Charging" + } + }, + "status_code": { + "name": "Status code" }, "handle_temp_c": { "name": "Handle temperature" diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 3279ddad12e..684d7de0e82 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -13,7 +13,9 @@ async def test_sensors(hass: HomeAssistant) -> None: """Test all sensors.""" entity_and_expected_values = [ - EntityAndExpectedValues("sensor.tesla_wall_connector_state", "1", "2"), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_status", "not_connected", "unknown" + ), EntityAndExpectedValues( "sensor.tesla_wall_connector_handle_temperature", "25.5", "-1.4" ), @@ -63,7 +65,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_first_update.session_energy_wh = 1234.56 mock_vitals_second_update = get_vitals_mock() - mock_vitals_second_update.evse_state = 2 + mock_vitals_second_update.evse_state = 3 mock_vitals_second_update.handle_temp_c = -1.42 mock_vitals_second_update.grid_v = 229.21 mock_vitals_second_update.grid_hz = 49.981 From a45d26c2bd1de72ebb08a4fabba8cf92421c73ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 31 Jan 2024 17:23:03 +0100 Subject: [PATCH 1256/1544] Rename Traccar to Traccar Client (#109217) --- homeassistant/components/traccar/__init__.py | 4 ++-- homeassistant/components/traccar/config_flow.py | 4 ++-- homeassistant/components/traccar/const.py | 2 +- homeassistant/components/traccar/manifest.json | 2 +- homeassistant/components/traccar/strings.json | 6 +++--- homeassistant/generated/integrations.json | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 5dffd629e80..492f609907e 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,4 +1,4 @@ -"""Support for Traccar.""" +"""Support for Traccar Client.""" from http import HTTPStatus from aiohttp import web @@ -56,7 +56,7 @@ WEBHOOK_SCHEMA = vol.Schema( async def handle_webhook(hass, webhook_id, request): - """Handle incoming webhook with Traccar request.""" + """Handle incoming webhook with Traccar Client request.""" try: data = WEBHOOK_SCHEMA(dict(request.query)) except vol.MultipleInvalid as error: diff --git a/homeassistant/components/traccar/config_flow.py b/homeassistant/components/traccar/config_flow.py index 3702316ffb9..3d62d0a842d 100644 --- a/homeassistant/components/traccar/config_flow.py +++ b/homeassistant/components/traccar/config_flow.py @@ -1,10 +1,10 @@ -"""Config flow for Traccar.""" +"""Config flow for Traccar Client.""" from homeassistant.helpers import config_entry_flow from .const import DOMAIN config_entry_flow.register_webhook_flow( DOMAIN, - "Traccar Webhook", + "Traccar Client Webhook", {"docs_url": "https://www.home-assistant.io/integrations/traccar/"}, ) diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index 06dd368b6a3..df4bfa8ec99 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -1,4 +1,4 @@ -"""Constants for Traccar integration.""" +"""Constants for Traccar client integration.""" DOMAIN = "traccar" diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 403ba3987ab..978a0b2f507 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -1,6 +1,6 @@ { "domain": "traccar", - "name": "Traccar", + "name": "Traccar Client", "codeowners": ["@ludeeus"], "config_flow": true, "dependencies": ["webhook"], diff --git a/homeassistant/components/traccar/strings.json b/homeassistant/components/traccar/strings.json index 62bcf608852..804a26e3b0a 100644 --- a/homeassistant/components/traccar/strings.json +++ b/homeassistant/components/traccar/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Set up Traccar", - "description": "Are you sure you want to set up Traccar?" + "title": "Set up Traccar Client", + "description": "Are you sure you want to set up Traccar Client?" } }, "abort": { @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to set up the webhook feature in Traccar.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + "default": "To send events to Home Assistant, you will need to set up the webhook feature in Traccar Client.\n\nUse the following URL: `{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 071642500ba..96245342fa3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6163,7 +6163,7 @@ ] }, "traccar": { - "name": "Traccar", + "name": "Traccar Client", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" From 3b7ec8ed2ccdb0e7a1f8d874a82b5e06508d701f Mon Sep 17 00:00:00 2001 From: Caius-Bonus <123886836+Caius-Bonus@users.noreply.github.com> Date: Wed, 31 Jan 2024 17:53:10 +0100 Subject: [PATCH 1257/1544] Use EnumSensor instead of custom formatter() in ZHA Sensor entities (#109218) use EnumSensor for SonofPresenceSensorIlluminationStatus and AqaraPetFeederLastFeedingSource --- homeassistant/components/zha/sensor.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index f4689460f93..15985922ccd 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1190,17 +1190,14 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSource(Sensor): +class AqaraPetFeederLastFeedingSource(EnumSensor): """Sensor that displays the last feeding source of pet feeder.""" _attribute_name = "last_feeding_source" _unique_id_suffix = "last_feeding_source" _attr_translation_key: str = "last_feeding_source" _attr_icon = "mdi:devices" - - def formatter(self, value: int) -> int | float | None: - """Numeric pass-through formatter.""" - return AqaraFeedingSource(value).name + _enum = AqaraFeedingSource @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) @@ -1262,17 +1259,14 @@ class SonoffIlluminationStates(types.enum8): @MULTI_MATCH(cluster_handler_names="sonoff_manufacturer", models={"SNZB-06P"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SonoffPresenceSenorIlluminationStatus(Sensor): +class SonoffPresenceSenorIlluminationStatus(EnumSensor): """Sensor that displays the illumination status the last time peresence was detected.""" _attribute_name = "last_illumination_state" _unique_id_suffix = "last_illumination" _attr_translation_key: str = "last_illumination_state" _attr_icon: str = "mdi:theme-light-dark" - - def formatter(self, value: int) -> int | float | None: - """Numeric pass-through formatter.""" - return SonoffIlluminationStates(value).name + _enum = SonoffIlluminationStates @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) From f4a2d7c61254d251b7c0dbe61b989f9e86737984 Mon Sep 17 00:00:00 2001 From: Paul Strawder Date: Wed, 31 Jan 2024 18:02:34 +0100 Subject: [PATCH 1258/1544] Add ZHA support for Bosch Twinguard and siren install QR codes (#107460) * Enable Bosch Outdoor Siren and Bosch Twinguard QR Codes These devices contain inside their QR code device specific link keys instead of installation codes. Normally, the link key is generated from the installation code, but in this case we can directly pass the provided link key from QR code to zigpy application controller. * Replace ZHA deprecated permit_with_key by permit_with_link_key Convert installation code directly to link key * Update tests * formatting --- homeassistant/components/zha/core/helpers.py | 21 ++++-- homeassistant/components/zha/websocket_api.py | 38 +++++------ tests/components/zha/test_websocket_api.py | 66 +++++++++++++------ 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 6f0167827e8..5506ffb8289 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -318,7 +318,7 @@ class LogMixin: return self.log(logging.ERROR, msg, *args, **kwargs) -def convert_install_code(value: str) -> bytes: +def convert_install_code(value: str) -> zigpy.types.KeyData: """Convert string to install code bytes and validate length.""" try: @@ -329,10 +329,11 @@ def convert_install_code(value: str) -> bytes: if len(code) != 18: # 16 byte code + 2 crc bytes raise vol.Invalid("invalid length of the install code") - if zigpy.util.convert_install_code(code) is None: + link_key = zigpy.util.convert_install_code(code) + if link_key is None: raise vol.Invalid("invalid install code") - return code + return link_key QR_CODES = ( @@ -360,13 +361,13 @@ QR_CODES = ( [0-9a-fA-F]{34} ([0-9a-fA-F]{16}) # IEEE address DLK - ([0-9a-fA-F]{36}) # install code + ([0-9a-fA-F]{36}|[0-9a-fA-F]{32}) # install code / link key $ """, ) -def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: +def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, zigpy.types.KeyData]: """Try to parse the QR code. if successful, return a tuple of a EUI64 address and install code. @@ -379,10 +380,16 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: ieee_hex = binascii.unhexlify(match[1]) ieee = zigpy.types.EUI64(ieee_hex[::-1]) + + # Bosch supplies (A) device specific link key (DSLK) or (A) install code + crc + if "RB01SG" in code_pattern and len(match[2]) == 32: + link_key_hex = binascii.unhexlify(match[2]) + link_key = zigpy.types.KeyData(link_key_hex) + return ieee, link_key install_code = match[2] # install_code sanity check - install_code = convert_install_code(install_code) - return ieee, install_code + link_key = convert_install_code(install_code) + return ieee, link_key raise vol.Invalid(f"couldn't convert qr code: {qr_code}") diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 447aa5efd0f..e3e67ea0e41 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -9,7 +9,7 @@ import voluptuous as vol import zigpy.backups from zigpy.config import CONF_DEVICE from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 +from zigpy.types.named import EUI64, KeyData from zigpy.zcl.clusters.security import IasAce import zigpy.zdo.types as zdo_types @@ -328,19 +328,19 @@ async def websocket_permit_devices( connection.subscriptions[msg["id"]] = async_cleanup zha_gateway.async_enable_debug_mode() src_ieee: EUI64 - code: bytes + link_key: KeyData if ATTR_SOURCE_IEEE in msg: src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + link_key = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + src_ieee, link_key = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) else: await zha_gateway.application_controller.permit(time_s=duration, node=ieee) @@ -1249,21 +1249,21 @@ def async_load_api(hass: HomeAssistant) -> None: duration: int = service.data[ATTR_DURATION] ieee: EUI64 | None = service.data.get(ATTR_IEEE) src_ieee: EUI64 - code: bytes + link_key: KeyData if ATTR_SOURCE_IEEE in service.data: src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + link_key = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) return if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code + src_ieee, link_key = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key ) return diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 44006ea6ca1..bafea7e1965 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -13,6 +13,7 @@ import zigpy.backups import zigpy.profiles.zha import zigpy.types from zigpy.types.named import EUI64 +import zigpy.util import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.general import Groups import zigpy.zcl.clusters.security as security @@ -528,7 +529,7 @@ async def test_permit_ha12( assert app_controller.permit.await_count == 1 assert app_controller.permit.await_args[1]["time_s"] == duration assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 IC_TEST_PARAMS = ( @@ -538,7 +539,9 @@ IC_TEST_PARAMS = ( ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", }, zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( { @@ -546,7 +549,9 @@ IC_TEST_PARAMS = ( ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", }, zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ) @@ -566,10 +571,10 @@ async def test_permit_with_install_code( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code IC_FAIL_PARAMS = ( @@ -621,19 +626,23 @@ async def test_permit_with_install_code_fail( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 IC_QR_CODE_TEST_PARAMS = ( ( {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), ), ( { @@ -643,7 +652,22 @@ IC_QR_CODE_TEST_PARAMS = ( ) }, zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + zigpy.util.convert_install_code( + unhexlify("52797BF4A5084DAA8E1712B61741CA024051") + ), + ), + ( + { + ATTR_QR_CODE: ( + "RB01SG" + "0D836591B3CC0010000000000000000000" + "000D6F0019107BB1" + "DLK" + "E4636CB6C41617C3E08F7325FFBFE1F9" + ) + }, + zigpy.types.EUI64.convert("00:0D:6F:00:19:10:7B:B1"), + zigpy.types.KeyData.convert("E4:63:6C:B6:C4:16:17:C3:E0:8F:73:25:FF:BF:E1:F9"), ), ) @@ -663,10 +687,10 @@ async def test_permit_with_qr_code( DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) ) assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code @pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) @@ -685,10 +709,10 @@ async def test_ws_permit_with_qr_code( assert msg["success"] assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code + assert app_controller.permit_with_link_key.call_count == 1 + assert app_controller.permit_with_link_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_link_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_link_key.await_args[1]["link_key"] == code @pytest.mark.parametrize("params", IC_FAIL_PARAMS) @@ -707,7 +731,7 @@ async def test_ws_permit_with_install_code_fail( assert msg["success"] is False assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 @pytest.mark.parametrize( @@ -744,7 +768,7 @@ async def test_ws_permit_ha12( assert app_controller.permit.await_count == 1 assert app_controller.permit.await_args[1]["time_s"] == duration assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 + assert app_controller.permit_with_link_key.call_count == 0 async def test_get_network_settings( From 0b0bf737805b20a1d15680962a2b92cb5175b086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 31 Jan 2024 18:15:40 +0100 Subject: [PATCH 1259/1544] Add brands definition for Traccar (#109219) --- homeassistant/brands/traccar.json | 5 +++++ homeassistant/generated/integrations.json | 25 ++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 homeassistant/brands/traccar.json diff --git a/homeassistant/brands/traccar.json b/homeassistant/brands/traccar.json new file mode 100644 index 00000000000..a30c881d978 --- /dev/null +++ b/homeassistant/brands/traccar.json @@ -0,0 +1,5 @@ +{ + "domain": "traccar", + "name": "Traccar", + "integrations": ["traccar", "traccar_server"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 96245342fa3..fa143ddf151 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6163,16 +6163,21 @@ ] }, "traccar": { - "name": "Traccar Client", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, - "traccar_server": { - "name": "Traccar Server", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "name": "Traccar", + "integrations": { + "traccar": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Traccar Client" + }, + "traccar_server": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Traccar Server" + } + } }, "tractive": { "name": "Tractive", From cd96fb381f18a934bdaf67a18ce7fa75298c80e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 31 Jan 2024 18:16:23 +0100 Subject: [PATCH 1260/1544] Import Traccar YAML configuration to Traccar Server (#109226) * Import Traccar YAML configuration to Traccar Server * Remove import --- .../components/traccar/device_tracker.py | 263 +++++------------- .../components/traccar_server/config_flow.py | 34 +++ .../components/traccar/test_device_tracker.py | 78 ------ .../traccar_server/test_config_flow.py | 126 +++++++++ 4 files changed, 230 insertions(+), 271 deletions(-) delete mode 100644 tests/components/traccar/test_device_tracker.py diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 3406997fd98..dbcb30e3a23 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -1,30 +1,25 @@ """Support for Traccar device tracking.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging +from typing import Any -from pytraccar import ( - ApiClient, - DeviceModel, - GeofenceModel, - PositionModel, - TraccarAuthenticationException, - TraccarConnectionException, - TraccarException, -) -from stringcase import camelcase +from pytraccar import ApiClient, TraccarException import voluptuous as vol from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, AsyncSeeCallback, SourceType, TrackerEntity, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_EVENT, CONF_HOST, @@ -34,34 +29,34 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STARTED, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + Event, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import slugify from . import DOMAIN, TRACKER_UPDATE from .const import ( ATTR_ACCURACY, - ATTR_ADDRESS, ATTR_ALTITUDE, ATTR_BATTERY, ATTR_BEARING, - ATTR_CATEGORY, - ATTR_GEOFENCE, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_MOTION, ATTR_SPEED, - ATTR_STATUS, - ATTR_TRACCAR_ID, - ATTR_TRACKER, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_ON, EVENT_ALARM, @@ -178,7 +173,7 @@ async def async_setup_scanner( async_see: AsyncSeeCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: - """Validate the configuration and return a Traccar scanner.""" + """Import configuration to the new integration.""" api = ApiClient( host=config[CONF_HOST], port=config[CONF_PORT], @@ -188,180 +183,62 @@ async def async_setup_scanner( client_session=async_get_clientsession(hass, config[CONF_VERIFY_SSL]), ) - scanner = TraccarScanner( - api, - hass, - async_see, - config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), - config[CONF_MAX_ACCURACY], - config[CONF_SKIP_ACCURACY_ON], - config[CONF_MONITORED_CONDITIONS], - config[CONF_EVENT], - ) - - return await scanner.async_init() - - -class TraccarScanner: - """Define an object to retrieve Traccar data.""" - - def __init__( - self, - api: ApiClient, - hass: HomeAssistant, - async_see: AsyncSeeCallback, - scan_interval: timedelta, - max_accuracy: int, - skip_accuracy_on: bool, - custom_attributes: list[str], - event_types: list[str], - ) -> None: - """Initialize.""" - - if EVENT_ALL_EVENTS in event_types: - event_types = EVENTS - self._event_types = {camelcase(evt): evt for evt in event_types} - self._custom_attributes = custom_attributes - self._scan_interval = scan_interval - self._async_see = async_see - self._api = api - self._hass = hass - self._max_accuracy = max_accuracy - self._skip_accuracy_on = skip_accuracy_on - self._devices: list[DeviceModel] = [] - self._positions: list[PositionModel] = [] - self._geofences: list[GeofenceModel] = [] - - async def async_init(self): - """Further initialize connection to Traccar.""" + async def _run_import(_: Event): + known_devices: dict[str, dict[str, Any]] = {} try: - await self._api.get_server() - except TraccarAuthenticationException: - _LOGGER.error("Authentication for Traccar failed") - return False - except TraccarConnectionException as exception: - _LOGGER.error("Connection with Traccar failed - %s", exception) - return False + known_devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) - await self._async_update() - async_track_time_interval( - self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + if known_devices: + traccar_devices: list[str] = [] + try: + resp = await api.get_devices() + traccar_devices = [slugify(device["name"]) for device in resp] + except TraccarException as exception: + _LOGGER.error("Error while getting device data: %s", exception) + return + + for dev_name in traccar_devices: + if dev_name in known_devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + hass.async_create_task( + hass.config_entries.flow.async_init( + "traccar_server", + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - return True - async def _async_update(self, now=None): - """Update info from Traccar.""" - _LOGGER.debug("Updating device data") - try: - ( - self._devices, - self._positions, - self._geofences, - ) = await asyncio.gather( - self._api.get_devices(), - self._api.get_positions(), - self._api.get_geofences(), - ) - except TraccarException as ex: - _LOGGER.error("Error while updating device data: %s", ex) - return - - self._hass.async_create_task(self.import_device_data()) - if self._event_types: - self._hass.async_create_task(self.import_events()) - - async def import_device_data(self): - """Import device data from Traccar.""" - for position in self._positions: - device = next( - (dev for dev in self._devices if dev["id"] == position["deviceId"]), - None, - ) - - if not device: - continue - - attr = { - ATTR_TRACKER: "traccar", - ATTR_ADDRESS: position["address"], - ATTR_SPEED: position["speed"], - ATTR_ALTITUDE: position["altitude"], - ATTR_MOTION: position["attributes"].get("motion", False), - ATTR_TRACCAR_ID: device["id"], - ATTR_GEOFENCE: next( - ( - geofence["name"] - for geofence in self._geofences - if geofence["id"] in (position["geofenceIds"] or []) - ), - None, - ), - ATTR_CATEGORY: device["category"], - ATTR_STATUS: device["status"], - } - - skip_accuracy_filter = False - - for custom_attr in self._custom_attributes: - if device["attributes"].get(custom_attr) is not None: - attr[custom_attr] = position["attributes"][custom_attr] - if custom_attr in self._skip_accuracy_on: - skip_accuracy_filter = True - if position["attributes"].get(custom_attr) is not None: - attr[custom_attr] = position["attributes"][custom_attr] - if custom_attr in self._skip_accuracy_on: - skip_accuracy_filter = True - - accuracy = position["accuracy"] or 0.0 - if ( - not skip_accuracy_filter - and self._max_accuracy > 0 - and accuracy > self._max_accuracy - ): - _LOGGER.debug( - "Excluded position by accuracy filter: %f (%s)", - accuracy, - attr[ATTR_TRACCAR_ID], - ) - continue - - await self._async_see( - dev_id=slugify(device["name"]), - gps=(position["latitude"], position["longitude"]), - gps_accuracy=accuracy, - battery=position["attributes"].get("batteryLevel", -1), - attributes=attr, - ) - - async def import_events(self): - """Import events from Traccar.""" - # get_reports_events requires naive UTC datetimes as of 1.0.0 - start_intervel = dt_util.utcnow().replace(tzinfo=None) - events = await self._api.get_reports_events( - devices=[device["id"] for device in self._devices], - start_time=start_intervel, - end_time=start_intervel - self._scan_interval, - event_types=self._event_types.keys(), + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Traccar", + }, ) - if events is not None: - for event in events: - self._hass.bus.async_fire( - f"traccar_{self._event_types.get(event['type'])}", - { - "device_traccar_id": event["deviceId"], - "device_name": next( - ( - dev["name"] - for dev in self._devices - if dev["id"] == event["deviceId"] - ), - None, - ), - "type": event["type"], - "serverTime": event["eventTime"], - "attributes": event["attributes"], - }, - ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) + return True class TraccarEntity(TrackerEntity, RestoreEntity): diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index 11a23b21bf6..a2a7daaaa98 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Traccar Server integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytraccar import ApiClient, ServerModel, TraccarException @@ -159,6 +160,39 @@ class TraccarServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: + """Import an entry.""" + configured_port = str(import_info[CONF_PORT]) + self._async_abort_entries_match( + { + CONF_HOST: import_info[CONF_HOST], + CONF_PORT: configured_port, + } + ) + if "all_events" in (imported_events := import_info.get("event", [])): + events = list(EVENTS.values()) + else: + events = imported_events + return self.async_create_entry( + title=f"{import_info[CONF_HOST]}:{configured_port}", + data={ + CONF_HOST: import_info[CONF_HOST], + CONF_PORT: configured_port, + CONF_SSL: import_info.get(CONF_SSL, False), + CONF_VERIFY_SSL: import_info.get(CONF_VERIFY_SSL, True), + CONF_USERNAME: import_info[CONF_USERNAME], + CONF_PASSWORD: import_info[CONF_PASSWORD], + }, + options={ + CONF_MAX_ACCURACY: import_info[CONF_MAX_ACCURACY], + CONF_EVENTS: events, + CONF_CUSTOM_ATTRIBUTES: import_info.get("monitored_conditions", []), + CONF_SKIP_ACCURACY_FILTER_FOR: import_info.get( + "skip_accuracy_filter_on", [] + ), + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py deleted file mode 100644 index ed6cc3f629b..00000000000 --- a/tests/components/traccar/test_device_tracker.py +++ /dev/null @@ -1,78 +0,0 @@ -"""The tests for the Traccar device tracker platform.""" -from unittest.mock import AsyncMock, patch - -from pytraccar import ReportsEventeModel - -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.components.traccar.device_tracker import ( - PLATFORM_SCHEMA as TRACCAR_PLATFORM_SCHEMA, -) -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_PASSWORD, - CONF_PLATFORM, - CONF_USERNAME, -) -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.common import async_capture_events - - -async def test_import_events_catch_all(hass: HomeAssistant) -> None: - """Test importing all events and firing them in HA using their event types.""" - conf_dict = { - DOMAIN: TRACCAR_PLATFORM_SCHEMA( - { - CONF_PLATFORM: "traccar", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_EVENT: ["all_events"], - } - ) - } - - device = {"id": 1, "name": "abc123"} - api_mock = AsyncMock() - api_mock.devices = [device] - api_mock.get_reports_events.return_value = [ - ReportsEventeModel( - **{ - "id": 1, - "positionId": 1, - "geofenceId": 1, - "maintenanceId": 1, - "deviceId": device["id"], - "type": "ignitionOn", - "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "attributes": {}, - } - ), - ReportsEventeModel( - **{ - "id": 2, - "positionId": 2, - "geofenceId": 1, - "maintenanceId": 1, - "deviceId": device["id"], - "type": "ignitionOff", - "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "attributes": {}, - } - ), - ] - - events_ignition_on = async_capture_events(hass, "traccar_ignition_on") - events_ignition_off = async_capture_events(hass, "traccar_ignition_off") - - with patch( - "homeassistant.components.traccar.device_tracker.ApiClient", - return_value=api_mock, - ): - assert await async_setup_component(hass, DOMAIN, conf_dict) - - assert len(events_ignition_on) == 1 - assert len(events_ignition_off) == 1 diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index 67358078869..028bc99cec5 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -1,16 +1,19 @@ """Test the Traccar Server config flow.""" +from typing import Any from unittest.mock import AsyncMock, patch import pytest from pytraccar import TraccarException from homeassistant import config_entries +from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA from homeassistant.components.traccar_server.const import ( CONF_CUSTOM_ATTRIBUTES, CONF_EVENTS, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_FILTER_FOR, DOMAIN, + EVENTS, ) from homeassistant.const import ( CONF_HOST, @@ -156,6 +159,129 @@ async def test_options( } +@pytest.mark.parametrize( + ("imported", "data", "options"), + ( + ( + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 443, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "443", + CONF_VERIFY_SSL: True, + CONF_SSL: False, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: [], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ( + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + "event": ["device_online", "device_offline"], + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_VERIFY_SSL: True, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: ["device_online", "device_offline"], + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ( + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SSL: True, + "event": ["device_online", "device_offline", "all_events"], + }, + { + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + CONF_VERIFY_SSL: True, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + { + CONF_EVENTS: list(EVENTS.values()), + CONF_CUSTOM_ATTRIBUTES: [], + CONF_SKIP_ACCURACY_FILTER_FOR: [], + CONF_MAX_ACCURACY: 0, + }, + ), + ), +) +async def test_import_from_yaml( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + imported: dict[str, Any], + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test importing configuration from YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=PLATFORM_SCHEMA({"platform": "traccar", **imported}), + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == f"{data[CONF_HOST]}:{data[CONF_PORT]}" + assert result["data"] == data + assert result["options"] == options + + +async def test_abort_import_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test abort for existing server while importing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"}, + ) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=PLATFORM_SCHEMA( + { + "platform": "traccar", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_HOST: "1.1.1.1", + CONF_PORT: "8082", + } + ), + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_abort_already_configured( hass: HomeAssistant, mock_setup_entry: AsyncMock, From bbdb9b61c49e3f0e2fcf0999209a8748ddfd264e Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 18:38:14 +0100 Subject: [PATCH 1261/1544] Add config flow to GPSD (#106196) --- .coveragerc | 1 + CODEOWNERS | 3 +- homeassistant/components/gpsd/__init__.py | 20 +++- homeassistant/components/gpsd/config_flow.py | 57 +++++++++++ homeassistant/components/gpsd/const.py | 3 + homeassistant/components/gpsd/manifest.json | 3 +- homeassistant/components/gpsd/sensor.py | 100 +++++++++++-------- homeassistant/components/gpsd/strings.json | 19 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/gpsd/__init__.py | 1 + tests/components/gpsd/conftest.py | 14 +++ tests/components/gpsd/test_config_flow.py | 76 ++++++++++++++ 14 files changed, 257 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/gpsd/config_flow.py create mode 100644 homeassistant/components/gpsd/const.py create mode 100644 homeassistant/components/gpsd/strings.json create mode 100644 tests/components/gpsd/__init__.py create mode 100644 tests/components/gpsd/conftest.py create mode 100644 tests/components/gpsd/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 34b6dde9854..bcd4e349668 100644 --- a/.coveragerc +++ b/.coveragerc @@ -481,6 +481,7 @@ omit = homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_pubsub/__init__.py + homeassistant/components/gpsd/__init__.py homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 9691a8d72f6..af196548bb3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -507,7 +507,8 @@ build.json @home-assistant/supervisor /tests/components/govee_ble/ @bdraco @PierreAronnax /homeassistant/components/govee_light_local/ @Galorhallen /tests/components/govee_light_local/ @Galorhallen -/homeassistant/components/gpsd/ @fabaff +/homeassistant/components/gpsd/ @fabaff @jrieger +/tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche /homeassistant/components/greeneye_monitor/ @jkeljo diff --git a/homeassistant/components/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py index 71656d4d13d..bdd5ddb13b0 100644 --- a/homeassistant/components/gpsd/__init__.py +++ b/homeassistant/components/gpsd/__init__.py @@ -1 +1,19 @@ -"""The gpsd component.""" +"""The GPSD integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GPSD from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/gpsd/config_flow.py b/homeassistant/components/gpsd/config_flow.py new file mode 100644 index 00000000000..db1f9c5b0c1 --- /dev/null +++ b/homeassistant/components/gpsd/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for GPSD integration.""" +from __future__ import annotations + +import socket +from typing import Any + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT, HOST as DEFAULT_HOST +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class GPSDConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for GPSD.""" + + VERSION = 1 + + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_data) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self._async_abort_entries_match(user_input) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((user_input[CONF_HOST], user_input[CONF_PORT])) + sock.shutdown(2) + except OSError: + return self.async_abort(reason="cannot_connect") + + port = "" + if user_input[CONF_PORT] != DEFAULT_PORT: + port = f":{user_input[CONF_PORT]}" + + return self.async_create_entry( + title=user_input.get(CONF_NAME, f"GPS {user_input[CONF_HOST]}{port}"), + data=user_input, + ) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) diff --git a/homeassistant/components/gpsd/const.py b/homeassistant/components/gpsd/const.py new file mode 100644 index 00000000000..8a2aec140b5 --- /dev/null +++ b/homeassistant/components/gpsd/const.py @@ -0,0 +1,3 @@ +"""Constants for the GPSD integration.""" + +DOMAIN = "gpsd" diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json index d202a6b0428..3f22c5bfab2 100644 --- a/homeassistant/components/gpsd/manifest.json +++ b/homeassistant/components/gpsd/manifest.json @@ -1,7 +1,8 @@ { "domain": "gpsd", "name": "GPSD", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@jrieger"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gpsd", "iot_class": "local_polling", "loggers": ["gps3"], diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 64b86434c3c..2b3fe756d8d 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -2,13 +2,17 @@ from __future__ import annotations import logging -import socket from typing import Any -from gps3.agps3threaded import AGPS3mechanism +from gps3.agps3threaded import ( + GPSD_PORT as DEFAULT_PORT, + HOST as DEFAULT_HOST, + AGPS3mechanism, +) import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -17,11 +21,15 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" @@ -29,9 +37,7 @@ ATTR_ELEVATION = "elevation" ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "GPS" -DEFAULT_PORT = 2947 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -42,64 +48,74 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the GPSD component.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - port = config[CONF_PORT] + async_add_entities( + [ + GpsdSensor( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.entry_id, + ) + ] + ) - # Will hopefully be possible with the next gps3 update - # https://github.com/wadda/gps3/issues/11 - # from gps3 import gps3 - # try: - # gpsd_socket = gps3.GPSDSocket() - # gpsd_socket.connect(host=host, port=port) - # except GPSError: - # _LOGGER.warning('Not able to connect to GPSD') - # return False - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((host, port)) - sock.shutdown(2) - _LOGGER.debug("Connection to GPSD possible") - except OSError: - _LOGGER.error("Not able to connect to GPSD") - return +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Initialize gpsd import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.9.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GPSD", + }, + ) - add_entities([GpsdSensor(hass, name, host, port)]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, - hass: HomeAssistant, - name: str, host: str, port: int, + unique_id: str, ) -> None: """Initialize the GPSD sensor.""" - self.hass = hass - self._name = name - self._host = host - self._port = port + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_unique_id = unique_id self.agps_thread = AGPS3mechanism() - self.agps_thread.stream_data(host=self._host, port=self._port) + self.agps_thread.stream_data(host=host, port=port) self.agps_thread.run_thread() - @property - def name(self) -> str: - """Return the name.""" - return self._name - @property def native_value(self) -> str | None: """Return the state of GPSD.""" diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json new file mode 100644 index 00000000000..ff91b239d0a --- /dev/null +++ b/homeassistant/components/gpsd/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of GPSD." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80d3f7310b0..aa3efde99bc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -203,6 +203,7 @@ FLOWS = { "google_travel_time", "govee_ble", "govee_light_local", + "gpsd", "gpslogger", "gree", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fa143ddf151..21186272bb6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2321,7 +2321,7 @@ "gpsd": { "name": "GPSD", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "gpslogger": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a535c5cd01..c7207fc5398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,6 +783,9 @@ govee-ble==0.27.3 # homeassistant.components.govee_light_local govee-local-api==1.4.1 +# homeassistant.components.gpsd +gps3==0.33.3 + # homeassistant.components.gree greeclimate==1.4.1 diff --git a/tests/components/gpsd/__init__.py b/tests/components/gpsd/__init__.py new file mode 100644 index 00000000000..d78331c94d9 --- /dev/null +++ b/tests/components/gpsd/__init__.py @@ -0,0 +1 @@ +"""Tests for the GPSD integration.""" diff --git a/tests/components/gpsd/conftest.py b/tests/components/gpsd/conftest.py new file mode 100644 index 00000000000..c2bd2b8564a --- /dev/null +++ b/tests/components/gpsd/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the GPSD tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gpsd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/gpsd/test_config_flow.py b/tests/components/gpsd/test_config_flow.py new file mode 100644 index 00000000000..0b0465b026d --- /dev/null +++ b/tests/components/gpsd/test_config_flow.py @@ -0,0 +1,76 @@ +"""Test the GPSD config flow.""" +from unittest.mock import AsyncMock, patch + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.gpsd.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +HOST = "gpsd.local" + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"GPS {HOST}" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_PORT: DEFAULT_PORT, + } + mock_setup_entry.assert_called_once() + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test connection to host error.""" + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.side_effect = OSError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "nonexistent.local", CONF_PORT: 1234}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + with patch("homeassistant.components.gpsd.config_flow.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_PORT: 1234, CONF_NAME: "MyGPS"}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "MyGPS" + assert result["data"] == { + CONF_HOST: HOST, + CONF_NAME: "MyGPS", + CONF_PORT: 1234, + } From d361d475164017a17b5fc69c5dfd3659d536bb95 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 31 Jan 2024 19:27:03 +0100 Subject: [PATCH 1262/1544] Add qr code selector (#109214) --- homeassistant/helpers/selector.py | 43 +++++++++++++++++++++++++++++++ tests/helpers/test_selector.py | 29 +++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 52e48724639..8f2d9bf4938 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -565,6 +565,49 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): return self.config["value"] +class QrErrorCorrectionLevel(StrEnum): + """Possible error correction levels for QR code selector.""" + + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class QrCodeSelectorConfig(TypedDict, total=False): + """Class to represent a QR code selector config.""" + + data: str + scale: int + error_correction_level: QrErrorCorrectionLevel + + +@SELECTORS.register("qr_code") +class QrCodeSelector(Selector[QrCodeSelectorConfig]): + """QR code selector.""" + + selector_type = "qr_code" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Required("data"): str, + vol.Optional("scale"): int, + vol.Optional("error_correction_level"): vol.All( + vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value + ), + } + ) + + def __init__(self, config: QrCodeSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(vol.Any(str, None))(data) + return self.config["data"] + + class ConversationAgentSelectorConfig(TypedDict, total=False): """Class to represent a conversation agent selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 633673cac98..00942b396e8 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1112,3 +1112,32 @@ def test_condition_selector_schema( def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test trigger sequence selector.""" _test_selector("trigger", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {"data": "test", "scale": 5}, + ("test",), + (False, 0, []), + ), + ( + {"data": "test"}, + ("test",), + (True, 1, []), + ), + ( + { + "data": "test", + "scale": 5, + "error_correction_level": selector.QrErrorCorrectionLevel.HIGH, + }, + ("test",), + (True, 1, []), + ), + ), +) +def test_qr_code_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test QR code selector.""" + _test_selector("qr_code", schema, valid_selections, invalid_selections) From 605b7312a49c30f0323496208bf8eed91b86a1aa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:39:18 +0000 Subject: [PATCH 1263/1544] Fix ring chimes data update (#109220) * Fix bug with chimes data update * Trigger update in test with time change * Fix test to use freezer * Make test less fragile --- homeassistant/components/ring/coordinator.py | 1 + .../ring/fixtures/chime_devices.json | 35 +++++++++++++++++++ tests/components/ring/test_sensor.py | 31 ++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 tests/components/ring/fixtures/chime_devices.json diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 35692ae2648..5b6412caffa 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -75,6 +75,7 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): if device.id in subscribed_device_ids: data[device.id] = RingDeviceData(device=device) try: + history_task = None async with TaskGroup() as tg: if hasattr(device, "history"): history_task = tg.create_task( diff --git a/tests/components/ring/fixtures/chime_devices.json b/tests/components/ring/fixtures/chime_devices.json new file mode 100644 index 00000000000..5c3e60ec655 --- /dev/null +++ b/tests/components/ring/fixtures/chime_devices.json @@ -0,0 +1,35 @@ +{ + "authorized_doorbots": [], + "chimes": [ + { + "address": "123 Main St", + "alerts": { "connection": "online" }, + "description": "Downstairs", + "device_id": "abcdef123", + "do_not_disturb": { "seconds_left": 0 }, + "features": { "ringtones_enabled": true }, + "firmware_version": "1.2.3", + "id": 123456, + "kind": "chime", + "latitude": 12.0, + "longitude": -70.12345, + "owned": true, + "owner": { + "email": "foo@bar.org", + "first_name": "Marcelo", + "id": 999999, + "last_name": "Assistant" + }, + "settings": { + "ding_audio_id": null, + "ding_audio_user_id": null, + "motion_audio_id": null, + "motion_audio_user_id": null, + "volume": 2 + }, + "time_zone": "America/New_York" + } + ], + "doorbots": [], + "stickup_cams": [] +} diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 5c9a6ecacf7..5fd50f69c13 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,10 +1,17 @@ """The tests for the Ring sensor platform.""" +import logging + +from freezegun.api import FrozenDateTimeFactory import requests_mock +from homeassistant.components.ring.const import SCAN_INTERVAL +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .common import setup_platform +from tests.common import async_fire_time_changed, load_fixture + WIFI_ENABLED = False @@ -48,3 +55,27 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker) ) assert front_door_wifi_signal_strength_state is not None assert front_door_wifi_signal_strength_state.state == "-58" + + +async def test_only_chime_devices( + hass: HomeAssistant, + requests_mock: requests_mock.Mocker, + freezer: FrozenDateTimeFactory, + caplog, +) -> None: + """Tests the update service works correctly if only chimes are returned.""" + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + requests_mock.get( + "https://api.ring.com/clients_api/ring_devices", + text=load_fixture("chime_devices.json", "ring"), + ) + await setup_platform(hass, Platform.SENSOR) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + caplog.clear() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "UnboundLocalError" not in caplog.text # For issue #109210 From 3cbfae5cc73a1fb31e84556d82bb922afacaf282 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 31 Jan 2024 19:49:50 +0100 Subject: [PATCH 1264/1544] Update frontend to 20240131.0 (#109231) --- 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 f12c6abce25..2b005c7e1ad 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==20240112.0"] + "requirements": ["home-assistant-frontend==20240131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f4205091d97..cf9fa157f26 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.4.0 hass-nabucasa==0.75.1 hassil==1.6.0 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240112.0 +home-assistant-frontend==20240131.0 home-assistant-intents==2024.1.29 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b9d7cb8ace7..290a8ef9050 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1059,7 +1059,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240112.0 +home-assistant-frontend==20240131.0 # homeassistant.components.conversation home-assistant-intents==2024.1.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7207fc5398..c611169af69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -855,7 +855,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240112.0 +home-assistant-frontend==20240131.0 # homeassistant.components.conversation home-assistant-intents==2024.1.29 From c59345338e479914c82ecf8b0875297910b9ee69 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 31 Jan 2024 19:57:59 +0100 Subject: [PATCH 1265/1544] Add test for integration migrated in climate (#109224) --- tests/components/climate/test_init.py | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 831a8503b79..9bf89df7fd7 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -604,3 +604,71 @@ async def test_no_warning_implemented_turn_on_off_feature( " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" not in caplog.text ) + + +async def test_no_warning_integration_has_migrated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test no warning when integration migrated using `_enable_turn_on_off_backwards_compatibility`.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities( + [MockClimateEntityTest(name="test", entity_id="climate.test")] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + not in caplog.text + ) + assert ( + "does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." + not in caplog.text + ) + assert ( + " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" + not in caplog.text + ) From 340df38bd00a6d5ae1dab962015b6c0c548bd624 Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Wed, 31 Jan 2024 12:02:21 -0700 Subject: [PATCH 1266/1544] Suppress log warnings when a sensor group has non numeric members (#102828) --- homeassistant/components/group/sensor.py | 14 ++++-- tests/components/group/test_sensor.py | 61 +++++++++++++++++++----- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 84827ef89fa..402a8242af7 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -299,7 +299,7 @@ class SensorGroup(GroupEntity, SensorEntity): unique_id: str | None, name: str, entity_ids: list[str], - mode: bool, + ignore_non_numeric: bool, sensor_type: str, unit_of_measurement: str | None, state_class: SensorStateClass | None, @@ -318,7 +318,8 @@ class SensorGroup(GroupEntity, SensorEntity): self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id - self.mode = all if mode is False else any + self._ignore_non_numeric = ignore_non_numeric + self.mode = all if ignore_non_numeric is False else any self._state_calc: Callable[ [list[tuple[str, float, State]]], tuple[dict[str, str | None], float | None], @@ -358,9 +359,14 @@ class SensorGroup(GroupEntity, SensorEntity): sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) + valid_states.append(True) except ValueError: valid_states.append(False) - if entity_id not in self._state_incorrect: + # Log invalid states unless ignoring non numeric values + if ( + not self._ignore_non_numeric + and entity_id not in self._state_incorrect + ): self._state_incorrect.add(entity_id) _LOGGER.warning( "Unable to use state. Only numerical states are supported," @@ -388,8 +394,6 @@ class SensorGroup(GroupEntity, SensorEntity): state.attributes.get("unit_of_measurement"), self.entity_id, ) - continue - valid_states.append(True) # Set group as unavailable if all members do not have numeric values self._attr_available = any(numeric_state for numeric_state in valid_states) diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 12bb8d0f7de..aa4901e689c 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -245,15 +245,15 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("sensor.second_test") -async def test_sensor_incorrect_state( +async def test_sensor_incorrect_state_with_ignore_non_numeric( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Test the min sensor.""" + """Test that non numeric values are ignored in a group.""" config = { SENSOR_DOMAIN: { "platform": GROUP_DOMAIN, - "name": "test_failure", - "type": "min", + "name": "test_ignore_non_numeric", + "type": "max", "ignore_non_numeric": True, "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], "unique_id": "very_unique_id", @@ -266,24 +266,63 @@ async def test_sensor_incorrect_state( entity_ids = config["sensor"]["entities"] + # Check that the final sensor value ignores the non numeric input + for entity_id, value in dict(zip(entity_ids, VALUES_ERROR)).items(): + hass.states.async_set(entity_id, value) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_ignore_non_numeric") + assert state.state == "17.0" + assert ( + "Unable to use state. Only numerical states are supported," not in caplog.text + ) + + # Check that the final sensor value with all numeric inputs + for entity_id, value in dict(zip(entity_ids, VALUES)).items(): + hass.states.async_set(entity_id, value) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_ignore_non_numeric") + assert state.state == "20.0" + + +async def test_sensor_incorrect_state_with_not_ignore_non_numeric( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that non numeric values cause a group to be unknown.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_failure", + "type": "max", + "ignore_non_numeric": False, + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", + "state_class": SensorStateClass.MEASUREMENT, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + entity_ids = config["sensor"]["entities"] + + # Check that the final sensor value is unavailable if a non numeric input exists for entity_id, value in dict(zip(entity_ids, VALUES_ERROR)).items(): hass.states.async_set(entity_id, value) await hass.async_block_till_done() state = hass.states.get("sensor.test_failure") + assert state.state == "unknown" + assert "Unable to use state. Only numerical states are supported" in caplog.text - assert state.state == "15.3" - assert ( - "Unable to use state. Only numerical states are supported, entity sensor.test_2 with value string excluded from calculation" - in caplog.text - ) - + # Check that the final sensor value is correct with all numeric inputs for entity_id, value in dict(zip(entity_ids, VALUES)).items(): hass.states.async_set(entity_id, value) await hass.async_block_till_done() state = hass.states.get("sensor.test_failure") - assert state.state == "15.3" + assert state.state == "20.0" async def test_sensor_require_all_states(hass: HomeAssistant) -> None: From cf6bcd63dd980e5552d987a74b213616192240a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 Jan 2024 20:40:26 +0100 Subject: [PATCH 1267/1544] Add reauth flow to kitchen sink (#109202) --- .../components/kitchen_sink/__init__.py | 3 +++ .../components/kitchen_sink/config_flow.py | 10 ++++++++++ .../components/kitchen_sink/strings.json | 7 +++++++ .../kitchen_sink/test_config_flow.py | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 5c8088823b2..8369892be85 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -61,6 +61,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if "recorder" in hass.config.components: await _insert_statistics(hass) + # Start a reauth flow + config_entry.async_start_reauth(hass) + return True diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index ded2b84e31c..54104784c50 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -20,3 +20,13 @@ class KitchenSinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Kitchen Sink", data=import_info) + + async def async_step_reauth(self, data): + """Reauth step.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Reauth confirm step.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index ce907a3368d..dca42ce8361 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -1,4 +1,11 @@ { + "config": { + "step": { + "reauth_confirm": { + "description": "Press SUBMIT to reauthenticate" + } + } + }, "issues": { "bad_psu": { "title": "The power supply is not stable", diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 625aa7926fe..e157c3e5d0a 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_import(hass: HomeAssistant) -> None: @@ -46,3 +47,20 @@ async def test_import_once(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth works.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["handler"] == DOMAIN + assert flows[0]["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From 3ce4e53b3206eb906724893dd79115df733f0112 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 31 Jan 2024 20:41:53 +0100 Subject: [PATCH 1268/1544] Sort script actions (#108247) --- homeassistant/helpers/config_validation.py | 40 +++++++++++----------- homeassistant/helpers/script.py | 8 ++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 497a00e40b2..bdf9897a4ba 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1793,21 +1793,21 @@ _SCRIPT_PARALLEL_SCHEMA = vol.Schema( ) -SCRIPT_ACTION_DELAY = "delay" -SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template" -SCRIPT_ACTION_CHECK_CONDITION = "condition" -SCRIPT_ACTION_FIRE_EVENT = "event" -SCRIPT_ACTION_CALL_SERVICE = "call_service" -SCRIPT_ACTION_DEVICE_AUTOMATION = "device" SCRIPT_ACTION_ACTIVATE_SCENE = "scene" -SCRIPT_ACTION_REPEAT = "repeat" +SCRIPT_ACTION_CALL_SERVICE = "call_service" +SCRIPT_ACTION_CHECK_CONDITION = "condition" SCRIPT_ACTION_CHOOSE = "choose" -SCRIPT_ACTION_WAIT_FOR_TRIGGER = "wait_for_trigger" -SCRIPT_ACTION_VARIABLES = "variables" -SCRIPT_ACTION_STOP = "stop" +SCRIPT_ACTION_DELAY = "delay" +SCRIPT_ACTION_DEVICE_AUTOMATION = "device" +SCRIPT_ACTION_FIRE_EVENT = "event" SCRIPT_ACTION_IF = "if" SCRIPT_ACTION_PARALLEL = "parallel" +SCRIPT_ACTION_REPEAT = "repeat" SCRIPT_ACTION_SET_CONVERSATION_RESPONSE = "set_conversation_response" +SCRIPT_ACTION_STOP = "stop" +SCRIPT_ACTION_VARIABLES = "variables" +SCRIPT_ACTION_WAIT_FOR_TRIGGER = "wait_for_trigger" +SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template" def determine_script_action(action: dict[str, Any]) -> str: @@ -1861,21 +1861,21 @@ def determine_script_action(action: dict[str, Any]) -> str: ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { - SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA, - SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA, - SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA, - SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA, - SCRIPT_ACTION_CHECK_CONDITION: CONDITION_ACTION_SCHEMA, - SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA, - SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, + SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA, + SCRIPT_ACTION_CHECK_CONDITION: CONDITION_ACTION_SCHEMA, SCRIPT_ACTION_CHOOSE: _SCRIPT_CHOOSE_SCHEMA, - SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA, - SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, - SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, + SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA, + SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, + SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA, SCRIPT_ACTION_IF: _SCRIPT_IF_SCHEMA, SCRIPT_ACTION_PARALLEL: _SCRIPT_PARALLEL_SCHEMA, + SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA, + SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, + SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, + SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA, + SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA, } diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2a31e02e3de..d1546528ef2 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -259,14 +259,14 @@ def make_script_schema( STATIC_VALIDATION_ACTION_TYPES = ( + cv.SCRIPT_ACTION_ACTIVATE_SCENE, cv.SCRIPT_ACTION_CALL_SERVICE, cv.SCRIPT_ACTION_DELAY, - cv.SCRIPT_ACTION_WAIT_TEMPLATE, cv.SCRIPT_ACTION_FIRE_EVENT, - cv.SCRIPT_ACTION_ACTIVATE_SCENE, - cv.SCRIPT_ACTION_VARIABLES, - cv.SCRIPT_ACTION_STOP, cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE, + cv.SCRIPT_ACTION_STOP, + cv.SCRIPT_ACTION_VARIABLES, + cv.SCRIPT_ACTION_WAIT_TEMPLATE, ) From 45f0e08395e7a9613b3975d3b4a77b83c49a5399 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 31 Jan 2024 20:42:14 +0100 Subject: [PATCH 1269/1544] Add translations to GPSd (#108600) * Add config flow to GPSD * Add translations to GPSd * Add device class * Apply feedback for unique_id and translation_key --- homeassistant/components/gpsd/sensor.py | 15 +++++++++++---- homeassistant/components/gpsd/strings.json | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 2b3fe756d8d..932db081598 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -11,7 +11,11 @@ from gps3.agps3threaded import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, @@ -98,6 +102,9 @@ class GpsdSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = "mode" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["2d_fix", "3d_fix"] def __init__( self, @@ -110,7 +117,7 @@ class GpsdSensor(SensorEntity): identifiers={(DOMAIN, unique_id)}, entry_type=DeviceEntryType.SERVICE, ) - self._attr_unique_id = unique_id + self._attr_unique_id = f"{unique_id}-mode" self.agps_thread = AGPS3mechanism() self.agps_thread.stream_data(host=host, port=port) @@ -120,9 +127,9 @@ class GpsdSensor(SensorEntity): def native_value(self) -> str | None: """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: - return "3D Fix" + return "3d_fix" if self.agps_thread.data_stream.mode == 2: - return "2D Fix" + return "2d_fix" return None @property diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json index ff91b239d0a..20dc283a8bb 100644 --- a/homeassistant/components/gpsd/strings.json +++ b/homeassistant/components/gpsd/strings.json @@ -15,5 +15,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "mode": { + "state": { + "2d_fix": "2D Fix", + "3d_fix": "3D Fix" + }, + "state_attributes": { + "latitude": { "name": "[%key:common::config_flow::data::latitude%]" }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + }, + "elevation": { "name": "Elevation" }, + "gps_time": { "name": "Time" }, + "speed": { "name": "Speed" }, + "climb": { "name": "Climb" }, + "mode": { "name": "Mode" } + } + } + } } } From 69416e7b76cec68eb3687310baf78a054967cc14 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 31 Jan 2024 20:45:31 +0100 Subject: [PATCH 1270/1544] Bump version to 2024.2.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8db9be36902..97f16528317 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 462d3d326d5..106a718ee8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0.dev0" +version = "2024.2.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bbe4483b4a89d22370a1269e5a715e0fdd374f04 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 1 Feb 2024 02:08:32 -0600 Subject: [PATCH 1271/1544] Update rokuecp to 0.19 (#109100) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 6fe70a3ab65..4e255fcf86c 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.18.1"], + "requirements": ["rokuecp==0.19.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index 290a8ef9050..2f3c5100138 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2441,7 +2441,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.18.1 +rokuecp==0.19.0 # homeassistant.components.romy romy==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c611169af69..3e620ad8b3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1863,7 +1863,7 @@ rflink==0.0.65 ring-doorbell[listen]==0.8.5 # homeassistant.components.roku -rokuecp==0.18.1 +rokuecp==0.19.0 # homeassistant.components.romy romy==0.0.7 From ddc1c4bb27718419c2efe2f7a337ea85baa26002 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 1 Feb 2024 06:52:58 +1000 Subject: [PATCH 1272/1544] Fix time to arrival to timestamp in Tessie (#109172) * Fix time to arrival * Update snapshot * Freeze time for snapshot * Fix docstring * Add available_fn * Update snapshot * Dont use variance for full charge * Remove unrelated changes * Revert snapshot * Rename hours_to_datetime --- homeassistant/components/tessie/sensor.py | 23 +++-- homeassistant/components/tessie/strings.json | 2 +- .../tessie/snapshots/test_sensor.ambr | 92 +++++++++---------- tests/components/tessie/test_sensor.py | 8 +- 4 files changed, 69 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 07f54ebde5b..ae9e06b2b35 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -29,6 +30,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util +from homeassistant.util.variance import ignore_variance from .const import DOMAIN from .coordinator import TessieStateUpdateCoordinator @@ -36,8 +38,8 @@ from .entity import TessieEntity @callback -def hours_to_datetime(value: StateType) -> datetime | None: - """Convert relative hours into absolute datetime.""" +def minutes_to_datetime(value: StateType) -> datetime | None: + """Convert relative minutes into absolute datetime.""" if isinstance(value, (int, float)) and value > 0: return dt_util.now() + timedelta(minutes=value) return None @@ -48,6 +50,7 @@ class TessieSensorEntityDescription(SensorEntityDescription): """Describes Tessie Sensor entity.""" value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + available_fn: Callable[[StateType], bool] = lambda _: True DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( @@ -95,7 +98,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="charge_state_minutes_to_full_charge", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=hours_to_datetime, + value_fn=minutes_to_datetime, ), TessieSensorEntityDescription( key="charge_state_battery_range", @@ -219,9 +222,12 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), TessieSensorEntityDescription( key="drive_state_active_route_minutes_to_arrival", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTime.MINUTES, - device_class=SensorDeviceClass.DURATION, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=ignore_variance( + lambda value: dt_util.now() + timedelta(minutes=cast(float, value)), + timedelta(seconds=30), + ), + available_fn=lambda x: x is not None, ), TessieSensorEntityDescription( key="drive_state_active_route_destination", @@ -262,3 +268,8 @@ class TessieSensorEntity(TessieEntity, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.get()) + + @property + def available(self) -> bool: + """Return if sensor is available.""" + return super().available and self.entity_description.available_fn(self.get()) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 57ba1f12bec..8340557843d 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -142,7 +142,7 @@ "drive_state_active_route_miles_to_arrival": { "name": "Distance to arrival" }, - "drive_state_active_route_time_to_arrival": { + "drive_state_active_route_minutes_to_arrival": { "name": "Time to arrival" }, "drive_state_active_route_destination": { diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 921aba0b330..2f5e1e8ddb2 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -493,54 +493,6 @@ 'state': '22.5', }) # --- -# name: test_sensors[sensor.test_duration-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.test_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'tessie', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'drive_state_active_route_minutes_to_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.test_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Test Duration', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.test_duration', - 'last_changed': , - 'last_updated': , - 'state': '59.2', - }) -# --- # name: test_sensors[sensor.test_inside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -953,6 +905,50 @@ 'state': '65', }) # --- +# name: test_sensors[sensor.test_time_to_arrival-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_time_to_arrival', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to arrival', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state_active_route_minutes_to_arrival', + 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.test_time_to_arrival-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Time to arrival', + }), + 'context': , + 'entity_id': 'sensor.test_time_to_arrival', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:59:12+00:00', + }) +# --- # name: test_sensors[sensor.test_time_to_full_charge-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py index fef251f0108..090f9df0ca5 100644 --- a/tests/components/tessie/test_sensor.py +++ b/tests/components/tessie/test_sensor.py @@ -1,4 +1,5 @@ """Test the Tessie sensor platform.""" +from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.const import Platform @@ -9,10 +10,15 @@ from .common import assert_entities, setup_platform async def test_sensors( - hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Tests that the sensor entities are correct.""" + freezer.move_to("2024-01-01 00:00:00+00:00") + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) From 16ad2728a6e722e37bfc58ffa506002357911b17 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:55:48 -0500 Subject: [PATCH 1273/1544] Make zwave_js last seen sensor enabled by default (#109191) * Make zwave_js last seen sensor enabled by default * Add test * Fix test * improve tests --- homeassistant/components/zwave_js/sensor.py | 3 ++- .../zwave_js/fixtures/zp3111-5_state.json | 3 ++- tests/components/zwave_js/test_discovery.py | 24 ++++++++++++------- tests/components/zwave_js/test_init.py | 16 ++++++++----- tests/components/zwave_js/test_sensor.py | 21 +++++++++++++--- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 9fed7158d4a..0b9defc5f62 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -343,6 +343,7 @@ class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription): convert: Callable[ [ControllerStatisticsDataType | NodeStatisticsDataType, str], Any ] = lambda statistics, key: statistics.get(key) + entity_registry_enabled_default: bool = False # Controller statistics descriptions @@ -487,6 +488,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ else None ) ), + entity_registry_enabled_default=True, ), ] @@ -930,7 +932,6 @@ class ZWaveStatisticsSensor(SensorEntity): entity_description: ZWaveJSStatisticsSensorEntityDescription _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_entity_registry_enabled_default = False _attr_has_entity_name = True def __init__( diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json index 68bb0f03af8..55f27b7fa5a 100644 --- a/tests/components/zwave_js/fixtures/zp3111-5_state.json +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -694,7 +694,8 @@ "commandsRX": 0, "commandsDroppedRX": 0, "commandsDroppedTX": 0, - "timeoutResponse": 0 + "timeoutResponse": 0, + "lastSeen": "2024-01-01T12:00:00+00" }, "highestSecurityClass": -1, "isControllerNode": false diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 569e36d3b5c..67f4a8d962f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -22,9 +22,10 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) +from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: @@ -224,14 +225,21 @@ async def test_indicator_test( This test covers indicators that we don't already have device fixtures for. """ + device = dr.async_get(hass).async_get_device( + identifiers={get_device_id(client.driver, indicator_test)} + ) + assert device ent_reg = er.async_get(hass) - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping - assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 - assert ( - len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - ) # include node + controller status - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + entities = er.async_entries_for_device(ent_reg, device.id) + + def len_domain(domain): + return len([entity for entity in entities if entity.domain == domain]) + + assert len_domain(NUMBER_DOMAIN) == 0 + assert len_domain(BUTTON_DOMAIN) == 1 # only ping + assert len_domain(BINARY_SENSOR_DOMAIN) == 1 + assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen + assert len_domain(SWITCH_DOMAIN) == 1 entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" entry = ent_reg.async_get(entity_id) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 7f3a9428dad..4555ee59e1e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -227,14 +227,16 @@ async def test_on_node_added_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 3 - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 + ent_reg = er.async_get(hass) + entities = er.async_entries_for_device(ent_reg, device.id) + # the only entities are the node status sensor, last_seen sensor, and ping button + assert len(entities) == 3 + async def test_existing_node_ready( hass: HomeAssistant, client, multisensor_6, integration @@ -329,14 +331,16 @@ async def test_existing_node_not_ready( assert not device.model assert not device.sw_version - # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 3 - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 + ent_reg = er.async_get(hass) + entities = er.async_entries_for_device(ent_reg, device.id) + # the only entities are the node status sensor, last_seen sensor, and ping button + assert len(entities) == 3 + async def test_existing_node_not_replaced_when_not_ready( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 4e88b2b50cc..a3d36b84382 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -731,14 +731,13 @@ NODE_STATISTICS_SUFFIXES = { NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, "rssi": 7, - "last_seen": "2024-01-01T00:00:00+00:00", } -async def test_statistics_sensors( +async def test_statistics_sensors_no_last_seen( hass: HomeAssistant, zp3111, client, integration, caplog: pytest.LogCaptureFixture ) -> None: - """Test statistics sensors.""" + """Test all statistics sensors but last seen which is enabled by default.""" ent_reg = er.async_get(hass) for prefix, suffixes in ( @@ -880,6 +879,22 @@ async def test_statistics_sensors( ) +async def test_last_seen_statistics_sensors( + hass: HomeAssistant, zp3111, client, integration +) -> None: + """Test last_seen statistics sensors.""" + ent_reg = er.async_get(hass) + + entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" + entry = ent_reg.async_get(entity_id) + assert entry + assert not entry.disabled + + state = hass.states.get(entity_id) + assert state + assert state.state == "2024-01-01T12:00:00+00:00" + + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { "state": 1.23, From f76689fb751b0df44b300b9dc6b258c03ac3071c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 1 Feb 2024 03:38:16 +0100 Subject: [PATCH 1274/1544] Pass verify_ssl to created session in Omada (#109212) * Pass verify_ssl to created session in Omada * Fix tests * Fix tests --- homeassistant/components/tplink_omada/config_flow.py | 4 +++- tests/components/tplink_omada/test_config_flow.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index f6a75abe6d8..3f27417894d 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -61,7 +61,9 @@ async def create_omada_client( is not None ): # TP-Link API uses cookies for login session, so an unsafe cookie jar is required for IP addresses - websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + websession = async_create_clientsession( + hass, cookie_jar=CookieJar(unsafe=True), verify_ssl=verify_ssl + ) else: websession = async_get_clientsession(hass, verify_ssl=verify_ssl) diff --git a/tests/components/tplink_omada/test_config_flow.py b/tests/components/tplink_omada/test_config_flow.py index cf3fddf5943..1a9635d44cb 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -401,7 +401,7 @@ async def test_create_omada_client_with_ip_creates_clientsession( hass, { "host": "10.10.10.10", - "verify_ssl": True, # Verify is meaningless for IP + "verify_ssl": True, "username": "test-username", "password": "test-password", }, @@ -412,5 +412,5 @@ async def test_create_omada_client_with_ip_creates_clientsession( "https://10.10.10.10", "test-username", "test-password", "ws" ) mock_create_clientsession.assert_called_once_with( - hass, cookie_jar=mock_jar.return_value + hass, cookie_jar=mock_jar.return_value, verify_ssl=True ) From ada37f558ce979cd7826f61c22bd5c8bb3a640a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 31 Jan 2024 09:47:31 -1000 Subject: [PATCH 1275/1544] Bump govee-ble to 0.31.0 (#109235) --- 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 1cfa367ebe7..64feedc44c1 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.27.3"] + "requirements": ["govee-ble==0.31.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2f3c5100138..49f0494e76f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -961,7 +961,7 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.27.3 +govee-ble==0.31.0 # homeassistant.components.govee_light_local govee-local-api==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e620ad8b3f..471dfa974a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -778,7 +778,7 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.27.3 +govee-ble==0.31.0 # homeassistant.components.govee_light_local govee-local-api==1.4.1 From 3f619a8022a77d13b0f9abc162df69c1b0564fab Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 1 Feb 2024 05:21:40 -0500 Subject: [PATCH 1276/1544] Remove deprecation warnings for zwave_js climate TURN_ON/TURN_OFF features (#109242) --- homeassistant/components/zwave_js/climate.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 2506db13f6d..f5ad8ce36cd 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -129,6 +129,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): """Representation of a Z-Wave climate.""" _attr_precision = PRECISION_TENTHS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo @@ -193,6 +194,16 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): self._set_modes_and_presets() if self._current_mode and len(self._hvac_presets) > 1: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + if HVACMode.OFF in self._hvac_modes: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + + # We can only support turn on if we are able to turn the device off, + # otherwise the device can be considered always on + if len(self._hvac_modes) == 2 or any( + mode in self._hvac_modes + for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL) + ): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON # If any setpoint value exists, we can assume temperature # can be set if any(self._setpoint_values.values()): From 133b68a68df94191446dbccb42cca4fa9eeec40f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:02:38 +0100 Subject: [PATCH 1277/1544] Apply review comments on proximity (#109249) use a named tuple as TrackedEntityDescriptor --- homeassistant/components/proximity/sensor.py | 27 +++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index a1bd4d33914..4b1e1d1f29d 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import NamedTuple + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -50,6 +52,13 @@ SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ ] +class TrackedEntityDescriptor(NamedTuple): + """Descriptor of a tracked entity.""" + + entity_id: str + identifier: str + + def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: return DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, @@ -70,23 +79,17 @@ async def async_setup_entry( for description in SENSORS_PER_PROXIMITY ] - tracked_entity_descriptors = [] + tracked_entity_descriptors: list[TrackedEntityDescriptor] = [] entity_reg = er.async_get(hass) for tracked_entity_id in coordinator.tracked_entities: if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: tracked_entity_descriptors.append( - { - "entity_id": tracked_entity_id, - "identifier": entity_entry.id, - } + TrackedEntityDescriptor(tracked_entity_id, entity_entry.id) ) else: tracked_entity_descriptors.append( - { - "entity_id": tracked_entity_id, - "identifier": tracked_entity_id, - } + TrackedEntityDescriptor(tracked_entity_id, tracked_entity_id) ) entities += [ @@ -139,15 +142,15 @@ class ProximityTrackedEntitySensor( self, description: SensorEntityDescription, coordinator: ProximityDataUpdateCoordinator, - tracked_entity_descriptor: dict[str, str], + tracked_entity_descriptor: TrackedEntityDescriptor, ) -> None: """Initialize the proximity.""" super().__init__(coordinator) self.entity_description = description - self.tracked_entity_id = tracked_entity_descriptor["entity_id"] + self.tracked_entity_id = tracked_entity_descriptor.entity_id - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor['identifier']}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" self._attr_name = f"{self.tracked_entity_id.split('.')[-1]} {description.name}" self._attr_device_info = _device_info(coordinator) From 70f0d77ba5e17e2ad53619650d80ae2ebe981f0a Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Thu, 1 Feb 2024 09:04:02 +0100 Subject: [PATCH 1278/1544] Fix Xiaomi-ble automations for multiple button devices (#109251) --- homeassistant/components/xiaomi_ble/const.py | 7 ++ .../components/xiaomi_ble/device_trigger.py | 67 +++++++++++-------- .../components/xiaomi_ble/strings.json | 3 + .../xiaomi_ble/test_device_trigger.py | 64 +++++++++++++++++- 4 files changed, 112 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index b6a6369e258..1accfd9dc55 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -21,8 +21,15 @@ XIAOMI_BLE_EVENT: Final = "xiaomi_ble_event" EVENT_CLASS_BUTTON: Final = "button" EVENT_CLASS_MOTION: Final = "motion" +BUTTON: Final = "button" +DOUBLE_BUTTON: Final = "double_button" +TRIPPLE_BUTTON: Final = "tripple_button" +MOTION: Final = "motion" + BUTTON_PRESS: Final = "button_press" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" +DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" +TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" MOTION_DEVICE: Final = "motion_device" diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index a2373da89b4..6d29af9ac11 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -21,15 +21,21 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from .const import ( + BUTTON, BUTTON_PRESS, BUTTON_PRESS_DOUBLE_LONG, CONF_SUBTYPE, DOMAIN, + DOUBLE_BUTTON, + DOUBLE_BUTTON_PRESS_DOUBLE_LONG, EVENT_CLASS, EVENT_CLASS_BUTTON, EVENT_CLASS_MOTION, EVENT_TYPE, + MOTION, MOTION_DEVICE, + TRIPPLE_BUTTON, + TRIPPLE_BUTTON_PRESS_DOUBLE_LONG, XIAOMI_BLE_EVENT, ) @@ -39,47 +45,47 @@ TRIGGERS_BY_TYPE = { MOTION_DEVICE: ["motion_detected"], } +EVENT_TYPES = { + BUTTON: ["button"], + DOUBLE_BUTTON: ["button_left", "button_right"], + TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + MOTION: ["motion"], +} + @dataclass class TriggerModelData: """Data class for trigger model data.""" - schema: vol.Schema event_class: str + event_types: list[str] triggers: list[str] TRIGGER_MODEL_DATA = { BUTTON_PRESS: TriggerModelData( - schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), - vol.Required(CONF_SUBTYPE): vol.In(TRIGGERS_BY_TYPE[BUTTON_PRESS]), - } - ), event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS], ), BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( - schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), - vol.Required(CONF_SUBTYPE): vol.In( - TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG] - ), - } - ), event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + DOUBLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[DOUBLE_BUTTON], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], + ), + TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), MOTION_DEVICE: TriggerModelData( - schema=DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_MOTION]), - vol.Required(CONF_SUBTYPE): vol.In(TRIGGERS_BY_TYPE[MOTION_DEVICE]), - } - ), event_class=EVENT_CLASS_MOTION, + event_types=EVENT_TYPES[MOTION], triggers=TRIGGERS_BY_TYPE[MOTION_DEVICE], ), } @@ -90,13 +96,13 @@ MODEL_DATA = { "MS1BB(MI)": TRIGGER_MODEL_DATA[BUTTON_PRESS], "RTCGQ02LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], "SJWS01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS], - "K9B-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], - "K9B-2BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], - "K9B-3BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], "K9BB-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], "YLAI003": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], "XMWXKG01LM": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], - "XMWXKG01YL": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "K9B-1BTN": TRIGGER_MODEL_DATA[BUTTON_PRESS_DOUBLE_LONG], + "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], + "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], + "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], "MUE4094RT": TRIGGER_MODEL_DATA[MOTION_DEVICE], } @@ -107,7 +113,13 @@ async def async_validate_trigger_config( """Validate trigger config.""" device_id = config[CONF_DEVICE_ID] if model_data := _async_trigger_model_data(hass, device_id): - return model_data.schema(config) # type: ignore[no-any-return] + schema = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(model_data.event_types), + vol.Required(CONF_SUBTYPE): vol.In(model_data.triggers), + } + ) + return schema(config) # type: ignore[no-any-return] return config @@ -120,7 +132,7 @@ async def async_get_triggers( if not (model_data := _async_trigger_model_data(hass, device_id)): return [] - event_type = model_data.event_class + event_types = model_data.event_types event_subtypes = model_data.triggers return [ { @@ -132,6 +144,7 @@ async def async_get_triggers( CONF_TYPE: event_type, CONF_SUBTYPE: event_subtype, } + for event_type in event_types for event_subtype in event_subtypes ] diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 2017ee674bb..c7cbe43bd94 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -48,6 +48,9 @@ }, "trigger_type": { "button": "Button \"{subtype}\"", + "button_left": "Button Left \"{subtype}\"", + "button_middle": "Button Middle \"{subtype}\"", + "button_right": "Button Right \"{subtype}\"", "motion": "{subtype}" } }, diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 5c86173ca01..31f896680bf 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -164,8 +164,8 @@ async def test_get_triggers_double_button(hass: HomeAssistant) -> None: CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device.id, - CONF_TYPE: "button", - CONF_SUBTYPE: "press", + CONF_TYPE: "button_right", + CONF_SUBTYPE: "long_press", "metadata": {}, } triggers = await async_get_device_automations( @@ -334,6 +334,66 @@ async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() +async def test_if_fires_on_double_button_long_press(hass: HomeAssistant, calls) -> None: + """Test for button press event trigger firing.""" + mac = "DC:ED:83:87:12:73" + data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} + entry = await _async_setup_xiaomi_device(hass, mac, data) + + # Emit left button press event so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Ks\x12\x87\x83\xed\xdc!\xad\xb4\xcd\x02\x00\x00,\xf3\xd9\x83", + ), + ) + + # wait for the device being created + await hass.async_block_till_done() + + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device_id = device.id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "button_right", + CONF_SUBTYPE: "press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_right_button_press"}, + }, + }, + ] + }, + ) + # Emit right button press event + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + mac, + b"XYI\x19Ps\x12\x87\x83\xed\xdc\x13~~\xbe\x02\x00\x00\xf0\\;4", + ), + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_right_button_press" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: """Test for motion event trigger firing.""" mac = "DE:70:E8:B2:39:0C" From 29f4c2d5136bd2398889d9b7b7cb7038a8c54dd8 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 1 Feb 2024 08:26:39 +0100 Subject: [PATCH 1279/1544] Fix ZHA update entity not updating installed version (#109260) --- homeassistant/components/zha/update.py | 1 + tests/components/zha/test_update.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 93912fc68db..e92424acf47 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -122,6 +122,7 @@ class ZHAFirmwareUpdateEntity(ZhaEntity, UpdateEntity): self._latest_version_firmware = image self._attr_latest_version = f"0x{image.header.file_version:08x}" self._image_type = image.header.image_type + self._attr_installed_version = self.determine_installed_version() self.async_write_ha_state() @callback diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index c1424ca1730..981b8ba5e1b 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -108,12 +108,18 @@ async def setup_test_data( return zha_device, cluster, fw_image, installed_fw_version +@pytest.mark.parametrize("initial_version_unknown", (False, True)) async def test_firmware_update_notification_from_zigpy( - hass: HomeAssistant, zha_device_joined_restored, zigpy_device + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device, + initial_version_unknown, ) -> None: """Test ZHA update platform - firmware update notification.""" zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( - zha_device_joined_restored, zigpy_device + zha_device_joined_restored, + zigpy_device, + skip_attribute_plugs=initial_version_unknown, ) entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) From e34ebcb1955a8060d3af4f3463d8fa9e153c4152 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 31 Jan 2024 21:56:57 -1000 Subject: [PATCH 1280/1544] Restore support for packages being installed from urls with fragments (#109267) --- homeassistant/util/package.py | 23 +++++++++++++++++++++-- tests/util/test_package.py | 15 ++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index d487edee4a4..ce6276ef4d4 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -9,6 +9,7 @@ import os from pathlib import Path from subprocess import PIPE, Popen import sys +from urllib.parse import urlparse from packaging.requirements import InvalidRequirement, Requirement @@ -40,14 +41,32 @@ def is_installed(requirement_str: str) -> bool: expected input is a pip compatible package specifier (requirement string) e.g. "package==1.0.0" or "package>=1.0.0,<2.0.0" + For backward compatibility, it also accepts a URL with a fragment + e.g. "git+https://github.com/pypa/pip#pip>=1" + Returns True when the requirement is met. Returns False when the package is not installed or doesn't meet req. """ try: req = Requirement(requirement_str) except InvalidRequirement: - _LOGGER.error("Invalid requirement '%s'", requirement_str) - return False + if "#" not in requirement_str: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return False + + # This is likely a URL with a fragment + # example: git+https://github.com/pypa/pip#pip>=1 + + # fragment support was originally used to install zip files, and + # we no longer do this in Home Assistant. However, custom + # components started using it to install packages from git + # urls which would make it would be a breaking change to + # remove it. + try: + req = Requirement(urlparse(requirement_str).fragment) + except InvalidRequirement: + _LOGGER.error("Invalid requirement '%s'", requirement_str) + return False try: if (installed_version := version(req.name)) is None: diff --git a/tests/util/test_package.py b/tests/util/test_package.py index e940fdf6f9c..42ba0131d71 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -217,7 +217,7 @@ async def test_async_get_user_site(mock_env_copy) -> None: assert ret == os.path.join(deps_dir, "lib_dir") -def test_check_package_global() -> None: +def test_check_package_global(caplog: pytest.LogCaptureFixture) -> None: """Test for an installed package.""" pkg = metadata("homeassistant") installed_package = pkg["name"] @@ -229,10 +229,19 @@ def test_check_package_global() -> None: assert package.is_installed(f"{installed_package}<={installed_version}") assert not package.is_installed(f"{installed_package}<{installed_version}") + assert package.is_installed("-1 invalid_package") is False + assert "Invalid requirement '-1 invalid_package'" in caplog.text -def test_check_package_zip() -> None: - """Test for an installed zip package.""" + +def test_check_package_fragment(caplog: pytest.LogCaptureFixture) -> None: + """Test for an installed package with a fragment.""" assert not package.is_installed(TEST_ZIP_REQ) + assert package.is_installed("git+https://github.com/pypa/pip#pip>=1") + assert not package.is_installed("git+https://github.com/pypa/pip#-1 invalid") + assert ( + "Invalid requirement 'git+https://github.com/pypa/pip#-1 invalid'" + in caplog.text + ) def test_get_is_installed() -> None: From 0b6df23ee5924c2a99b242dd5f402134f118d651 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 31 Jan 2024 21:53:18 -1000 Subject: [PATCH 1281/1544] Fix app name sorting in apple_tv (#109274) --- homeassistant/components/apple_tv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index e8fd9d5acfc..789415a1717 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -155,7 +155,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): else: self._app_list = { app_name: app.identifier - for app in sorted(apps, key=lambda app: app_name.lower()) + for app in sorted(apps, key=lambda app: (app.name or "").lower()) if (app_name := app.name) is not None } self.async_write_ha_state() From 1353c1d24cede11934e881d3ab739a043ab5cd8a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 1 Feb 2024 11:14:33 +0100 Subject: [PATCH 1282/1544] Address late review of Tankerkoenig package move (#109277) --- homeassistant/components/tankerkoenig/config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index e15bfbfeb94..9bdf5ef0fe0 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -57,8 +57,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _data: dict[str, Any] = {} - _stations: dict[str, str] = {} + def __init__(self) -> None: + """Init the FlowHandler.""" + super().__init__() + self._data: dict[str, Any] = {} + self._stations: dict[str, str] = {} @staticmethod @callback From fc6cc45ee2c3c768925ef1ab30cf5927aa4edb84 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 1 Feb 2024 08:52:07 +0100 Subject: [PATCH 1283/1544] Fix dalkin climate warnings (#109279) --- homeassistant/components/daikin/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 0c955b1ce4f..047acd3cccf 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -142,7 +142,11 @@ class DaikinClimate(ClimateEntity): ATTR_SWING_MODE: self._attr_swing_modes, } - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + ) if api.device.support_away_mode or api.device.support_advanced_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE From 74ce778691ea28a41431c64dac020bc83f4ff194 Mon Sep 17 00:00:00 2001 From: Luis Andrade Date: Thu, 1 Feb 2024 03:00:22 -0500 Subject: [PATCH 1284/1544] bugfix: name missing in getLogger (#109282) --- homeassistant/components/wyoming/satellite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 0cb2796b9f0..ea7a7d5df0c 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -25,7 +25,7 @@ from .const import DOMAIN from .data import WyomingService from .devices import SatelliteDevice -_LOGGER = logging.getLogger() +_LOGGER = logging.getLogger(__name__) _SAMPLES_PER_CHUNK: Final = 1024 _RECONNECT_SECONDS: Final = 10 From 0070d2171fe9255eb1d5d3c45be8c407eea98d4e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 1 Feb 2024 08:57:12 +0100 Subject: [PATCH 1285/1544] Fix two icon translations for La Marzocco (#109284) --- homeassistant/components/lamarzocco/icons.json | 2 +- homeassistant/components/lamarzocco/switch.py | 1 + tests/components/lamarzocco/snapshots/test_switch.ambr | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index c893ba42848..70adfe95134 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -82,7 +82,7 @@ "off": "mdi:alarm-off" } }, - "steam_boiler_enable": { + "steam_boiler": { "default": "mdi:water-boiler", "state": { "on": "mdi:water-boiler", diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 4cab49064e7..0d4d8d7dc8e 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -28,6 +28,7 @@ class LaMarzoccoSwitchEntityDescription( ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( LaMarzoccoSwitchEntityDescription( key="main", + translation_key="main", name=None, control_fn=lambda coordinator, state: coordinator.lm.set_power(state), is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index bf7062d65bd..789e979894e 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -65,7 +65,7 @@ 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'main', 'unique_id': 'GS01234_main', 'unit_of_measurement': None, }) From e2bbdda0167e0b2119bfeaa79ee1303503a331e2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 1 Feb 2024 09:30:29 +0100 Subject: [PATCH 1286/1544] Remove quality scale platinum from daikin integration (#109292) --- homeassistant/components/daikin/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 7c7f5ce7f2a..0b97ff6b902 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "quality_scale": "platinum", "requirements": ["pydaikin==2.11.1"], "zeroconf": ["_dkapi._tcp.local."] } From e4fc35c5631412fa8ad8a144fe4e756f56d5e8d0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 1 Feb 2024 10:20:52 +0100 Subject: [PATCH 1287/1544] Fix device class repairs issues UOM placeholders in Group (#109294) --- homeassistant/components/group/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 402a8242af7..3c8f7059901 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -559,7 +559,7 @@ class SensorGroup(GroupEntity, SensorEntity): "entity_id": self.entity_id, "device_class": device_class, "source_entities": ", ".join(self._entity_ids), - "uoms:": ", ".join(unit_of_measurements), + "uoms": ", ".join(unit_of_measurements), }, ) else: @@ -574,7 +574,7 @@ class SensorGroup(GroupEntity, SensorEntity): translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), - "uoms:": ", ".join(unit_of_measurements), + "uoms": ", ".join(unit_of_measurements), }, ) return None From 403c2d84404aad089531b9452f0b63ff52e47d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 1 Feb 2024 11:11:28 +0100 Subject: [PATCH 1288/1544] Bump hass-nabucasa from 0.75.1 to 0.76.0 (#109296) --- homeassistant/components/cloud/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/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index f7337e1d771..d314aac2092 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.75.1"] + "requirements": ["hass-nabucasa==0.76.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cf9fa157f26..c5cea22795a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.4.0 -hass-nabucasa==0.75.1 +hass-nabucasa==0.76.0 hassil==1.6.0 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240131.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49f0494e76f..4b5bcad5d76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1019,7 +1019,7 @@ habitipy==0.2.0 habluetooth==2.4.0 # homeassistant.components.cloud -hass-nabucasa==0.75.1 +hass-nabucasa==0.76.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 471dfa974a0..390bce7559d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ habitipy==0.2.0 habluetooth==2.4.0 # homeassistant.components.cloud -hass-nabucasa==0.75.1 +hass-nabucasa==0.76.0 # homeassistant.components.conversation hassil==1.6.0 From c98228110a8d3d366d0f46265299b17717a64cb0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 1 Feb 2024 12:19:53 +0100 Subject: [PATCH 1289/1544] Bump version to 2024.2.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 97f16528317..56a6690320f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 106a718ee8d..c13f67bb130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b0" +version = "2024.2.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 52a8216150fb0de9e861b11537a4bb54dfb072fb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:07:47 -0500 Subject: [PATCH 1290/1544] Add translations for zwave_js entities and services (#109188) --- homeassistant/components/zwave_js/button.py | 2 +- homeassistant/components/zwave_js/icons.json | 53 +- homeassistant/components/zwave_js/sensor.py | 70 +- .../components/zwave_js/strings.json | 791 ++++++++++-------- 4 files changed, 513 insertions(+), 403 deletions(-) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 33d1e6dfa63..876cf60b4cb 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -86,13 +86,13 @@ class ZWaveNodePingButton(ButtonEntity): _attr_should_poll = False _attr_entity_category = EntityCategory.CONFIG _attr_has_entity_name = True + _attr_translation_key = "ping" def __init__(self, driver: Driver, node: ZwaveNode) -> None: """Initialize a ping Z-Wave device button entity.""" self.node = node # Entity class attributes - self._attr_name = "Ping" self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.ping" # device may not be precreated in main handler yet diff --git a/homeassistant/components/zwave_js/icons.json b/homeassistant/components/zwave_js/icons.json index 2280811d3da..2956cf2c6e0 100644 --- a/homeassistant/components/zwave_js/icons.json +++ b/homeassistant/components/zwave_js/icons.json @@ -1,14 +1,34 @@ { "entity": { + "button": { + "ping": { + "default": "mdi:crosshairs-gps" + } + }, "sensor": { + "can": { + "default": "mdi:car-brake-alert" + }, + "commands_dropped": { + "default": "mdi:trash-can" + }, "controller_status": { "default": "mdi:help-rhombus", "state": { + "jammed": "mdi:lock", "ready": "mdi:check", - "unresponsive": "mdi:bell-off", - "jammed": "mdi:lock" + "unresponsive": "mdi:bell-off" } }, + "last_seen": { + "default": "mdi:timer-sync" + }, + "messages_dropped": { + "default": "mdi:trash-can" + }, + "nak": { + "default": "mdi:hand-back-left-off" + }, "node_status": { "default": "mdi:help-rhombus", "state": { @@ -18,7 +38,36 @@ "dead": "mdi:robot-dead", "unknown": "mdi:help-rhombus" } + }, + "successful_commands": { + "default": "mdi:check" + }, + "successful_messages": { + "default": "mdi:check" + }, + "timeout_ack": { + "default": "mdi:ear-hearing-off" + }, + "timeout_callback": { + "default": "mdi:timer-sand-empty" + }, + "timeout_response": { + "default": "mdi:timer-sand-empty" } } + }, + "services": { + "bulk_set_partial_config_parameters": "mdi:cogs", + "clear_lock_usercode": "mdi:eraser", + "invoke_cc_api": "mdi:api", + "multicast_set_value": "mdi:list-box", + "ping": "mdi:crosshairs-gps", + "refresh_notifications": "mdi:bell", + "refresh_value": "mdi:refresh", + "reset_meter": "mdi:meter-electric", + "set_config_parameter": "mdi:cog", + "set_lock_configuration": "mdi:shield-lock", + "set_lock_usercode": "mdi:lock-smart", + "set_value": "mdi:form-textbox" } } diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 0b9defc5f62..0240725ca2d 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -350,55 +350,61 @@ class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription): ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ZWaveJSStatisticsSensorEntityDescription( key="messagesTX", - name="Successful messages (TX)", + translation_key="successful_messages", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="messagesRX", - name="Successful messages (RX)", + translation_key="successful_messages", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedTX", - name="Messages dropped (TX)", + translation_key="messages_dropped", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="messagesDroppedRX", - name="Messages dropped (RX)", + translation_key="messages_dropped", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="NAK", - name="Messages not accepted", + key="NAK", translation_key="nak", state_class=SensorStateClass.TOTAL + ), + ZWaveJSStatisticsSensorEntityDescription( + key="CAN", translation_key="can", state_class=SensorStateClass.TOTAL + ), + ZWaveJSStatisticsSensorEntityDescription( + key="timeoutACK", + translation_key="timeout_ack", state_class=SensorStateClass.TOTAL, ), - ZWaveJSStatisticsSensorEntityDescription( - key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL - ), - ZWaveJSStatisticsSensorEntityDescription( - key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL - ), ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", - name="Timed out responses", + translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="timeoutCallback", - name="Timed out callbacks", + translation_key="timeout_callback", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.average", - name="Average background RSSI (channel 0)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, convert=convert_dict_of_dicts, ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel0.current", - name="Current background RSSI (channel 0)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -406,14 +412,16 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.average", - name="Average background RSSI (channel 1)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, convert=convert_dict_of_dicts, ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel1.current", - name="Current background RSSI (channel 1)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -421,14 +429,16 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.average", - name="Average background RSSI (channel 2)", + translation_key="average_background_rssi", + translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, convert=convert_dict_of_dicts, ), ZWaveJSStatisticsSensorEntityDescription( key="backgroundRSSI.channel2.current", - name="Current background RSSI (channel 2)", + translation_key="current_background_rssi", + translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -440,46 +450,50 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ZWaveJSStatisticsSensorEntityDescription( key="commandsRX", - name="Successful commands (RX)", + translation_key="successful_commands", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="commandsTX", - name="Successful commands (TX)", + translation_key="successful_commands", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedRX", - name="Commands dropped (RX)", + translation_key="commands_dropped", + translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="commandsDroppedTX", - name="Commands dropped (TX)", + translation_key="commands_dropped", + translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="timeoutResponse", - name="Timed out responses", + translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( key="rtt", - name="Round Trip Time", + translation_key="rtt", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), ZWaveJSStatisticsSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), ZWaveJSStatisticsSensorEntityDescription( key="lastSeen", - name="Last Seen", + translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, convert=( lambda statistics, key: ( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index db19c0fceeb..9e2317ba728 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,6 +1,133 @@ { + "config": { + "abort": { + "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "addon_info_failed": "Failed to get Z-Wave JS add-on info.", + "addon_install_failed": "Failed to install the Z-Wave JS add-on.", + "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", + "addon_start_failed": "Failed to start the Z-Wave JS add-on.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_requires_supervisor": "Discovery requires the supervisor.", + "not_zwave_device": "Discovered device is not a Z-Wave device.", + "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." + }, + "error": { + "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ws_url": "Invalid websocket URL", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "{name}", + "progress": { + "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + }, + "step": { + "configure_addon": { + "data": { + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "The add-on will generate security keys if those fields are left empty.", + "title": "Enter the Z-Wave JS add-on configuration" + }, + "hassio_confirm": { + "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" + }, + "install_addon": { + "title": "The Z-Wave JS add-on installation has started" + }, + "manual": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Use the Z-Wave JS Supervisor add-on" + }, + "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "title": "Select connection method" + }, + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + }, + "usb_confirm": { + "description": "Do you want to set up {name} with the Z-Wave JS add-on?" + }, + "zeroconf_confirm": { + "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", + "title": "Discovered Z-Wave JS Server" + } + } + }, + "device_automation": { + "action_type": { + "clear_lock_usercode": "Clear usercode on {entity_name}", + "ping": "Ping device", + "refresh_value": "Refresh the value(s) for {entity_name}", + "reset_meter": "Reset meters on {subtype}", + "set_config_parameter": "Set value of config parameter {subtype}", + "set_lock_usercode": "Set a usercode on {entity_name}", + "set_value": "Set value of a Z-Wave Value" + }, + "condition_type": { + "config_parameter": "Config parameter {subtype} value", + "node_status": "Node status", + "value": "Current value of a Z-Wave Value" + }, + "trigger_type": { + "event.notification.entry_control": "Sent an Entry Control notification", + "event.notification.notification": "Sent a notification", + "event.value_notification.basic": "Basic CC event on {subtype}", + "event.value_notification.central_scene": "Central Scene action on {subtype}", + "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "state.node_status": "Node status changed", + "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", + "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + } + }, "entity": { + "button": { + "ping": { + "name": "Ping" + } + }, "sensor": { + "average_background_rssi": { + "name": "Average background RSSI (channel {channel})" + }, + "can": { + "name": "Collisions" + }, + "commands_dropped": { + "name": "Commands dropped ({direction})" + }, + "controller_status": { + "name": "Status", + "state": { + "jammed": "Jammed", + "ready": "Ready", + "unresponsive": "Unresponsive" + } + }, + "current_background_rssi": { + "name": "Current background RSSI (channel {channel})" + }, + "last_seen": { + "name": "Last seen" + }, + "messages_dropped": { + "name": "Messages dropped ({direction})" + }, + "nak": { + "name": "Messages not accepted" + }, "node_status": { "name": "Node status", "state": { @@ -11,434 +138,354 @@ "unknown": "Unknown" } }, - "controller_status": { - "name": "Status", - "state": { - "ready": "Ready", - "unresponsive": "Unresponsive", - "jammed": "Jammed" - } + "rssi": { + "name": "RSSI" + }, + "rtt": { + "name": "Round trip time" + }, + "successful_commands": { + "name": "Successful commands ({direction})" + }, + "successful_messages": { + "name": "Successful messages ({direction})" + }, + "timeout_ack": { + "name": "Missing ACKs" + }, + "timeout_callback": { + "name": "Timed out callbacks" + }, + "timeout_response": { + "name": "Timed out responses" } } }, - "config": { - "flow_title": "{name}", - "step": { - "manual": { - "data": { - "url": "[%key:common::config_flow::data::url%]" - } - }, - "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave JS add-on?" - }, - "on_supervisor": { - "title": "Select connection method", - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", - "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" - } - }, - "install_addon": { - "title": "The Z-Wave JS add-on installation has started" - }, - "configure_addon": { - "title": "Enter the Z-Wave JS add-on configuration", - "description": "The add-on will generate security keys if those fields are left empty.", - "data": { - "usb_path": "[%key:common::config_flow::data::usb_path%]", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", - "s2_access_control_key": "S2 Access Control Key" - } - }, - "start_addon": { - "title": "The Z-Wave JS add-on is starting." - }, - "hassio_confirm": { - "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" - }, - "zeroconf_confirm": { - "description": "Do you want to add the Z-Wave JS Server with home ID {home_id} found at {url} to Home Assistant?", - "title": "Discovered Z-Wave JS Server" - } - }, - "error": { - "addon_start_failed": "Failed to start the Z-Wave JS add-on. Check the configuration.", - "invalid_ws_url": "Invalid websocket URL", - "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_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "addon_info_failed": "Failed to get Z-Wave JS add-on info.", - "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", - "addon_start_failed": "Failed to start the Z-Wave JS add-on.", - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "discovery_requires_supervisor": "Discovery requires the supervisor.", - "not_zwave_device": "Discovered device is not a Z-Wave device.", - "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave JS add-on." - }, - "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." - } - }, - "options": { - "step": { - "manual": { - "data": { - "url": "[%key:common::config_flow::data::url%]" - } - }, - "on_supervisor": { - "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]", - "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", - "data": { - "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" - } - }, - "install_addon": { - "title": "[%key:component::zwave_js::config::step::install_addon::title%]" - }, - "configure_addon": { - "title": "[%key:component::zwave_js::config::step::configure_addon::title%]", - "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", - "data": { - "usb_path": "[%key:common::config_flow::data::usb_path%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", - "log_level": "Log level", - "emulate_hardware": "Emulate Hardware" - } - }, - "start_addon": { - "title": "[%key:component::zwave_js::config::step::start_addon::title%]" - } - }, - "error": { - "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", - "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_device%]", - "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", - "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", - "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", - "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", - "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." - }, - "progress": { - "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", - "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" - } - }, - "device_automation": { - "trigger_type": { - "event.notification.entry_control": "Sent an Entry Control notification", - "event.notification.notification": "Sent a notification", - "event.value_notification.basic": "Basic CC event on {subtype}", - "event.value_notification.central_scene": "Central Scene action on {subtype}", - "event.value_notification.scene_activation": "Scene Activation on {subtype}", - "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", - "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value", - "state.node_status": "Node status changed" - }, - "condition_type": { - "node_status": "Node status", - "config_parameter": "Config parameter {subtype} value", - "value": "Current value of a Z-Wave Value" - }, - "action_type": { - "clear_lock_usercode": "Clear usercode on {entity_name}", - "set_lock_usercode": "Set a usercode on {entity_name}", - "set_config_parameter": "Set value of config parameter {subtype}", - "set_value": "Set value of a Z-Wave Value", - "refresh_value": "Refresh the value(s) for {entity_name}", - "ping": "Ping device", - "reset_meter": "Reset meters on {subtype}" - } - }, "issues": { - "invalid_server_version": { - "title": "Newer version of Z-Wave JS Server needed", - "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue." - }, "device_config_file_changed": { - "title": "Device configuration file changed: {device_name}", "fix_flow": { + "abort": { + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant.", + "issue_ignored": "Device config file update for {device_name} ignored." + }, "step": { "init": { + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" }, - "title": "Device configuration file changed: {device_name}", - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background." + "title": "Device configuration file changed: {device_name}" } - }, - "abort": { - "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant.", - "issue_ignored": "Device config file update for {device_name} ignored." } + }, + "title": "Device configuration file changed: {device_name}" + }, + "invalid_server_version": { + "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue.", + "title": "Newer version of Z-Wave JS Server needed" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", + "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", + "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "progress": { + "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", + "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emulate Hardware", + "log_level": "Log level", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", + "usb_path": "[%key:common::config_flow::data::usb_path%]" + }, + "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", + "title": "[%key:component::zwave_js::config::step::configure_addon::title%]" + }, + "install_addon": { + "title": "[%key:component::zwave_js::config::step::install_addon::title%]" + }, + "manual": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "on_supervisor": { + "data": { + "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" + }, + "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", + "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]" + }, + "start_addon": { + "title": "[%key:component::zwave_js::config::step::start_addon::title%]" } } }, "services": { - "clear_lock_usercode": { - "name": "Clear lock user code", - "description": "Clears a user code from a lock.", - "fields": { - "code_slot": { - "name": "Code slot", - "description": "Code slot to clear code from." - } - } - }, - "set_lock_usercode": { - "name": "Set lock user code", - "description": "Sets a user code on a lock.", - "fields": { - "code_slot": { - "name": "[%key:component::zwave_js::services::clear_lock_usercode::fields::code_slot::name%]", - "description": "Code slot to set the code." - }, - "usercode": { - "name": "Code", - "description": "Lock code to set." - } - } - }, - "set_config_parameter": { - "name": "Set device configuration parameter", - "description": "Changes the configuration parameters of your Z-Wave devices.", - "fields": { - "endpoint": { - "name": "Endpoint", - "description": "The configuration parameter's endpoint." - }, - "parameter": { - "name": "Parameter", - "description": "The name (or ID) of the configuration parameter you want to configure." - }, - "bitmask": { - "name": "Bitmask", - "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format." - }, - "value": { - "name": "Value", - "description": "The new value to set for this configuration parameter." - }, - "value_size": { - "name": "Value size", - "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." - }, - "value_format": { - "name": "Value format", - "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask." - } - } - }, "bulk_set_partial_config_parameters": { - "name": "Bulk set partial configuration parameters (advanced).", "description": "Allows for bulk setting partial parameters. Useful when multiple partial parameters have to be set at the same time.", "fields": { "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::description%]" + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "parameter": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::name%]", - "description": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::description%]" + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::name%]" }, "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter." + "description": "The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" } - } + }, + "name": "Bulk set partial configuration parameters (advanced)." }, - "refresh_value": { - "name": "Refresh values", - "description": "Force updates the values of a Z-Wave entity.", + "clear_lock_usercode": { + "description": "Clears a user code from a lock.", "fields": { - "entity_id": { - "name": "Entities", - "description": "Entities to refresh." - }, - "refresh_all_values": { - "name": "Refresh all values?", - "description": "Whether to refresh all values (true) or just the primary value (false)." + "code_slot": { + "description": "Code slot to clear code from.", + "name": "Code slot" } - } - }, - "set_value": { - "name": "Set a value (advanced)", - "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing.", - "fields": { - "command_class": { - "name": "Command class", - "description": "The ID of the command class for the value." - }, - "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "The endpoint for the value." - }, - "property": { - "name": "Property", - "description": "The ID of the property for the value." - }, - "property_key": { - "name": "Property key", - "description": "The ID of the property key for the value." - }, - "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "The new value to set." - }, - "options": { - "name": "Options", - "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set." - }, - "wait_for_result": { - "name": "Wait for result?", - "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device." - } - } - }, - "multicast_set_value": { - "name": "Set a value on multiple devices via multicast (advanced)", - "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing.", - "fields": { - "broadcast": { - "name": "Broadcast?", - "description": "Whether command should be broadcast to all devices on the network." - }, - "command_class": { - "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]" - }, - "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::endpoint::description%]" - }, - "property": { - "name": "[%key:component::zwave_js::services::set_value::fields::property::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::property::description%]" - }, - "property_key": { - "name": "[%key:component::zwave_js::services::set_value::fields::property_key::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::property_key::description%]" - }, - "options": { - "name": "[%key:component::zwave_js::services::set_value::fields::options::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::options::description%]" - }, - "value": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", - "description": "[%key:component::zwave_js::services::set_value::fields::value::description%]" - } - } - }, - "ping": { - "name": "Ping a node", - "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep." - }, - "reset_meter": { - "name": "Reset meters on a node", - "description": "Resets the meters on a node.", - "fields": { - "meter_type": { - "name": "Meter type", - "description": "The type of meter to reset. Not all meters support the ability to pick a meter type to reset." - }, - "value": { - "name": "Target value", - "description": "The value that meters should be reset to. Not all meters support the ability to be reset to a specific value." - } - } + }, + "name": "Clear lock user code" }, "invoke_cc_api": { - "name": "Invoke a Command Class API on a node (advanced)", "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` service and require direct calls to the Command Class API.", "fields": { "command_class": { - "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", - "description": "The ID of the command class that you want to issue a command to." + "description": "The ID of the command class that you want to issue a command to.", + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]" }, "endpoint": { - "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", - "description": "The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted." + "description": "The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" }, "method_name": { - "name": "Method name", - "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods." + "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods.", + "name": "Method name" }, "parameters": { - "name": "Parameters", - "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters." + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.", + "name": "Parameters" } - } + }, + "name": "Invoke a Command Class API on a node (advanced)" + }, + "multicast_set_value": { + "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "broadcast": { + "description": "Whether command should be broadcast to all devices on the network.", + "name": "Broadcast?" + }, + "command_class": { + "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]" + }, + "endpoint": { + "description": "[%key:component::zwave_js::services::set_value::fields::endpoint::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" + }, + "options": { + "description": "[%key:component::zwave_js::services::set_value::fields::options::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::options::name%]" + }, + "property": { + "description": "[%key:component::zwave_js::services::set_value::fields::property::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::property::name%]" + }, + "property_key": { + "description": "[%key:component::zwave_js::services::set_value::fields::property_key::description%]", + "name": "[%key:component::zwave_js::services::set_value::fields::property_key::name%]" + }, + "value": { + "description": "[%key:component::zwave_js::services::set_value::fields::value::description%]", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" + } + }, + "name": "Set a value on multiple devices via multicast (advanced)" + }, + "ping": { + "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep.", + "name": "Ping a node" }, "refresh_notifications": { - "name": "Refresh notifications on a node (advanced)", "description": "Refreshes notifications on a node based on notification type and optionally notification event.", "fields": { - "notification_type": { - "name": "Notification Type", - "description": "The Notification Type number as defined in the Z-Wave specs." - }, "notification_event": { - "name": "Notification Event", - "description": "The Notification Event number as defined in the Z-Wave specs." + "description": "The Notification Event number as defined in the Z-Wave specs.", + "name": "Notification Event" + }, + "notification_type": { + "description": "The Notification Type number as defined in the Z-Wave specs.", + "name": "Notification Type" } - } + }, + "name": "Refresh notifications on a node (advanced)" + }, + "refresh_value": { + "description": "Force updates the values of a Z-Wave entity.", + "fields": { + "entity_id": { + "description": "Entities to refresh.", + "name": "Entities" + }, + "refresh_all_values": { + "description": "Whether to refresh all values (true) or just the primary value (false).", + "name": "Refresh all values?" + } + }, + "name": "Refresh values" + }, + "reset_meter": { + "description": "Resets the meters on a node.", + "fields": { + "meter_type": { + "description": "The type of meter to reset. Not all meters support the ability to pick a meter type to reset.", + "name": "Meter type" + }, + "value": { + "description": "The value that meters should be reset to. Not all meters support the ability to be reset to a specific value.", + "name": "Target value" + } + }, + "name": "Reset meters on a node" + }, + "set_config_parameter": { + "description": "Changes the configuration parameters of your Z-Wave devices.", + "fields": { + "bitmask": { + "description": "Target a specific bitmask (see the documentation for more information). Cannot be combined with value_size or value_format.", + "name": "Bitmask" + }, + "endpoint": { + "description": "The configuration parameter's endpoint.", + "name": "Endpoint" + }, + "parameter": { + "description": "The name (or ID) of the configuration parameter you want to configure.", + "name": "Parameter" + }, + "value": { + "description": "The new value to set for this configuration parameter.", + "name": "Value" + }, + "value_format": { + "description": "Format of the value, 0 for signed integer, 1 for unsigned integer, 2 for enumerated, 3 for bitfield. Used in combination with value_size when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "name": "Value format" + }, + "value_size": { + "description": "Size of the value, either 1, 2, or 4. Used in combination with value_format when a config parameter is not defined in your device's configuration file. Cannot be combined with bitmask.", + "name": "Value size" + } + }, + "name": "Set device configuration parameter" }, "set_lock_configuration": { - "name": "Set lock configuration", "description": "Sets the configuration for a lock.", "fields": { - "operation_type": { - "name": "Operation Type", - "description": "The operation type of the lock." - }, - "lock_timeout": { - "name": "Lock timeout", - "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`." - }, - "outside_handles_can_open_door_configuration": { - "name": "Outside handles can open door configuration", - "description": "A list of four booleans which indicate which outside handles can open the door." - }, - "inside_handles_can_open_door_configuration": { - "name": "Inside handles can open door configuration", - "description": "A list of four booleans which indicate which inside handles can open the door." - }, "auto_relock_time": { - "name": "Auto relock time", - "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`." - }, - "hold_and_release_time": { - "name": "Hold and release time", - "description": "Duration in seconds the latch stays retracted." - }, - "twist_assist": { - "name": "Twist assist", - "description": "Enable Twist Assist." + "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`.", + "name": "Auto relock time" }, "block_to_block": { - "name": "Block to block", - "description": "Enable block-to-block functionality." + "description": "Enable block-to-block functionality.", + "name": "Block to block" + }, + "hold_and_release_time": { + "description": "Duration in seconds the latch stays retracted.", + "name": "Hold and release time" + }, + "inside_handles_can_open_door_configuration": { + "description": "A list of four booleans which indicate which inside handles can open the door.", + "name": "Inside handles can open door configuration" + }, + "lock_timeout": { + "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`.", + "name": "Lock timeout" + }, + "operation_type": { + "description": "The operation type of the lock.", + "name": "Operation Type" + }, + "outside_handles_can_open_door_configuration": { + "description": "A list of four booleans which indicate which outside handles can open the door.", + "name": "Outside handles can open door configuration" + }, + "twist_assist": { + "description": "Enable Twist Assist.", + "name": "Twist assist" } - } + }, + "name": "Set lock configuration" + }, + "set_lock_usercode": { + "description": "Sets a user code on a lock.", + "fields": { + "code_slot": { + "description": "Code slot to set the code.", + "name": "[%key:component::zwave_js::services::clear_lock_usercode::fields::code_slot::name%]" + }, + "usercode": { + "description": "Lock code to set.", + "name": "Code" + } + }, + "name": "Set lock user code" + }, + "set_value": { + "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "command_class": { + "description": "The ID of the command class for the value.", + "name": "Command class" + }, + "endpoint": { + "description": "The endpoint for the value.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]" + }, + "options": { + "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set.", + "name": "Options" + }, + "property": { + "description": "The ID of the property for the value.", + "name": "Property" + }, + "property_key": { + "description": "The ID of the property key for the value.", + "name": "Property key" + }, + "value": { + "description": "The new value to set.", + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" + }, + "wait_for_result": { + "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device.", + "name": "Wait for result?" + } + }, + "name": "Set a value (advanced)" } } } From 50dfe4dec0b32d878bec730531218fdd374bb858 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 2 Feb 2024 02:08:41 +1000 Subject: [PATCH 1291/1544] Add climate on/off feature to Tessie (#109239) --- homeassistant/components/tessie/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 8d27305cb0b..d143771ee2c 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -45,7 +45,10 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes: list = [ TessieClimateKeeper.OFF, From 647ac10dd9a0ca3407506e85f5a291418db2246e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 2 Feb 2024 02:09:24 +1000 Subject: [PATCH 1292/1544] Add climate turn on/off feature to Teslemetry (#109241) --- homeassistant/components/teslemetry/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index cea56f35b15..b626d3ef759 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -39,7 +39,10 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] From c31dfd6d00d119bd5bd88df92305f5dccf1d31d9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 1 Feb 2024 16:53:53 +0100 Subject: [PATCH 1293/1544] Don't log warning for core integrations on new feature flags in Climate (#109250) * Don't log for core integration on Climate new feature flags * Add test * Fix test --- homeassistant/components/climate/__init__.py | 3 + tests/components/climate/test_init.py | 114 +++++++++++++++---- 2 files changed, 97 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 43d98ad6bbd..bf663fac365 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -339,6 +339,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _report_turn_on_off(feature: str, method: str) -> None: """Log warning not implemented turn on/off feature.""" + module = type(self).__module__ + if module and "custom_components" not in module: + return report_issue = self._suggest_report_issue() if feature.startswith("TURN"): message = ( diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 9bf89df7fd7..f764ad77aa9 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import voluptuous as vol @@ -415,23 +415,26 @@ async def test_warning_not_implemented_turn_on_off_feature( MockPlatform(async_setup_entry=async_setup_entry_climate_platform), ) - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("climate.test") assert state is not None assert ( - "Entity climate.test (.MockClimateEntityTest'>)" " does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." " Please report it to the author of the 'test' custom integration" in caplog.text ) assert ( - "Entity climate.test (.MockClimateEntityTest'>)" " does not set ClimateEntityFeature.TURN_ON but implements the turn_on method." " Please report it to the author of the 'test' custom integration" @@ -520,16 +523,19 @@ async def test_implicit_warning_not_implemented_turn_on_off_feature( MockPlatform(async_setup_entry=async_setup_entry_climate_platform), ) - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("climate.test") assert state is not None assert ( - "Entity climate.test (.MockClimateEntityTest'>)" " implements HVACMode(s): off, heat and therefore implicitly supports the turn_on/turn_off" " methods without setting the proper ClimateEntityFeature. Please report it to the author" @@ -584,10 +590,13 @@ async def test_no_warning_implemented_turn_on_off_feature( MockPlatform(async_setup_entry=async_setup_entry_climate_platform), ) - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("climate.test") assert state is not None @@ -652,10 +661,13 @@ async def test_no_warning_integration_has_migrated( MockPlatform(async_setup_entry=async_setup_entry_climate_platform), ) - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch.object( + MockClimateEntityTest, "__module__", "tests.custom_components.climate.test_init" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("climate.test") assert state is not None @@ -672,3 +684,65 @@ async def test_no_warning_integration_has_migrated( " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" not in caplog.text ) + + +async def test_no_warning_on_core_integrations_for_on_off_feature_flags( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None +) -> None: + """Test we don't warn on core integration on new turn_on/off feature flags.""" + + class MockClimateEntityTest(MockClimateEntity): + """Mock Climate device.""" + + def turn_on(self) -> None: + """Turn on.""" + + def turn_off(self) -> None: + """Turn off.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities( + [MockClimateEntityTest(name="test", entity_id="climate.test")] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + with patch.object( + MockClimateEntityTest, "__module__", "homeassistant.components.test.climate" + ): + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state is not None + + assert ( + "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." + not in caplog.text + ) From 77b25553e3bb7d74434f1cb70f661fdfb8084b7d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 1 Feb 2024 14:10:24 -0600 Subject: [PATCH 1294/1544] Migrate to new intent error response keys (#109269) --- .../components/conversation/default_agent.py | 120 +++++++++--------- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 18 +-- .../conversation/test_default_agent.py | 110 ++++++++++++++-- 7 files changed, 174 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c9119935213..73c177a3150 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -12,22 +12,15 @@ import re from typing import IO, Any from hassil.expression import Expression, ListReference, Sequence -from hassil.intents import ( - Intents, - ResponseType, - SlotList, - TextSlotList, - WildcardSlotList, -) +from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.recognize import ( MISSING_ENTITY, RecognizeResult, - UnmatchedEntity, UnmatchedTextEntity, recognize_all, ) from hassil.util import merge_dict -from home_assistant_intents import get_intents, get_languages +from home_assistant_intents import ErrorKey, get_intents, get_languages import yaml from homeassistant import core, setup @@ -259,7 +252,7 @@ class DefaultAgent(AbstractConversationAgent): return _make_error_result( language, intent.IntentResponseErrorCode.NO_INTENT_MATCH, - self._get_error_text(ResponseType.NO_INTENT, lang_intents), + self._get_error_text(ErrorKey.NO_INTENT, lang_intents), conversation_id, ) @@ -273,9 +266,7 @@ class DefaultAgent(AbstractConversationAgent): else "", result.unmatched_entities_list, ) - error_response_type, error_response_args = _get_unmatched_response( - result.unmatched_entities - ) + error_response_type, error_response_args = _get_unmatched_response(result) return _make_error_result( language, intent.IntentResponseErrorCode.NO_VALID_TARGETS, @@ -325,7 +316,7 @@ class DefaultAgent(AbstractConversationAgent): return _make_error_result( language, intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents), + self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), conversation_id, ) except intent.IntentUnexpectedError: @@ -333,7 +324,7 @@ class DefaultAgent(AbstractConversationAgent): return _make_error_result( language, intent.IntentResponseErrorCode.UNKNOWN, - self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents), + self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), conversation_id, ) @@ -795,7 +786,7 @@ class DefaultAgent(AbstractConversationAgent): def _get_error_text( self, - response_type: ResponseType, + error_key: ErrorKey, lang_intents: LanguageIntents | None, **response_args, ) -> str: @@ -803,7 +794,7 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: return _DEFAULT_ERROR_TEXT - response_key = response_type.value + response_key = error_key.value response_str = ( lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT ) @@ -916,59 +907,72 @@ def _make_error_result( return ConversationResult(response, conversation_id) -def _get_unmatched_response( - unmatched_entities: dict[str, UnmatchedEntity], -) -> tuple[ResponseType, dict[str, Any]]: - error_response_type = ResponseType.NO_INTENT - error_response_args: dict[str, Any] = {} +def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: + """Get key and template arguments for error when there are unmatched intent entities/slots.""" - if unmatched_name := unmatched_entities.get("name"): - # Unmatched device or entity - assert isinstance(unmatched_name, UnmatchedTextEntity) - error_response_type = ResponseType.NO_ENTITY - error_response_args["entity"] = unmatched_name.text + # Filter out non-text and missing context entities + unmatched_text: dict[str, str] = { + key: entity.text.strip() + for key, entity in result.unmatched_entities.items() + if isinstance(entity, UnmatchedTextEntity) and entity.text != MISSING_ENTITY + } - elif unmatched_area := unmatched_entities.get("area"): - # Unmatched area - assert isinstance(unmatched_area, UnmatchedTextEntity) - error_response_type = ResponseType.NO_AREA - error_response_args["area"] = unmatched_area.text + if unmatched_area := unmatched_text.get("area"): + # area only + return ErrorKey.NO_AREA, {"area": unmatched_area} - return error_response_type, error_response_args + # Area may still have matched + matched_area: str | None = None + if matched_area_entity := result.entities.get("area"): + matched_area = matched_area_entity.text.strip() + + if unmatched_name := unmatched_text.get("name"): + if matched_area: + # device in area + return ErrorKey.NO_ENTITY_IN_AREA, { + "entity": unmatched_name, + "area": matched_area, + } + + # device only + return ErrorKey.NO_ENTITY, {"entity": unmatched_name} + + # Default error + return ErrorKey.NO_INTENT, {} def _get_no_states_matched_response( no_states_error: intent.NoStatesMatchedError, -) -> tuple[ResponseType, dict[str, Any]]: - """Return error response type and template arguments for error.""" - if not ( - no_states_error.area - and (no_states_error.device_classes or no_states_error.domains) - ): - # Device class and domain must be paired with an area for the error - # message. - return ResponseType.NO_INTENT, {} +) -> tuple[ErrorKey, dict[str, Any]]: + """Return key and template arguments for error when intent returns no matching states.""" - error_response_args: dict[str, Any] = {"area": no_states_error.area} - - # Check device classes first, since it's more specific than domain + # Device classes should be checked before domains if no_states_error.device_classes: - # No exposed entities of a particular class in an area. - # Example: "close the bedroom windows" - # - # Only use the first device class for the error message - error_response_args["device_class"] = next(iter(no_states_error.device_classes)) + device_class = next(iter(no_states_error.device_classes)) # first device class + if no_states_error.area: + # device_class in area + return ErrorKey.NO_DEVICE_CLASS_IN_AREA, { + "device_class": device_class, + "area": no_states_error.area, + } - return ResponseType.NO_DEVICE_CLASS, error_response_args + # device_class only + return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class} - # No exposed entities of a domain in an area. - # Example: "turn on lights in kitchen" - assert no_states_error.domains - # - # Only use the first domain for the error message - error_response_args["domain"] = next(iter(no_states_error.domains)) + if no_states_error.domains: + domain = next(iter(no_states_error.domains)) # first domain + if no_states_error.area: + # domain in area + return ErrorKey.NO_DOMAIN_IN_AREA, { + "domain": domain, + "area": no_states_error.area, + } - return ResponseType.NO_DOMAIN, error_response_args + # domain only + return ErrorKey.NO_DOMAIN, {"domain": domain} + + # Default error + return ErrorKey.NO_INTENT, {} def _collect_list_references(expression: Expression, list_names: set[str]) -> None: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 89dd880f69e..ea0a11ae657 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.1.29"] + "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.2.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c5cea22795a..1b47b2693b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ hass-nabucasa==0.76.0 hassil==1.6.0 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240131.0 -home-assistant-intents==2024.1.29 +home-assistant-intents==2024.2.1 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4b5bcad5d76..949c35cc3f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1062,7 +1062,7 @@ holidays==0.41 home-assistant-frontend==20240131.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.29 +home-assistant-intents==2024.2.1 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 390bce7559d..0ce1d0a33dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -858,7 +858,7 @@ holidays==0.41 home-assistant-frontend==20240131.0 # homeassistant.components.conversation -home-assistant-intents==2024.1.29 +home-assistant-intents==2024.2.1 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 23dab0902a9..468f3215cb7 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -339,7 +339,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'An unexpected error occurred while handling the intent', + 'speech': 'An unexpected error occurred', }), }), }), @@ -379,7 +379,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'An unexpected error occurred while handling the intent', + 'speech': 'An unexpected error occurred', }), }), }), @@ -519,7 +519,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called late added alias', + 'speech': 'Sorry, I am not aware of any device called late added alias', }), }), }), @@ -539,7 +539,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen light', }), }), }), @@ -679,7 +679,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called late added light', + 'speech': 'Sorry, I am not aware of any device called late added light', }), }), }), @@ -759,7 +759,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen light', }), }), }), @@ -779,7 +779,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called my cool light', + 'speech': 'Sorry, I am not aware of any device called my cool light', }), }), }), @@ -919,7 +919,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen light', }), }), }), @@ -969,7 +969,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device or entity called renamed light', + 'speech': 'Sorry, I am not aware of any device called renamed light', }), }), }), diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index b992b0086d7..d7182aa3c2f 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2,6 +2,7 @@ from collections import defaultdict from unittest.mock import AsyncMock, patch +from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest from homeassistant.components import conversation @@ -430,8 +431,8 @@ async def test_device_area_context( ) -async def test_error_missing_entity(hass: HomeAssistant, init_components) -> None: - """Test error message when entity is missing.""" +async def test_error_no_device(hass: HomeAssistant, init_components) -> None: + """Test error message when device/entity is missing.""" result = await conversation.async_converse( hass, "turn on missing entity", None, Context(), None ) @@ -440,11 +441,11 @@ async def test_error_missing_entity(hass: HomeAssistant, init_components) -> Non assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( result.response.speech["plain"]["speech"] - == "Sorry, I am not aware of any device or entity called missing entity" + == "Sorry, I am not aware of any device called missing entity" ) -async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: +async def test_error_no_area(hass: HomeAssistant, init_components) -> None: """Test error message when area is missing.""" result = await conversation.async_converse( hass, "turn on the lights in missing area", None, Context(), None @@ -458,10 +459,60 @@ async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: ) -async def test_error_no_exposed_for_domain( +async def test_error_no_device_in_area( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: - """Test error message when no entities for a domain are exposed in an area.""" + """Test error message when area is missing a device/entity.""" + area_registry.async_get_or_create("kitchen") + result = await conversation.async_converse( + hass, "turn on missing entity in the kitchen", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any device called missing entity in the kitchen area" + ) + + +async def test_error_no_domain( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no devices/entities exist for a domain.""" + + # We don't have a sentence for turning on all fans + fan_domain = MatchEntity(name="domain", value="fan", text="") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"domain": fan_domain}, + entities_list=[fan_domain], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "turn on the fans", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any fan" + ) + + +async def test_error_no_domain_in_area( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no devices/entities for a domain exist in an area.""" area_registry.async_get_or_create("kitchen") result = await conversation.async_converse( hass, "turn on the lights in the kitchen", None, Context(), None @@ -475,10 +526,43 @@ async def test_error_no_exposed_for_domain( ) -async def test_error_no_exposed_for_device_class( +async def test_error_no_device_class( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: - """Test error message when no entities of a device class are exposed in an area.""" + """Test error message when no entities of a device class exist.""" + + # We don't have a sentence for opening all windows + window_class = MatchEntity(name="device_class", value="window", text="") + recognize_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={"device_class": window_class}, + entities_list=[window_class], + ) + + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[recognize_result], + ): + result = await conversation.async_converse( + hass, "open the windows", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any window" + ) + + +async def test_error_no_device_class_in_area( + hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry +) -> None: + """Test error message when no entities of a device class exist in an area.""" area_registry.async_get_or_create("bedroom") result = await conversation.async_converse( hass, "open bedroom windows", None, Context(), None @@ -492,8 +576,8 @@ async def test_error_no_exposed_for_device_class( ) -async def test_error_match_failure(hass: HomeAssistant, init_components) -> None: - """Test response with complete match failure.""" +async def test_error_no_intent(hass: HomeAssistant, init_components) -> None: + """Test response with an intent match failure.""" with patch( "homeassistant.components.conversation.default_agent.recognize_all", return_value=[], @@ -506,6 +590,10 @@ async def test_error_match_failure(hass: HomeAssistant, init_components) -> None assert ( result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH ) + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I couldn't understand that" + ) async def test_no_states_matched_default_error( @@ -601,5 +689,5 @@ async def test_all_domains_loaded( assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS assert ( result.response.speech["plain"]["speech"] - == "Sorry, I am not aware of any device or entity called test light" + == "Sorry, I am not aware of any device called test light" ) From a8b39ce332f0ea3373ab7b1105343ceb1997eddb Mon Sep 17 00:00:00 2001 From: Josh Pettersen <12600312+bubonicbob@users.noreply.github.com> Date: Thu, 1 Feb 2024 12:01:05 -0800 Subject: [PATCH 1295/1544] Remove battery charge sensor from powerwall (#109271) --- homeassistant/components/powerwall/sensor.py | 16 ---------------- tests/components/powerwall/test_sensor.py | 2 -- 2 files changed, 18 deletions(-) diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 24aeb9e4f4e..9e17cd32e9c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -113,12 +113,6 @@ POWERWALL_INSTANT_SENSORS = ( ) -def _get_battery_charge(battery_data: BatteryResponse) -> float: - """Get the current value in %.""" - ratio = float(battery_data.energy_remaining) / float(battery_data.capacity) - return round(100 * ratio, 1) - - BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ PowerwallSensorEntityDescription[BatteryResponse, int]( key="battery_capacity", @@ -202,16 +196,6 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ suggested_display_precision=1, value_fn=lambda battery_data: battery_data.energy_remaining, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( - key="charge", - translation_key="charge", - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=0, - value_fn=_get_battery_charge, - ), PowerwallSensorEntityDescription[BatteryResponse, str]( key="grid_state", translation_key="grid_state", diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 11b4f25e4a3..2de79a6a6dc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -157,7 +157,6 @@ async def test_sensors( float(hass.states.get("sensor.mysite_tg0123456789ab_battery_remaining").state) == 14.715 ) - assert float(hass.states.get("sensor.mysite_tg0123456789ab_charge").state) == 100.0 assert ( str(hass.states.get("sensor.mysite_tg0123456789ab_grid_state").state) == "grid_compliant" @@ -187,7 +186,6 @@ async def test_sensors( float(hass.states.get("sensor.mysite_tg9876543210ba_battery_remaining").state) == 15.137 ) - assert float(hass.states.get("sensor.mysite_tg9876543210ba_charge").state) == 100.0 assert ( str(hass.states.get("sensor.mysite_tg9876543210ba_grid_state").state) == "grid_compliant" From b464e77112c32ea81743994cc10b05803a05a4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 1 Feb 2024 12:59:41 +0100 Subject: [PATCH 1296/1544] Bump airthings-ble to 0.6.1 (#109302) Bump airthings-ble --- homeassistant/components/airthings_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/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 03b42410d66..97e27793da2 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.6.0"] + "requirements": ["airthings-ble==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 949c35cc3f6..f2e2d6dc011 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -410,7 +410,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.0 +airthings-ble==0.6.1 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ce1d0a33dc..9ead917d8f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,7 +383,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.6.0 +airthings-ble==0.6.1 # homeassistant.components.airthings airthings-cloud==0.2.0 From 6aba79d7b9eea7456771554a27246cb44f8ec3af Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 1 Feb 2024 17:07:55 +0100 Subject: [PATCH 1297/1544] Verify Ecovacs mqtt config (#109306) --- homeassistant/components/ecovacs/controller.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 06e3a1acccd..27a1996c3e9 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -74,11 +74,16 @@ class EcovacsController: async def initialize(self) -> None: """Init controller.""" + mqtt_config_verfied = False try: devices = await self._api_client.get_devices() credentials = await self._authenticator.authenticate() for device_config in devices: if isinstance(device_config, DeviceInfo): + # MQTT device + if not mqtt_config_verfied: + await self._mqtt.verify_config() + mqtt_config_verfied = True device = Device(device_config, self._authenticator) await device.initialize(self._mqtt) self.devices.append(device) From faf2a90cd13c55c5ec67c0269f3943441c7e313a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 1 Feb 2024 17:05:02 +0100 Subject: [PATCH 1298/1544] Bump pytedee_async to 0.2.13 (#109307) bump --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index dbed87bb890..0a13b2266fa 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.12"] + "requirements": ["pytedee-async==0.2.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2e2d6dc011..09793f1e3fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2163,7 +2163,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.12 +pytedee-async==0.2.13 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ead917d8f6..3228c579ea5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.12 +pytedee-async==0.2.13 # homeassistant.components.motionmount python-MotionMount==0.3.1 From ca539630a6cf2ba8b55f37f975f72536352ca615 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 1 Feb 2024 17:07:08 +0100 Subject: [PATCH 1299/1544] Do not use a battery device class for Shelly analog input sensor (#109311) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/shelly/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 57e60c8fc48..e46800963a3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -958,7 +958,6 @@ RPC_SENSORS: Final = { sub_key="percent", name="Analog input", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), } From a535bda8215b5478daa21590c620c1af1560c094 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Feb 2024 12:34:23 -0600 Subject: [PATCH 1300/1544] Fix race in loading service descriptions (#109316) --- homeassistant/helpers/service.py | 5 ++ tests/helpers/test_service.py | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5a9786eb0fa..30516e3a099 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -608,6 +608,11 @@ async def async_get_all_descriptions( # Files we loaded for missing descriptions loaded: dict[str, JSON_TYPE] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new services get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + services = {domain: service.copy() for domain, service in services.items()} if domains_with_missing_services: ints_or_excs = await async_get_integrations(hass, domains_with_missing_services) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 07e68e081b3..90f9b65aaba 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,4 +1,5 @@ """Test service helpers.""" +import asyncio from collections.abc import Iterable from copy import deepcopy from typing import Any @@ -782,6 +783,84 @@ async def test_async_get_all_descriptions_dynamically_created_services( } +async def test_async_get_all_descriptions_new_service_added_while_loading( + hass: HomeAssistant, +) -> None: + """Test async_get_all_descriptions when a new service is added while loading translations.""" + group = hass.components.group + group_config = {group.DOMAIN: {}} + await async_setup_component(hass, group.DOMAIN, group_config) + descriptions = await service.async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert "description" in descriptions["group"]["reload"] + assert "fields" in descriptions["group"]["reload"] + + logger = hass.components.logger + logger_domain = logger.DOMAIN + logger_config = {logger_domain: {}} + + translations_called = asyncio.Event() + translations_wait = asyncio.Event() + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + translations_called.set() + await translations_wait.wait() + translation_key_prefix = f"component.{logger_domain}.services.set_default_level" + return { + f"{translation_key_prefix}.name": "Translated name", + f"{translation_key_prefix}.description": "Translated description", + f"{translation_key_prefix}.fields.level.name": "Field name", + f"{translation_key_prefix}.fields.level.description": "Field description", + f"{translation_key_prefix}.fields.level.example": "Field example", + } + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + side_effect=async_get_translations, + ): + await async_setup_component(hass, logger_domain, logger_config) + task = asyncio.create_task(service.async_get_all_descriptions(hass)) + await translations_called.wait() + # Now register a new service while translations are being loaded + hass.services.async_register(logger_domain, "new_service", lambda x: None, None) + service.async_set_service_schema( + hass, logger_domain, "new_service", {"description": "new service"} + ) + translations_wait.set() + descriptions = await task + + # Two domains should be present + assert len(descriptions) == 2 + + logger_descriptions = descriptions[logger_domain] + + # The new service was loaded after the translations were loaded + # so it should not appear until the next time we fetch + assert "new_service" not in logger_descriptions + + set_default_level = logger_descriptions["set_default_level"] + + assert set_default_level["name"] == "Translated name" + assert set_default_level["description"] == "Translated description" + set_default_level_fields = set_default_level["fields"] + assert set_default_level_fields["level"]["name"] == "Field name" + assert set_default_level_fields["level"]["description"] == "Field description" + assert set_default_level_fields["level"]["example"] == "Field example" + + descriptions = await service.async_get_all_descriptions(hass) + assert "description" in descriptions[logger_domain]["new_service"] + assert descriptions[logger_domain]["new_service"]["description"] == "new service" + + async def test_register_with_mixed_case(hass: HomeAssistant) -> None: """Test registering a service with mixed case. From 0015af0b3c2448b9946e27f79efc7311aae187c4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 1 Feb 2024 13:40:29 -0600 Subject: [PATCH 1301/1544] Move default response out of sentence trigger registration and into agent (#109317) * Move default response out of trigger and into agent * Add test --- .../components/conversation/default_agent.py | 7 ++- .../components/conversation/trigger.py | 7 ++- tests/components/conversation/test_trigger.py | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 73c177a3150..a2cb3b68041 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -231,7 +231,10 @@ class DefaultAgent(AbstractConversationAgent): ) ) - # Use last non-empty result as response + # Use last non-empty result as response. + # + # There may be multiple copies of a trigger running when editing in + # the UI, so it's critical that we filter out empty responses here. response_text: str | None = None for trigger_response in trigger_responses: response_text = response_text or trigger_response @@ -239,7 +242,7 @@ class DefaultAgent(AbstractConversationAgent): # Convert to conversation result response = intent.IntentResponse(language=language) response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text or "") + response.async_set_speech(response_text or "Done") return ConversationResult(response=response) diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index d38bb69f3e1..4600135c1e5 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -98,7 +98,12 @@ async def async_attach_trigger( # mypy does not understand the type narrowing, unclear why return automation_result.conversation_response # type: ignore[return-value] - return "Done" + # It's important to return None here instead of a string. + # + # When editing in the UI, a copy of this trigger is registered. + # If we return a string from here, there is a race condition between the + # two trigger copies for who will provide a response. + return None default_agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) assert isinstance(default_agent, DefaultAgent) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index e40c7554fdd..26626a04079 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -7,6 +7,7 @@ from homeassistant.helpers import trigger from homeassistant.setup import async_setup_component from tests.common import async_mock_service +from tests.typing import WebSocketGenerator @pytest.fixture @@ -99,6 +100,63 @@ async def test_response(hass: HomeAssistant, setup_comp) -> None: assert service_response["response"]["speech"]["plain"]["speech"] == response +async def test_subscribe_trigger_does_not_interfere_with_responses( + hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator +) -> None: + """Test that subscribing to a trigger from the websocket API does not interfere with responses.""" + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 5, + "type": "subscribe_trigger", + "trigger": {"platform": "conversation", "command": ["test sentence"]}, + } + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "test sentence", + }, + blocking=True, + return_response=True, + ) + + # Default response, since no automations with responses are registered + assert service_response["response"]["speech"]["plain"]["speech"] == "Done" + + # Now register a trigger with a response + assert await async_setup_component( + hass, + "automation", + { + "automation test1": { + "trigger": { + "platform": "conversation", + "command": ["test sentence"], + }, + "action": { + "set_conversation_response": "test response", + }, + } + }, + ) + + service_response = await hass.services.async_call( + "conversation", + "process", + { + "text": "test sentence", + }, + blocking=True, + return_response=True, + ) + + # Response will now come through + assert service_response["response"]["speech"]["plain"]["speech"] == "test response" + + async def test_same_trigger_multiple_sentences( hass: HomeAssistant, calls, setup_comp ) -> None: From 3d80c4f7f6e4a14fa22fc62f786b17549be0d253 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 1 Feb 2024 22:13:41 +0100 Subject: [PATCH 1302/1544] Update Home Assistant base image to 2024.02.0 (#109329) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 824d580913d..d0baa4ac18e 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.01.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.01.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.01.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.01.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.01.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 15a1a4bfdfabb6af97a90f9bc3f8fbe24cef57f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 1 Feb 2024 22:06:34 +0100 Subject: [PATCH 1303/1544] Fix custom attribute lookup in Traccar Server (#109331) --- .../components/traccar_server/coordinator.py | 14 ++++++++------ .../components/traccar_server/device_tracker.py | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 337d0dcafbb..90c910e6062 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -93,10 +93,9 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat skip_accuracy_filter = False for custom_attr in self.custom_attributes: - attr[custom_attr] = getattr( - device["attributes"], + attr[custom_attr] = device["attributes"].get( custom_attr, - getattr(position["attributes"], custom_attr, None), + position["attributes"].get(custom_attr, None), ) if custom_attr in self.skip_accuracy_filter_for: skip_accuracy_filter = True @@ -151,13 +150,16 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat device = get_device(event["deviceId"], devices) self.hass.bus.async_fire( # This goes against two of the HA core guidelines: - # 1. Event names should be prefixed with the domain name of the integration + # 1. Event names should be prefixed with the domain name of + # the integration # 2. This should be event entities - # However, to not break it for those who currently use the "old" integration, this is kept as is. + # + # However, to not break it for those who currently use + # the "old" integration, this is kept as is. f"traccar_{EVENTS[event['type']]}", { "device_traccar_id": event["deviceId"], - "device_name": getattr(device, "name", None), + "device_name": device["name"] if device else None, "type": event["type"], "serverTime": event["eventTime"], "attributes": event["attributes"], diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index 2abcc6398fb..226d942e465 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -51,12 +51,13 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific attributes.""" + geofence_name = self.traccar_geofence["name"] if self.traccar_geofence else None return { **self.traccar_attributes, ATTR_ADDRESS: self.traccar_position["address"], ATTR_ALTITUDE: self.traccar_position["altitude"], ATTR_CATEGORY: self.traccar_device["category"], - ATTR_GEOFENCE: getattr(self.traccar_geofence, "name", None), + ATTR_GEOFENCE: geofence_name, ATTR_MOTION: self.traccar_position["attributes"].get("motion", False), ATTR_SPEED: self.traccar_position["speed"], ATTR_STATUS: self.traccar_device["status"], From fe4ad30ade1758760a35730bb560b78acceafc24 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 1 Feb 2024 22:28:02 +0100 Subject: [PATCH 1304/1544] Add device class to tesla wall connector session energy (#109333) --- homeassistant/components/tesla_wall_connector/sensor.py | 2 ++ tests/components/tesla_wall_connector/test_sensor.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 09933d628fe..67d3d4ba22e 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -153,7 +153,9 @@ WALL_CONNECTOR_SENSORS = [ key="session_energy_wh", translation_key="session_energy_wh", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].session_energy_wh, + device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, ), WallConnectorSensorDescription( diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 684d7de0e82..28b50ba72ea 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -47,7 +47,7 @@ async def test_sensors(hass: HomeAssistant) -> None: "sensor.tesla_wall_connector_phase_c_voltage", "232.1", "230" ), EntityAndExpectedValues( - "sensor.tesla_wall_connector_session_energy", "1234.56", "112.2" + "sensor.tesla_wall_connector_session_energy", "1.23456", "0.1122" ), ] From f77bd13cc03474860e52e01862a6536a0d86ee3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 1 Feb 2024 22:29:58 +0100 Subject: [PATCH 1305/1544] Bump version to 2024.2.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 56a6690320f..84afe7ce1b2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index c13f67bb130..24a3d4a9d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b1" +version = "2024.2.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 66d802b5e50952dc0a68dedf672aed97221c69ce Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 2 Feb 2024 10:37:49 +0100 Subject: [PATCH 1306/1544] Follow up swiss_public_transport migration fix of unique ids (#107873) improve migration fix of unique ids - follow up to #107087 --- .../swiss_public_transport/__init__.py | 11 +++++++---- .../swiss_public_transport/test_init.py | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index a510b5b7414..d87b711e376 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -89,7 +89,9 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_remove_device(dev.id) + device_registry.async_update_device( + dev.id, remove_config_entry_id=config_entry.entry_id + ) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" @@ -105,12 +107,13 @@ async def async_migrate_entry( ) # Set a valid unique id for config entries - config_entry.unique_id = new_unique_id config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry) + hass.config_entries.async_update_entry(config_entry, unique_id=new_unique_id) _LOGGER.debug( - "Migration to minor version %s successful", config_entry.minor_version + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, ) return True diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py index f2b4e41ed71..2c8e12e04bf 100644 --- a/tests/components/swiss_public_transport/test_init.py +++ b/tests/components/swiss_public_transport/test_init.py @@ -45,25 +45,26 @@ CONNECTIONS = [ ] -async def test_migration_1_to_2( +async def test_migration_1_1_to_1_2( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test successful setup.""" + config_entry_faulty = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + title="MIGRATION_TEST", + version=1, + minor_version=1, + ) + config_entry_faulty.add_to_hass(hass) + with patch( "homeassistant.components.swiss_public_transport.OpendataTransport", return_value=AsyncMock(), ) as mock: mock().connections = CONNECTIONS - config_entry_faulty = MockConfigEntry( - domain=DOMAIN, - data=MOCK_DATA_STEP, - title="MIGRATION_TEST", - minor_version=1, - ) - config_entry_faulty.add_to_hass(hass) - # Setup the config entry await hass.config_entries.async_setup(config_entry_faulty.entry_id) await hass.async_block_till_done() From 3e7dc3588d0650b98f02737bc72865393725a382 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 2 Feb 2024 11:31:16 -0500 Subject: [PATCH 1307/1544] Add independent session in honeywell (#108435) --- .../components/honeywell/__init__.py | 15 ++++++++++----- tests/components/honeywell/conftest.py | 19 +++++++++++++++++++ tests/components/honeywell/test_init.py | 16 ++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index c79d99276b1..baabf4ca4d8 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -8,7 +8,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from .const import ( _LOGGER, @@ -48,9 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - client = aiosomecomfort.AIOSomeComfort( - username, password, session=async_get_clientsession(hass) - ) + if len(hass.config_entries.async_entries(DOMAIN)) > 1: + session = async_create_clientsession(hass) + else: + session = async_get_clientsession(hass) + + client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() await client.discover() @@ -76,7 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(devices) == 0: _LOGGER.debug("No devices found") return False - data = HoneywellData(config_entry.entry_id, client, devices) hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 876050586d2..5c5b6c0a44a 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -39,6 +39,15 @@ def config_data(): } +@pytest.fixture +def another_config_data(): + """Provide configuration data for tests.""" + return { + CONF_USERNAME: "user2", + CONF_PASSWORD: "fake2", + } + + @pytest.fixture def config_options(): """Provide configuratio options for test.""" @@ -55,6 +64,16 @@ def config_entry(config_data, config_options): ) +@pytest.fixture +def config_entry2(another_config_data, config_options): + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=another_config_data, + options=config_options, + ) + + @pytest.fixture def device(): """Mock a somecomfort.Device.""" diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ccfc2c5d264..98578217af6 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -33,6 +33,22 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - ) # 1 climate entity; 2 sensor entities +@patch("homeassistant.components.honeywell.UPDATE_LOOP_SLEEP_TIME", 0) +async def test_setup_multiple_entry( + hass: HomeAssistant, config_entry: MockConfigEntry, config_entry2: MockConfigEntry +) -> None: + """Initialize the config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry2.entry_id) + await hass.async_block_till_done() + assert config_entry2.state is ConfigEntryState.LOADED + + async def test_setup_multiple_thermostats( hass: HomeAssistant, config_entry: MockConfigEntry, From 41ad3d898739ff2e4f8dc262cf996c6e6ae1f8b8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:32:50 +0100 Subject: [PATCH 1308/1544] Add migrated ClimateEntityFeature for Atag (#108961) --- homeassistant/components/atag/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 9b2729f141e..a5f119e3a2b 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -46,6 +46,7 @@ class AtagThermostat(AtagEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, atag_id): """Initialize an Atag climate device.""" From a2b6b0a0bcd96b9498506cb8badf0c7bd64610ad Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:33:54 +0100 Subject: [PATCH 1309/1544] Add TURN_ON/OFF ClimateEntityFeature for Fibaro (#108963) --- homeassistant/components/fibaro/climate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 18fef8dbe7a..42b8a5c0446 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -126,6 +126,8 @@ async def async_setup_entry( class FibaroThermostat(FibaroDevice, ClimateEntity): """Representation of a Fibaro Thermostat.""" + _enable_turn_on_off_backwards_compatibility = False + def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) @@ -209,6 +211,11 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): if mode in OPMODES_PRESET: self._attr_preset_modes.append(OPMODES_PRESET[mode]) + if HVACMode.OFF in self._attr_hvac_modes and len(self._attr_hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" _LOGGER.debug( From bd5bc6b83de66c6fa5e2a0eb680465b7765a0761 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 07:39:54 +0100 Subject: [PATCH 1310/1544] Add TURN_ON/OFF ClimateEntityFeature for Matter (#108974) * Add TURN_ON/OFF ClimateEntityFeature for Matter * Adjust matter --- homeassistant/components/matter/climate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index a22f9174d2a..8769fc430d8 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -73,11 +73,8 @@ class MatterClimate(MatterEntity, ClimateEntity): """Representation of a Matter climate entity.""" _attr_temperature_unit: str = UnitOfTemperature.CELSIUS - _attr_supported_features: ClimateEntityFeature = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ) _attr_hvac_mode: HVACMode = HVACMode.OFF + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -99,6 +96,13 @@ class MatterClimate(MatterEntity, ClimateEntity): self._attr_hvac_modes.append(HVACMode.COOL) if feature_map & ThermostatFeature.kAutoMode: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + ) + if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" From 8bb98b4146988bfbe67801c36b16c38a41e55430 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:31:35 +0100 Subject: [PATCH 1311/1544] Add TURN_ON/OFF ClimateEntityFeature for Modbus (#109133) --- homeassistant/components/modbus/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 71c01d20205..637478fffd4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -97,7 +97,12 @@ async def async_setup_platform( class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Representation of a Modbus Thermostat.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 550c0bf3c329c5b1f631184feaef7ed5f24a9cd6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:33:13 +0100 Subject: [PATCH 1312/1544] Add migrated ClimateEntityFeature for SwitchBot Cloud (#109136) --- homeassistant/components/switchbot_cloud/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 803669c806d..d184063939a 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -80,6 +80,7 @@ class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature = 21 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False async def _do_send_command( self, From 8ed0af2fb7d57c39612dd22a885dbf27692e2049 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:48:01 +0100 Subject: [PATCH 1313/1544] Add TURN_ON/OFF ClimateEntityFeature for KNX (#109138) --- homeassistant/components/knx/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 72039e1300f..1038cdde80f 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -134,12 +134,17 @@ class KNXClimate(KnxEntity, ClimateEntity): _device: XknxClimate _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of a KNX climate device.""" super().__init__(_create_climate(xknx, config)) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON + ) + if self._device.supports_on_off: + self._attr_supported_features |= ClimateEntityFeature.TURN_OFF if self.preset_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_target_temperature_step = self._device.temperature_step From e720e82fd6cb86cac1d167ebd6141fe100e3d5ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:31:09 +0100 Subject: [PATCH 1314/1544] Add migrated ClimateEntityFeature for Nibe Heat Pump (#109140) --- homeassistant/components/nibe_heatpump/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 38a3a5f825c..3a89f4f6022 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -72,6 +72,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 5a4f88349a9f802505502699951a749ca2951312 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Feb 2024 15:39:44 -0600 Subject: [PATCH 1315/1544] Fix stale camera error message in img_util (#109325) --- homeassistant/components/camera/img_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index dcb321d5ebb..e41e43c3a3c 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -98,6 +98,6 @@ class TurboJPEGSingleton: TurboJPEGSingleton.__instance = TurboJPEG() except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Error loading libturbojpeg; Cameras may impact HomeKit performance" + "Error loading libturbojpeg; Camera snapshot performance will be sub-optimal" ) TurboJPEGSingleton.__instance = False From 0c82e0a6181451bb83caa325a904ca098280a4ba Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 2 Feb 2024 09:46:53 +0100 Subject: [PATCH 1316/1544] Correct modbus commit validation, too strict on integers (#109338) --- .../components/modbus/base_platform.py | 18 ++++++++---------- tests/components/modbus/test_sensor.py | 10 +++++----- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 877d33afbcc..cdc1e7a6986 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -182,7 +182,6 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._scale = config[CONF_SCALE] - self._precision = config.get(CONF_PRECISION, 2) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 @@ -196,11 +195,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): DataType.UINT32, DataType.UINT64, ) - if self._value_is_int: - if self._min_value: - self._min_value = round(self._min_value) - if self._max_value: - self._max_value = round(self._max_value) + if not self._value_is_int: + self._precision = config.get(CONF_PRECISION, 2) + else: + self._precision = config.get(CONF_PRECISION, 0) def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: """Do swap as needed.""" @@ -235,13 +233,13 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): return None val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: - return str(self._min_value) + val = self._min_value if self._max_value is not None and val > self._max_value: - return str(self._max_value) + val = self._max_value if self._zero_suppress is not None and abs(val) <= self._zero_suppress: return "0" - if self._precision == 0 or self._value_is_int: - return str(int(round(val, 0))) + if self._precision == 0: + return str(round(val)) return f"{float(val):.{self._precision}f}" def unpack_structure_result(self, registers: list[int]) -> str | None: diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 7c58290b143..97571041482 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -357,7 +357,7 @@ async def test_config_wrong_struct_sensor( }, [7], False, - "34", + "34.0000", ), ( { @@ -379,7 +379,7 @@ async def test_config_wrong_struct_sensor( }, [9], False, - "18", + "18.5", ), ( { @@ -390,7 +390,7 @@ async def test_config_wrong_struct_sensor( }, [1], False, - "2", + "2.40", ), ( { @@ -401,7 +401,7 @@ async def test_config_wrong_struct_sensor( }, [2], False, - "-8", + "-8.3", ), ( { @@ -676,7 +676,7 @@ async def test_config_wrong_struct_sensor( }, [0x00AB, 0xCDEF], False, - "112594", + "112593.75", ), ( { From 7467d588c85b0ee1f379a38a69f37f0a835ab649 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:01:38 +0100 Subject: [PATCH 1317/1544] Add sensibo migrated ClimateEntityFeatures (#109340) Adds sensibo migrated ClimateEntityFeatures --- homeassistant/components/sensibo/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index a718cac88fb..bcc851e02ae 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -191,6 +191,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): _attr_name = None _attr_precision = PRECISION_TENTHS _attr_translation_key = "climate_device" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str From 05c0973937b9f9590abc9aa6c2eb00808181f313 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 09:02:41 +0100 Subject: [PATCH 1318/1544] Add Adax migrated ClimateEntityFeatures (#109341) --- homeassistant/components/adax/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 2ce8adc30d6..6b0adcb52cf 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -74,6 +74,7 @@ class AdaxDevice(ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: """Initialize the heater.""" From 12e307789554193f19370b0cbeab03fca484a999 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Feb 2024 02:32:17 -0600 Subject: [PATCH 1319/1544] Ensure the purge entities service cleans up the states_meta table (#109344) --- homeassistant/components/recorder/purge.py | 2 ++ tests/components/recorder/test_purge.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 8dd539f84f3..0b63bb8daa2 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -794,4 +794,6 @@ def purge_entity_data( _LOGGER.debug("Purging entity data hasn't fully completed yet") return False + _purge_old_entity_ids(instance, session) + return True diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 1696c9018b4..2a9260a28a4 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -1424,6 +1424,18 @@ async def test_purge_entities( ) assert states_sensor_kept.count() == 10 + # sensor.keep should remain in the StatesMeta table + states_meta_remain = session.query(StatesMeta).filter( + StatesMeta.entity_id == "sensor.keep" + ) + assert states_meta_remain.count() == 1 + + # sensor.purge_entity should be removed from the StatesMeta table + states_meta_remain = session.query(StatesMeta).filter( + StatesMeta.entity_id == "sensor.purge_entity" + ) + assert states_meta_remain.count() == 0 + _add_purge_records(hass) # Confirm calling service without arguments matches all records (default filter behavior) @@ -1437,6 +1449,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 0 + # The states_meta table should be empty + states_meta_remain = session.query(StatesMeta) + assert states_meta_remain.count() == 0 + async def _add_test_states(hass: HomeAssistant, wait_recording_done: bool = True): """Add multiple states to the db for testing.""" From 1383a0c13ad84834c25abfb2ca313b8c91e78c5e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 2 Feb 2024 02:58:03 -0500 Subject: [PATCH 1320/1544] Missing template helper translation keys (#109347) --- homeassistant/components/template/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 19ad9e5ddeb..79cd0289724 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -75,6 +75,7 @@ "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "tamper": "[%key:component::binary_sensor::entity_component::tamper::name%]", "update": "[%key:component::binary_sensor::entity_component::update::name%]", "vibration": "[%key:component::binary_sensor::entity_component::vibration::name%]", "window": "[%key:component::binary_sensor::entity_component::window::name%]" @@ -127,6 +128,7 @@ "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", From 7a7bcf1a92e3740dcd130cd2861d5313b0e644f7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Feb 2024 10:37:04 +0100 Subject: [PATCH 1321/1544] Update cryptography to 42.0.2 (#109359) --- 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 1b47b2693b0..fbb072db9c0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==42.0.1 +cryptography==42.0.2 dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 diff --git a/pyproject.toml b/pyproject.toml index 24a3d4a9d35..069ca3564ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==42.0.1", + "cryptography==42.0.2", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==24.0.0", "orjson==3.9.12", diff --git a/requirements.txt b/requirements.txt index 67aad2e9f0d..066855e718b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 PyJWT==2.8.0 -cryptography==42.0.1 +cryptography==42.0.2 pyOpenSSL==24.0.0 orjson==3.9.12 packaging>=23.1 From e91e67b40006eeadfe9b4335f14919d8f5287762 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 2 Feb 2024 11:02:00 +0100 Subject: [PATCH 1322/1544] Bump deebot_client to 5.1.0 (#109360) --- homeassistant/components/ecovacs/config_flow.py | 6 +++++- homeassistant/components/ecovacs/controller.py | 2 +- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ecovacs/test_init.py | 1 + 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 39c61b3ce23..db3c60fa9e7 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -75,7 +75,7 @@ async def _validate_input( rest_config = create_rest_config( aiohttp_client.async_get_clientsession(hass), device_id=device_id, - country=country, + alpha_2_country=country, override_rest_url=rest_url, ) @@ -266,6 +266,10 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): # If not we will inform the user about the mismatch. error = None placeholders = None + + # Convert the country to upper case as ISO 3166-1 alpha-2 country codes are upper case + user_input[CONF_COUNTRY] = user_input[CONF_COUNTRY].upper() + if len(user_input[CONF_COUNTRY]) != 2: error = "invalid_country_length" placeholders = {"countries_url": "https://www.iso.org/obp/ui/#search/code/"} diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 27a1996c3e9..27b64db20b6 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -49,7 +49,7 @@ class EcovacsController: create_rest_config( aiohttp_client.async_get_clientsession(self._hass), device_id=self._device_id, - country=country, + alpha_2_country=country, override_rest_url=config.get(CONF_OVERRIDE_REST_URL), ), config[CONF_USERNAME], diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 3472e4746f8..34760ea6aca 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.8", "deebot-client==5.0.0"] + "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 09793f1e3fe..bef0b762232 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -684,7 +684,7 @@ debugpy==1.8.0 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==5.0.0 +deebot-client==5.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3228c579ea5..ba5383086b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -559,7 +559,7 @@ dbus-fast==2.21.1 debugpy==1.8.0 # homeassistant.components.ecovacs -deebot-client==5.0.0 +deebot-client==5.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 8557ccb983c..e76001fbaeb 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -80,6 +80,7 @@ async def test_invalid_auth( ({}, 0), ({DOMAIN: IMPORT_DATA.copy()}, 1), ], + ids=["no_config", "import_config"], ) async def test_async_setup_import( hass: HomeAssistant, From 92a3edc53638d6a2ed7fc90260f612b132019bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 2 Feb 2024 13:42:07 +0100 Subject: [PATCH 1323/1544] Specify end_time when importing Elvia data to deal with drift (#109361) --- homeassistant/components/elvia/importer.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 3fc79240254..69e3d64d09d 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -38,11 +38,18 @@ class ElviaImporter: self.client = Elvia(meter_value_token=api_token).meter_value() self.metering_point_id = metering_point_id - async def _fetch_hourly_data(self, since: datetime) -> list[MeterValueTimeSeries]: + async def _fetch_hourly_data( + self, + since: datetime, + until: datetime, + ) -> list[MeterValueTimeSeries]: """Fetch hourly data.""" - LOGGER.debug("Fetching hourly data since %s", since) + start_time = since.isoformat() + end_time = until.isoformat() + LOGGER.debug("Fetching hourly data %s - %s", start_time, end_time) all_data = await self.client.get_meter_values( - start_time=since.isoformat(), + start_time=start_time, + end_time=end_time, metering_point_ids=[self.metering_point_id], ) return all_data["meteringpoints"][0]["metervalue"]["timeSeries"] @@ -62,8 +69,10 @@ class ElviaImporter: if not last_stats: # First time we insert 1 years of data (if available) + until = dt_util.utcnow() hourly_data = await self._fetch_hourly_data( - since=dt_util.now() - timedelta(days=365) + since=until - timedelta(days=365), + until=until, ) if hourly_data is None or len(hourly_data) == 0: return @@ -71,7 +80,8 @@ class ElviaImporter: _sum = 0.0 else: hourly_data = await self._fetch_hourly_data( - since=dt_util.utc_from_timestamp(last_stats[statistic_id][0]["end"]) + since=dt_util.utc_from_timestamp(last_stats[statistic_id][0]["end"]), + until=dt_util.utcnow(), ) if ( From 6afc6ca126ff69992d59c7f0665833debae445bd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Feb 2024 11:43:05 +0100 Subject: [PATCH 1324/1544] Remove suggested area from Verisure (#109364) --- homeassistant/components/verisure/binary_sensor.py | 1 - homeassistant/components/verisure/camera.py | 1 - homeassistant/components/verisure/lock.py | 1 - homeassistant/components/verisure/sensor.py | 2 -- homeassistant/components/verisure/switch.py | 1 - 5 files changed, 6 deletions(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index cadb9b6788d..19a60602540 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -58,7 +58,6 @@ class VerisureDoorWindowSensor( area = self.coordinator.data["door_window"][self.serial_number]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="Shock Sensor Detector", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index a240d45cf7e..e0505328245 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -71,7 +71,6 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="SmartCam", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 1a81b437116..8e57c9695c0 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,6 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="Lockguard Smartlock", identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 0fb16aa87c4..51947484dca 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -68,7 +68,6 @@ class VerisureThermometer( area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, @@ -119,7 +118,6 @@ class VerisureHygrometer( area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 427ca5e6ea8..96992cadb75 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -53,7 +53,6 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch ] return DeviceInfo( name=area, - suggested_area=area, manufacturer="Verisure", model="SmartPlug", identifiers={(DOMAIN, self.serial_number)}, From 57a43ef1514a4ce29a765ef1d801fe15378da5c0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 2 Feb 2024 14:12:26 +0100 Subject: [PATCH 1325/1544] Improve Ecovacs naming (#109372) --- homeassistant/components/ecovacs/strings.json | 16 +- .../ecovacs/snapshots/test_button.ambr | 98 +++++----- .../ecovacs/snapshots/test_select.ambr | 12 +- .../ecovacs/snapshots/test_sensor.ambr | 180 +++++++++--------- .../ecovacs/snapshots/test_switch.ambr | 12 +- tests/components/ecovacs/test_button.py | 13 +- tests/components/ecovacs/test_select.py | 6 +- tests/components/ecovacs/test_sensor.py | 18 +- tests/components/ecovacs/test_switch.py | 8 +- 9 files changed, 182 insertions(+), 181 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index f56b65a4e46..7a456483877 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -47,13 +47,13 @@ "name": "Relocate" }, "reset_lifespan_brush": { - "name": "Reset brush lifespan" + "name": "Reset main brush lifespan" }, "reset_lifespan_filter": { "name": "Reset filter lifespan" }, "reset_lifespan_side_brush": { - "name": "Reset side brush lifespan" + "name": "Reset side brushes lifespan" } }, "image": { @@ -79,13 +79,13 @@ } }, "lifespan_brush": { - "name": "Brush lifespan" + "name": "Main brush lifespan" }, "lifespan_filter": { "name": "Filter lifespan" }, "lifespan_side_brush": { - "name": "Side brush lifespan" + "name": "Side brushes lifespan" }, "network_ip": { "name": "IP address" @@ -100,7 +100,7 @@ "name": "Area cleaned" }, "stats_time": { - "name": "Time cleaned" + "name": "Cleaning duration" }, "total_stats_area": { "name": "Total area cleaned" @@ -109,12 +109,12 @@ "name": "Total cleanings" }, "total_stats_time": { - "name": "Total time cleaned" + "name": "Total cleaning duration" } }, "select": { "water_amount": { - "name": "Water amount", + "name": "Water flow level", "state": { "high": "High", "low": "Low", @@ -137,7 +137,7 @@ "name": "Advanced mode" }, "carpet_auto_fan_boost": { - "name": "Carpet auto fan speed boost" + "name": "Carpet auto-boost suction" }, "clean_preference": { "name": "Clean preference" diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index ca61d16602a..45b7ef1cc51 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -42,49 +42,6 @@ 'state': '2024-01-01T00:00:00+00:00', }) # --- -# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:entity-registry] - 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.ozmo_950_reset_brush_lifespan', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Reset brush lifespan', - 'platform': 'ecovacs', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'reset_lifespan_brush', - 'unique_id': 'E1234567890000000001_reset_lifespan_brush', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[yna5x1][button.ozmo_950_reset_brush_lifespan:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Reset brush lifespan', - }), - 'context': , - 'entity_id': 'button.ozmo_950_reset_brush_lifespan', - 'last_changed': , - 'last_updated': , - 'state': '2024-01-01T00:00:00+00:00', - }) -# --- # name: test_buttons[yna5x1][button.ozmo_950_reset_filter_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -128,7 +85,7 @@ 'state': '2024-01-01T00:00:00+00:00', }) # --- -# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:entity-registry] +# name: test_buttons[yna5x1][button.ozmo_950_reset_main_brush_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -140,7 +97,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.ozmo_950_reset_side_brush_lifespan', + 'entity_id': 'button.ozmo_950_reset_main_brush_lifespan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -150,7 +107,50 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Reset side brush lifespan', + 'original_name': 'Reset main brush lifespan', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_lifespan_brush', + 'unique_id': 'E1234567890000000001_reset_lifespan_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_main_brush_lifespan:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Reset main brush lifespan', + }), + 'context': , + 'entity_id': 'button.ozmo_950_reset_main_brush_lifespan', + 'last_changed': , + 'last_updated': , + 'state': '2024-01-01T00:00:00+00:00', + }) +# --- +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brushes_lifespan:entity-registry] + 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.ozmo_950_reset_side_brushes_lifespan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset side brushes lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, @@ -159,13 +159,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brush_lifespan:state] +# name: test_buttons[yna5x1][button.ozmo_950_reset_side_brushes_lifespan:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Reset side brush lifespan', + 'friendly_name': 'Ozmo 950 Reset side brushes lifespan', }), 'context': , - 'entity_id': 'button.ozmo_950_reset_side_brush_lifespan', + 'entity_id': 'button.ozmo_950_reset_side_brushes_lifespan', 'last_changed': , 'last_updated': , 'state': '2024-01-01T00:00:00+00:00', diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index abf37a17256..4b01d448fd8 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:entity-registry] +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18,7 +18,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.ozmo_950_water_amount', + 'entity_id': 'select.ozmo_950_water_flow_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -28,7 +28,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Water amount', + 'original_name': 'Water flow level', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, @@ -37,10 +37,10 @@ 'unit_of_measurement': None, }) # --- -# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_amount:state] +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Water amount', + 'friendly_name': 'Ozmo 950 Water flow level', 'options': list([ 'low', 'medium', @@ -49,7 +49,7 @@ ]), }), 'context': , - 'entity_id': 'select.ozmo_950_water_amount', + 'entity_id': 'select.ozmo_950_water_flow_level', 'last_changed': , 'last_updated': , 'state': 'ultrahigh', diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 3a59b3ba418..f07722afb53 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -88,7 +88,7 @@ 'state': '100', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_brush_lifespan:entity-registry] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_cleaning_duration:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -99,37 +99,41 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.ozmo_950_brush_lifespan', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_cleaning_duration', 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Brush lifespan', + 'original_name': 'Cleaning duration', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'lifespan_brush', - 'unique_id': 'E1234567890000000001_lifespan_brush', - 'unit_of_measurement': '%', + 'translation_key': 'stats_time', + 'unique_id': 'E1234567890000000001_stats_time', + 'unit_of_measurement': , }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_brush_lifespan:state] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_cleaning_duration:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Brush lifespan', - 'unit_of_measurement': '%', + 'device_class': 'duration', + 'friendly_name': 'Ozmo 950 Cleaning duration', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ozmo_950_brush_lifespan', + 'entity_id': 'sensor.ozmo_950_cleaning_duration', 'last_changed': , 'last_updated': , - 'state': '80', + 'state': '5.0', }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_error:entity-registry] @@ -263,7 +267,7 @@ 'state': '192.168.0.10', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brush_lifespan:entity-registry] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_main_brush_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -275,7 +279,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.ozmo_950_side_brush_lifespan', + 'entity_id': 'sensor.ozmo_950_main_brush_lifespan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -285,29 +289,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Side brush lifespan', + 'original_name': 'Main brush lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'lifespan_side_brush', - 'unique_id': 'E1234567890000000001_lifespan_side_brush', + 'translation_key': 'lifespan_brush', + 'unique_id': 'E1234567890000000001_lifespan_brush', 'unit_of_measurement': '%', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brush_lifespan:state] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_main_brush_lifespan:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Side brush lifespan', + 'friendly_name': 'Ozmo 950 Main brush lifespan', 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.ozmo_950_side_brush_lifespan', + 'entity_id': 'sensor.ozmo_950_main_brush_lifespan', 'last_changed': , 'last_updated': , - 'state': '40', + 'state': '80', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:entity-registry] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brushes_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -318,41 +322,37 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ozmo_950_time_cleaned', + 'entity_category': , + 'entity_id': 'sensor.ozmo_950_side_brushes_lifespan', 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , 'name': None, 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Time cleaned', + 'original_name': 'Side brushes lifespan', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'stats_time', - 'unique_id': 'E1234567890000000001_stats_time', - 'unit_of_measurement': , + 'translation_key': 'lifespan_side_brush', + 'unique_id': 'E1234567890000000001_lifespan_side_brush', + 'unit_of_measurement': '%', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_time_cleaned:state] +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_side_brushes_lifespan:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Ozmo 950 Time cleaned', - 'unit_of_measurement': , + 'friendly_name': 'Ozmo 950 Side brushes lifespan', + 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.ozmo_950_time_cleaned', + 'entity_id': 'sensor.ozmo_950_side_brushes_lifespan', 'last_changed': , 'last_updated': , - 'state': '5.0', + 'state': '40', }) # --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_area_cleaned:entity-registry] @@ -402,6 +402,57 @@ 'state': '60', }) # --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleaning_duration:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ozmo_950_total_cleaning_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total cleaning duration', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_stats_time', + 'unique_id': 'E1234567890000000001_total_stats_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleaning_duration:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Ozmo 950 Total cleaning duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ozmo_950_total_cleaning_duration', + 'last_changed': , + 'last_updated': , + 'state': '40.000', + }) +# --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_cleanings:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -448,57 +499,6 @@ 'state': '123', }) # --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ozmo_950_total_time_cleaned', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Total time cleaned', - 'platform': 'ecovacs', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'total_stats_time', - 'unique_id': 'E1234567890000000001_total_stats_time', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_total_time_cleaned:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Ozmo 950 Total time cleaned', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ozmo_950_total_time_cleaned', - 'last_changed': , - 'last_updated': , - 'state': '40.000', - }) -# --- # name: test_sensors[yna5x1-entity_ids0][sensor.ozmo_950_wi_fi_rssi:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr index 75441c4f918..c645502a831 100644 --- a/tests/components/ecovacs/snapshots/test_switch.ambr +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -42,7 +42,7 @@ 'state': 'on', }) # --- -# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:entity-registry] +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_boost_suction:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -54,7 +54,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'entity_id': 'switch.ozmo_950_carpet_auto_boost_suction', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -64,7 +64,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Carpet auto fan speed boost', + 'original_name': 'Carpet auto-boost suction', 'platform': 'ecovacs', 'previous_unique_id': None, 'supported_features': 0, @@ -73,13 +73,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_fan_speed_boost:state] +# name: test_switch_entities[yna5x1][switch.ozmo_950_carpet_auto_boost_suction:state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ozmo 950 Carpet auto fan speed boost', + 'friendly_name': 'Ozmo 950 Carpet auto-boost suction', }), 'context': , - 'entity_id': 'switch.ozmo_950_carpet_auto_fan_speed_boost', + 'entity_id': 'switch.ozmo_950_carpet_auto_boost_suction', 'last_changed': , 'last_updated': , 'state': 'on', diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index f804e813256..24c926b1f77 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -33,13 +33,16 @@ def platforms() -> Platform | list[Platform]: "yna5x1", [ ("button.ozmo_950_relocate", SetRelocationState()), - ("button.ozmo_950_reset_brush_lifespan", ResetLifeSpan(LifeSpan.BRUSH)), + ( + "button.ozmo_950_reset_main_brush_lifespan", + ResetLifeSpan(LifeSpan.BRUSH), + ), ( "button.ozmo_950_reset_filter_lifespan", ResetLifeSpan(LifeSpan.FILTER), ), ( - "button.ozmo_950_reset_side_brush_lifespan", + "button.ozmo_950_reset_side_brushes_lifespan", ResetLifeSpan(LifeSpan.SIDE_BRUSH), ), ], @@ -56,7 +59,7 @@ async def test_buttons( entities: list[tuple[str, Command]], ) -> None: """Test that sensor entity snapshots match.""" - assert sorted(hass.states.async_entity_ids()) == [e[0] for e in entities] + assert hass.states.async_entity_ids() == [e[0] for e in entities] device = controller.devices[0] for entity_id, command in entities: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" @@ -89,9 +92,9 @@ async def test_buttons( ( "yna5x1", [ - "button.ozmo_950_reset_brush_lifespan", + "button.ozmo_950_reset_main_brush_lifespan", "button.ozmo_950_reset_filter_lifespan", - "button.ozmo_950_reset_side_brush_lifespan", + "button.ozmo_950_reset_side_brushes_lifespan", ], ), ], diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index cfe34c5a7a6..0d1a5d19116 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -44,7 +44,7 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): ( "yna5x1", [ - "select.ozmo_950_water_amount", + "select.ozmo_950_water_flow_level", ], ), ], @@ -58,7 +58,7 @@ async def test_selects( entity_ids: list[str], ) -> None: """Test that select entity snapshots match.""" - assert entity_ids == sorted(hass.states.async_entity_ids()) + assert entity_ids == hass.states.async_entity_ids() for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN @@ -83,7 +83,7 @@ async def test_selects( [ ( "yna5x1", - "select.ozmo_950_water_amount", + "select.ozmo_950_water_flow_level", "ultrahigh", "low", SetWaterInfo(WaterAmount.LOW), diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 18d65349fa2..78755668f0f 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -54,18 +54,18 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "yna5x1", [ "sensor.ozmo_950_area_cleaned", - "sensor.ozmo_950_battery", - "sensor.ozmo_950_brush_lifespan", - "sensor.ozmo_950_error", - "sensor.ozmo_950_filter_lifespan", - "sensor.ozmo_950_ip_address", - "sensor.ozmo_950_side_brush_lifespan", - "sensor.ozmo_950_time_cleaned", + "sensor.ozmo_950_cleaning_duration", "sensor.ozmo_950_total_area_cleaned", + "sensor.ozmo_950_total_cleaning_duration", "sensor.ozmo_950_total_cleanings", - "sensor.ozmo_950_total_time_cleaned", + "sensor.ozmo_950_battery", + "sensor.ozmo_950_ip_address", "sensor.ozmo_950_wi_fi_rssi", "sensor.ozmo_950_wi_fi_ssid", + "sensor.ozmo_950_main_brush_lifespan", + "sensor.ozmo_950_filter_lifespan", + "sensor.ozmo_950_side_brushes_lifespan", + "sensor.ozmo_950_error", ], ), ], @@ -79,7 +79,7 @@ async def test_sensors( entity_ids: list[str], ) -> None: """Test that sensor entity snapshots match.""" - assert entity_ids == sorted(hass.states.async_entity_ids()) + assert entity_ids == hass.states.async_entity_ids() for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index 43c5d25e18f..35d2f487b95 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -69,7 +69,7 @@ class SwitchTestCase: SetContinuousCleaning, ), SwitchTestCase( - "switch.ozmo_950_carpet_auto_fan_speed_boost", + "switch.ozmo_950_carpet_auto_boost_suction", CarpetAutoFanBoostEvent(True), SetCarpetAutoFanBoost, ), @@ -90,9 +90,7 @@ async def test_switch_entities( device = controller.devices[0] event_bus = device.events - assert sorted(hass.states.async_entity_ids()) == sorted( - test.entity_id for test in tests - ) + assert hass.states.async_entity_ids() == [test.entity_id for test in tests] for test_case in tests: entity_id = test_case.entity_id assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" @@ -139,7 +137,7 @@ async def test_switch_entities( [ "switch.ozmo_950_advanced_mode", "switch.ozmo_950_continuous_cleaning", - "switch.ozmo_950_carpet_auto_fan_speed_boost", + "switch.ozmo_950_carpet_auto_boost_suction", ], ), ], From 3c90e3d83f6d830c9604690f37536a13ed68900c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 2 Feb 2024 17:30:07 +0100 Subject: [PATCH 1326/1544] Update frontend to 20240202.0 (#109388) --- 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 2b005c7e1ad..039328b9cac 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==20240131.0"] + "requirements": ["home-assistant-frontend==20240202.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbb072db9c0..950cae8b322 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.4.0 hass-nabucasa==0.76.0 hassil==1.6.0 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240131.0 +home-assistant-frontend==20240202.0 home-assistant-intents==2024.2.1 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bef0b762232..4f5e26d5423 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1059,7 +1059,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240131.0 +home-assistant-frontend==20240202.0 # homeassistant.components.conversation home-assistant-intents==2024.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba5383086b5..68b56339127 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -855,7 +855,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240131.0 +home-assistant-frontend==20240202.0 # homeassistant.components.conversation home-assistant-intents==2024.2.1 From f18f161efa021ea21190288b5165e039bff19d3f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Feb 2024 17:36:51 +0100 Subject: [PATCH 1327/1544] Bump version to 2024.2.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 84afe7ce1b2..4f720539b77 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 069ca3564ca..68b4d47d662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b2" +version = "2024.2.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4735aadaa8659f577547d920510dc9d12cc226e6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 3 Feb 2024 12:53:22 +0100 Subject: [PATCH 1328/1544] Ignore gateway devices in ViCare integration (#106477) * filter unsupported devices * Update __init__.py * use debug * remove dead code --- homeassistant/components/vicare/__init__.py | 23 +++++++++--- .../components/vicare/config_flow.py | 2 +- homeassistant/components/vicare/sensor.py | 35 ------------------- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 603a42bae41..a2b2f3ac769 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -10,6 +10,7 @@ from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, @@ -85,15 +86,16 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up PyVicare API.""" vicare_api = vicare_login(hass, entry.data) - for device in vicare_api.devices: - _LOGGER.info( + device_config_list = get_supported_devices(vicare_api.devices) + + for device in device_config_list: + _LOGGER.debug( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) # Currently we only support a single device - device_list = vicare_api.devices - device = device_list[0] - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_list + device = device_config_list[0] + hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_config_list hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( device, @@ -113,3 +115,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return unload_ok + + +def get_supported_devices( + devices: list[PyViCareDeviceConfig], +) -> list[PyViCareDeviceConfig]: + """Remove unsupported devices from the list.""" + return [ + device_config + for device_config in devices + if device_config.getModel() not in ["Heatbox1", "Heatbox2_SRC"] + ] diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 87bfcf7b146..32ae4af0fe7 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -118,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Invoke when a Viessmann MAC address is discovered on the network.""" formatted_mac = format_mac(discovery_info.macaddress) - _LOGGER.info("Found device with mac %s", formatted_mac) + _LOGGER.debug("Found device with mac %s", formatted_mac) await self.async_set_unique_id(formatted_mac) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 39b4bd032dc..f5a7cfe182a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -658,41 +658,6 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _build_entity( - vicare_api, - device_config: PyViCareDeviceConfig, - entity_description: ViCareSensorEntityDescription, -): - """Create a ViCare sensor entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareSensor( - vicare_api, - device_config, - entity_description, - ) - return None - - -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareSensor], - sensor_descriptions: tuple[ViCareSensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - entity = await hass.async_add_executor_job( - _build_entity, - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity: - entities.append(entity) - - def _build_entities( device: PyViCareDevice, device_config: PyViCareDeviceConfig, From b1bf69689e6d8cd84fa0865410b0ec0825d3cb6d Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 3 Feb 2024 02:20:10 -0600 Subject: [PATCH 1329/1544] Do not suggest area for portable Sonos speakers (#109350) * Do not suggest area for portable speakers * Update tests * Improve readability, update tests --- homeassistant/components/sonos/entity.py | 6 +++- tests/components/sonos/test_media_player.py | 31 +++++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 90cadcdad37..05b69c54c50 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -76,6 +76,10 @@ class SonosEntity(Entity): @property def device_info(self) -> DeviceInfo: """Return information about the device.""" + suggested_area: str | None = None + if not self.speaker.battery_info: + # Only set suggested area for non-portable devices + suggested_area = self.speaker.zone_name return DeviceInfo( identifiers={(DOMAIN, self.soco.uid)}, name=self.speaker.zone_name, @@ -86,7 +90,7 @@ class SonosEntity(Entity): (dr.CONNECTION_UPNP, f"uuid:{self.speaker.uid}"), }, manufacturer="Sonos", - suggested_area=self.speaker.zone_name, + suggested_area=suggested_area, configuration_url=f"http://{self.soco.ip_address}:1400/support/review", ) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index fa37b2210e7..ddf550dc376 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,26 +1,45 @@ """Tests for the Sonos Media Player platform.""" from homeassistant.const import STATE_IDLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + CONNECTION_UPNP, + DeviceRegistry, +) async def test_device_registry( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, device_registry: DeviceRegistry, async_autosetup_sonos, soco ) -> None: """Test sonos device registered in the device registry.""" - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={("sonos", "RINCON_test")} ) + assert reg_device is not None assert reg_device.model == "Model Name" assert reg_device.sw_version == "13.1" assert reg_device.connections == { - (dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), - (dr.CONNECTION_UPNP, "uuid:RINCON_test"), + (CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), + (CONNECTION_UPNP, "uuid:RINCON_test"), } assert reg_device.manufacturer == "Sonos" - assert reg_device.suggested_area == "Zone A" assert reg_device.name == "Zone A" + # Default device provides battery info, area should not be suggested + assert reg_device.suggested_area is None + + +async def test_device_registry_not_portable( + hass: HomeAssistant, device_registry: DeviceRegistry, async_setup_sonos, soco +) -> None: + """Test non-portable sonos device registered in the device registry to ensure area suggested.""" + soco.get_battery_info.return_value = {} + await async_setup_sonos() + + reg_device = device_registry.async_get_device( + identifiers={("sonos", "RINCON_test")} + ) + assert reg_device is not None + assert reg_device.suggested_area == "Zone A" async def test_entity_basic( From a57c30be88208a719791dbdce97eb162f30f82e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Feb 2024 20:34:00 +0100 Subject: [PATCH 1330/1544] Update elgato to 5.1.2 (#109391) --- homeassistant/components/elgato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 0671a7adb1d..c68902560b9 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==5.1.1"], + "requirements": ["elgato==5.1.2"], "zeroconf": ["_elg._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f5e26d5423..e826b1581da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -758,7 +758,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.1 +elgato==5.1.2 # homeassistant.components.eliqonline eliqonline==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68b56339127..3fa1984ce1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -618,7 +618,7 @@ easyenergy==2.1.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==5.1.1 +elgato==5.1.2 # homeassistant.components.elkm1 elkm1-lib==2.2.6 From 07a1ee0baa46fa678a186a43bea4218ee158591d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 2 Feb 2024 23:03:55 +0100 Subject: [PATCH 1331/1544] Add diagnostics to proximity (#109393) --- .../components/proximity/diagnostics.py | 49 +++++++++++ .../proximity/snapshots/test_diagnostics.ambr | 86 +++++++++++++++++++ .../components/proximity/test_diagnostics.py | 62 +++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 homeassistant/components/proximity/diagnostics.py create mode 100644 tests/components/proximity/snapshots/test_diagnostics.ambr create mode 100644 tests/components/proximity/test_diagnostics.py diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py new file mode 100644 index 00000000000..ba5e1f53722 --- /dev/null +++ b/homeassistant/components/proximity/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for Proximity.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.person import ATTR_USER_ID +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ProximityDataUpdateCoordinator + +TO_REDACT = { + ATTR_GPS, + ATTR_IP, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MAC, + ATTR_USER_ID, + "context", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + diag_data = { + "entry": entry.as_dict(), + } + + tracked_states: dict[str, dict] = {} + for tracked_entity_id in coordinator.tracked_entities: + if (state := hass.states.get(tracked_entity_id)) is None: + continue + tracked_states[tracked_entity_id] = state.as_dict() + + diag_data["data"] = { + "proximity": coordinator.data.proximity, + "entities": coordinator.data.entities, + "entity_mapping": coordinator.entity_mapping, + "tracked_states": async_redact_data(tracked_states, TO_REDACT), + } + return diag_data diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f8f7d9b014e --- /dev/null +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'entities': dict({ + 'device_tracker.test1': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 2218752, + 'is_in_ignored_zone': False, + 'name': 'test1', + }), + 'device_tracker.test2': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 4077309, + 'is_in_ignored_zone': False, + 'name': 'test2', + }), + }), + 'entity_mapping': dict({ + 'device_tracker.test1': list([ + 'sensor.home_test1_distance', + 'sensor.home_test1_direction_of_travel', + ]), + 'device_tracker.test2': list([ + 'sensor.home_test2_distance', + 'sensor.home_test2_direction_of_travel', + ]), + 'device_tracker.test3': list([ + 'sensor.home_test3_distance', + 'sensor.home_test3_direction_of_travel', + ]), + }), + 'proximity': dict({ + 'dir_of_travel': 'unknown', + 'dist_to_zone': 2219, + 'nearest': 'test1', + }), + 'tracked_states': dict({ + 'device_tracker.test1': dict({ + 'attributes': dict({ + 'friendly_name': 'test1', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test1', + 'state': 'not_home', + }), + 'device_tracker.test2': dict({ + 'attributes': dict({ + 'friendly_name': 'test2', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test2', + 'state': 'not_home', + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'ignored_zones': list([ + ]), + 'tolerance': 1, + 'tracked_entities': list([ + 'device_tracker.test1', + 'device_tracker.test2', + 'device_tracker.test3', + ]), + 'zone': 'zone.home', + }), + 'disabled_by': None, + 'domain': 'proximity', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'home', + 'unique_id': 'proximity_home', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/proximity/test_diagnostics.py b/tests/components/proximity/test_diagnostics.py new file mode 100644 index 00000000000..35ecd152a06 --- /dev/null +++ b/tests/components/proximity/test_diagnostics.py @@ -0,0 +1,62 @@ +"""Tests for proximity diagnostics platform.""" +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ZONE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 150.1, "longitude": 20.1}, + ) + + mock_entry = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [ + "device_tracker.test1", + "device_tracker.test2", + "device_tracker.test3", + ], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.state == ConfigEntryState.LOADED + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_entry + ) == snapshot(exclude=props("entry_id", "last_changed", "last_updated")) From 4a60d362163d803826e077185250d29140eb1dae Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 2 Feb 2024 20:26:44 -0600 Subject: [PATCH 1332/1544] More thorough checks in ESPHome voice assistant UDP server (#109394) * More thorough checks in UDP server * Simplify and change to stop_requested * Check transport --- homeassistant/components/esphome/manager.py | 1 - .../components/esphome/voice_assistant.py | 31 ++-- .../esphome/test_voice_assistant.py | 152 ++++++++++++------ 3 files changed, 122 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index f197574c30a..59f37d3a078 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -352,7 +352,6 @@ class ESPHomeManager: if self.voice_assistant_udp_server is not None: _LOGGER.warning("Voice assistant UDP server was not stopped") self.voice_assistant_udp_server.stop() - self.voice_assistant_udp_server.close() self.voice_assistant_udp_server = None hass = self.hass diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index de6b521d980..7c5c74d58ee 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -1,4 +1,5 @@ """ESPHome voice assistant support.""" + from __future__ import annotations import asyncio @@ -67,7 +68,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Receive UDP packets and forward them to the voice assistant.""" started = False - stopped = False + stop_requested = False transport: asyncio.DatagramTransport | None = None remote_addr: tuple[str, int] | None = None @@ -92,6 +93,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self._tts_done = asyncio.Event() self._tts_task: asyncio.Task | None = None + @property + def is_running(self) -> bool: + """True if the the UDP server is started and hasn't been asked to stop.""" + return self.started and (not self.stop_requested) + async def start_server(self) -> int: """Start accepting connections.""" @@ -99,7 +105,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Accept connection.""" if self.started: raise RuntimeError("Can only start once") - if self.stopped: + if self.stop_requested: raise RuntimeError("No longer accepting connections") self.started = True @@ -124,7 +130,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" - if not self.started or self.stopped: + if not self.is_running: return if self.remote_addr is None: self.remote_addr = addr @@ -142,19 +148,19 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): def stop(self) -> None: """Stop the receiver.""" self.queue.put_nowait(b"") - self.started = False - self.stopped = True + self.close() def close(self) -> None: """Close the receiver.""" self.started = False - self.stopped = True + self.stop_requested = True + if self.transport is not None: self.transport.close() async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if not self.started or self.stopped: + if not self.is_running: raise RuntimeError("Not running") while data := await self.queue.get(): @@ -303,8 +309,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): async def _send_tts(self, media_id: str) -> None: """Send TTS audio to device via UDP.""" + # Always send stream start/end events + self.handle_event(VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {}) + try: - if self.transport is None: + if (not self.is_running) or (self.transport is None): return extension, data = await tts.async_get_media_source_audio( @@ -337,15 +346,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): _LOGGER.debug("Sending %d bytes of audio", audio_bytes_size) - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} - ) - bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 sample_offset = 0 samples_left = audio_bytes_size // bytes_per_sample - while samples_left > 0: + while (samples_left > 0) and self.is_running: bytes_offset = sample_offset * bytes_per_sample chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] samples_in_chunk = len(chunk) // bytes_per_sample diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 38a33bfdec2..f6665c4ad91 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -70,6 +70,19 @@ def voice_assistant_udp_server_v2( return voice_assistant_udp_server(entry=mock_voice_assistant_v2_entry) +@pytest.fixture +def test_wav() -> bytes: + """Return one second of empty WAV audio.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(_ONE_SECOND)) + + return wav_io.getvalue() + + async def test_pipeline_events( hass: HomeAssistant, voice_assistant_udp_server_v1: VoiceAssistantUDPServer, @@ -241,11 +254,13 @@ async def test_udp_server_multiple( ): await voice_assistant_udp_server_v1.start_server() - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), pytest.raises(RuntimeError): - pass + with ( + patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), + pytest.raises(RuntimeError), + ): await voice_assistant_udp_server_v1.start_server() @@ -257,10 +272,13 @@ async def test_udp_server_after_stopped( ) -> None: """Test that the UDP server raises an error if started after stopped.""" voice_assistant_udp_server_v1.close() - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), pytest.raises(RuntimeError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.UDP_PORT", + new=unused_udp_port_factory(), + ), + pytest.raises(RuntimeError), + ): await voice_assistant_udp_server_v1.start_server() @@ -362,35 +380,33 @@ async def test_send_tts_not_called_when_empty( async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, + test_wav, ) -> None: """Test the UDP server calls sendto to transmit audio data to device.""" - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(16000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(bytes(_ONE_SECOND)) - - wav_bytes = wav_io.getvalue() - with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), + return_value=("wav", test_wav), ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) - - voice_assistant_udp_server_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, + with patch.object( + voice_assistant_udp_server_v2.transport, "is_closing", return_value=False + ): + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": { + "media_id": _TEST_MEDIA_ID, + "url": _TEST_OUTPUT_URL, + } + }, + ) ) - ) - await voice_assistant_udp_server_v2._tts_done.wait() + await voice_assistant_udp_server_v2._tts_done.wait() - voice_assistant_udp_server_v2.transport.sendto.assert_called() + voice_assistant_udp_server_v2.transport.sendto.assert_called() async def test_send_tts_wrong_sample_rate( @@ -400,17 +416,20 @@ async def test_send_tts_wrong_sample_rate( """Test the UDP server calls sendto to transmit audio data to device.""" with io.BytesIO() as wav_io: with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(22050) # should be 16000 + wav_file.setframerate(22050) wav_file.setsampwidth(2) wav_file.setnchannels(1) wav_file.writeframes(bytes(_ONE_SECOND)) wav_bytes = wav_io.getvalue() - - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), - ), pytest.raises(ValueError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", wav_bytes), + ), + pytest.raises(ValueError), + ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) voice_assistant_udp_server_v2._event_callback( @@ -431,10 +450,14 @@ async def test_send_tts_wrong_format( voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test that only WAV audio will be streamed.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("raw", bytes(1024)), - ), pytest.raises(ValueError): + with ( + patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("raw", bytes(1024)), + ), + pytest.raises(ValueError), + ): + voice_assistant_udp_server_v2.started = True voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) voice_assistant_udp_server_v2._event_callback( @@ -450,6 +473,33 @@ async def test_send_tts_wrong_format( await voice_assistant_udp_server_v2._tts_task # raises ValueError +async def test_send_tts_not_started( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, + test_wav, +) -> None: + """Test the UDP server does not call sendto when not started.""" + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", test_wav), + ): + voice_assistant_udp_server_v2.started = False + voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport) + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={ + "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} + }, + ) + ) + + await voice_assistant_udp_server_v2._tts_done.wait() + + voice_assistant_udp_server_v2.transport.sendto.assert_not_called() + + async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, @@ -459,11 +509,12 @@ async def test_wake_word( async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): assert start_stage == PipelineStage.WAKE_WORD - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), patch( - "asyncio.Event.wait" # TTS wait event + with ( + patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch("asyncio.Event.wait"), # TTS wait event ): voice_assistant_udp_server_v2.transport = Mock() @@ -515,10 +566,15 @@ async def test_wake_word_abort_exception( async def async_pipeline_from_audio_stream(*args, **kwargs): raise WakeWordDetectionAborted - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: + with ( + patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch.object( + voice_assistant_udp_server_v2, "handle_event" + ) as mock_handle_event, + ): voice_assistant_udp_server_v2.transport = Mock() await voice_assistant_udp_server_v2.run_pipeline( From 43f8731f8ad34cfd9b99e7e2a2d720e4f655c562 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 2 Feb 2024 21:24:24 +0100 Subject: [PATCH 1333/1544] Bump python-kasa to 0.6.2.1 (#109397) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 15748e83737..a479314d649 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -205,5 +205,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.6.2"] + "requirements": ["python-kasa[speedups]==0.6.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e826b1581da..a72e21147d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2232,7 +2232,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.2 +python-kasa[speedups]==0.6.2.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fa1984ce1f..2135ee94889 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1708,7 +1708,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.6.2 +python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter python-matter-server==5.4.0 From f3e8360949d165e0700707376481deff8924d4e0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 2 Feb 2024 23:04:41 +0100 Subject: [PATCH 1334/1544] Bump aioelectricitymaps to 0.3.0 (#109399) * Bump aioelectricitymaps to 0.3.0 * Fix tests --- .../components/co2signal/config_flow.py | 9 ++++++--- .../components/co2signal/coordinator.py | 11 +++++++---- .../components/co2signal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/co2signal/test_config_flow.py | 10 +++++----- tests/components/co2signal/test_sensor.py | 18 ++++++++++++------ 7 files changed, 33 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 2b2aca0b229..a678868ee18 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -4,8 +4,11 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aioelectricitymaps import ElectricityMaps -from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken +from aioelectricitymaps import ( + ElectricityMaps, + ElectricityMapsError, + ElectricityMapsInvalidTokenError, +) import voluptuous as vol from homeassistant import config_entries @@ -146,7 +149,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await fetch_latest_carbon_intensity(self.hass, em, data) - except InvalidToken: + except ElectricityMapsInvalidTokenError: errors["base"] = "invalid_auth" except ElectricityMapsError: errors["base"] = "unknown" diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 115c976b465..b06bee38bc4 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -4,9 +4,12 @@ from __future__ import annotations from datetime import timedelta import logging -from aioelectricitymaps import ElectricityMaps -from aioelectricitymaps.exceptions import ElectricityMapsError, InvalidToken -from aioelectricitymaps.models import CarbonIntensityResponse +from aioelectricitymaps import ( + CarbonIntensityResponse, + ElectricityMaps, + ElectricityMapsError, + ElectricityMapsInvalidTokenError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -43,7 +46,7 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): return await fetch_latest_carbon_intensity( self.hass, self.client, self.config_entry.data ) - except InvalidToken as err: + except ElectricityMapsInvalidTokenError as err: raise ConfigEntryAuthFailed from err except ElectricityMapsError as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 87f2b5c2db0..a4cbed00684 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.2.0"] + "requirements": ["aioelectricitymaps==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a72e21147d4..05fb9f3a03f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.2.0 +aioelectricitymaps==0.3.0 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2135ee94889..27f7eb2bad6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.2.0 +aioelectricitymaps==0.3.0 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 5b1ade1ee49..29ce783f33a 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,10 +1,10 @@ """Test the CO2 Signal config flow.""" from unittest.mock import AsyncMock, patch -from aioelectricitymaps.exceptions import ( - ElectricityMapsDecodeError, +from aioelectricitymaps import ( + ElectricityMapsConnectionError, ElectricityMapsError, - InvalidToken, + ElectricityMapsInvalidTokenError, ) import pytest @@ -134,11 +134,11 @@ async def test_form_country(hass: HomeAssistant) -> None: ("side_effect", "err_code"), [ ( - InvalidToken, + ElectricityMapsInvalidTokenError, "invalid_auth", ), (ElectricityMapsError("Something else"), "unknown"), - (ElectricityMapsDecodeError("Boom"), "unknown"), + (ElectricityMapsConnectionError("Boom"), "unknown"), ], ids=[ "invalid auth", diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index b79c8e04c23..4d663e1026b 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -2,10 +2,11 @@ from datetime import timedelta from unittest.mock import AsyncMock -from aioelectricitymaps.exceptions import ( - ElectricityMapsDecodeError, +from aioelectricitymaps import ( + ElectricityMapsConnectionError, + ElectricityMapsConnectionTimeoutError, ElectricityMapsError, - InvalidToken, + ElectricityMapsInvalidTokenError, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -42,7 +43,8 @@ async def test_sensor( @pytest.mark.parametrize( "error", [ - ElectricityMapsDecodeError, + ElectricityMapsConnectionTimeoutError, + ElectricityMapsConnectionError, ElectricityMapsError, Exception, ], @@ -93,8 +95,12 @@ async def test_sensor_reauth_triggered( assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) assert state.state == "45.9862319009581" - electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = InvalidToken - electricity_maps.latest_carbon_intensity_by_country_code.side_effect = InvalidToken + electricity_maps.latest_carbon_intensity_by_coordinates.side_effect = ( + ElectricityMapsInvalidTokenError + ) + electricity_maps.latest_carbon_intensity_by_country_code.side_effect = ( + ElectricityMapsInvalidTokenError + ) freezer.tick(timedelta(minutes=20)) async_fire_time_changed(hass) From 9c599f751397859bfa6049478b0c0cedcd3411b4 Mon Sep 17 00:00:00 2001 From: wilburCforce <109390391+wilburCforce@users.noreply.github.com> Date: Fri, 2 Feb 2024 14:42:53 -0600 Subject: [PATCH 1335/1544] Fix device type in Lutron (#109401) remove testing code --- homeassistant/components/lutron/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index d728cfac890..0bd00177cc1 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -42,7 +42,7 @@ async def async_setup_entry( lights = [] for area_name, device in entry_data.lights: - if device.type == "CEILING_FAN_TYPE2": + if device.type == "CEILING_FAN_TYPE": # If this is a fan, check to see if this entity already exists. # If not, do not create a new one. entity_id = ent_reg.async_get_entity_id( From 404e30911b793acb0f550de6ec6a24cac3406661 Mon Sep 17 00:00:00 2001 From: Jurriaan Pruis Date: Sat, 3 Feb 2024 12:31:48 +0100 Subject: [PATCH 1336/1544] Bump matrix-nio to 0.24.0 (#109403) Update matrix-nio to 0.24.0 --- homeassistant/components/matrix/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index a0eb7f3cb5b..0838bcc3764 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.22.1", "Pillow==10.2.0"] + "requirements": ["matrix-nio==0.24.0", "Pillow==10.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 05fb9f3a03f..75648545bd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1256,7 +1256,7 @@ lxml==5.1.0 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.22.1 +matrix-nio==0.24.0 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27f7eb2bad6..c2cc2c9af84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ lxml==5.1.0 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.22.1 +matrix-nio==0.24.0 # homeassistant.components.maxcube maxcube-api==0.4.3 From 24b8e6097856c15cac6fbeeddebe060d3ecc042e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 2 Feb 2024 22:42:10 +0100 Subject: [PATCH 1337/1544] Bump aiotankerkoenig to 0.3.0 (#109404) --- homeassistant/components/tankerkoenig/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index bf8896196ef..adea5b96490 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "requirements": ["aiotankerkoenig==0.2.0"] + "requirements": ["aiotankerkoenig==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 75648545bd6..00b453a9b16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ aioswitcher==3.4.1 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.2.0 +aiotankerkoenig==0.3.0 # homeassistant.components.tractive aiotractive==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2cc2c9af84..d84526af85f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioswitcher==3.4.1 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.2.0 +aiotankerkoenig==0.3.0 # homeassistant.components.tractive aiotractive==0.5.6 From 87a1482e4d743213d4e6d7d6b6d2cff673910022 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sat, 3 Feb 2024 05:14:33 -0600 Subject: [PATCH 1338/1544] Pass slots to error messages instead of IDs [rework] (#109410) Co-authored-by: tetele --- .../components/conversation/__init__.py | 2 +- .../components/conversation/default_agent.py | 28 ++++--- .../components/conversation/manifest.json | 2 +- homeassistant/helpers/intent.py | 23 +++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../conversation/snapshots/test_init.ambr | 12 +-- .../conversation/test_default_agent.py | 84 ++++++++++++------- 9 files changed, 95 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 7ca7fec115f..09b0e8e2310 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -349,7 +349,7 @@ async def websocket_hass_agent_debug( }, # Slot values that would be received by the intent "slots": { # direct access to values - entity_key: entity.value + entity_key: entity.text or entity.value for entity_key, entity in result.entities.items() }, # Extra slot details, such as the originally matched text diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index a2cb3b68041..fb33d87e107 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1,4 +1,5 @@ """Standard conversation implementation for Home Assistant.""" + from __future__ import annotations import asyncio @@ -264,9 +265,11 @@ class DefaultAgent(AbstractConversationAgent): _LOGGER.debug( "Recognized intent '%s' for template '%s' but had unmatched: %s", result.intent.name, - result.intent_sentence.text - if result.intent_sentence is not None - else "", + ( + result.intent_sentence.text + if result.intent_sentence is not None + else "" + ), result.unmatched_entities_list, ) error_response_type, error_response_args = _get_unmatched_response(result) @@ -285,7 +288,8 @@ class DefaultAgent(AbstractConversationAgent): # Slot values to pass to the intent slots = { - entity.name: {"value": entity.value} for entity in result.entities_list + entity.name: {"value": entity.value, "text": entity.text or entity.value} + for entity in result.entities_list } try: @@ -474,9 +478,11 @@ class DefaultAgent(AbstractConversationAgent): for entity_name, entity_value in recognize_result.entities.items() }, # First matched or unmatched state - "state": template.TemplateState(self.hass, state1) - if state1 is not None - else None, + "state": ( + template.TemplateState(self.hass, state1) + if state1 is not None + else None + ), "query": { # Entity states that matched the query (e.g, "on") "matched": [ @@ -734,7 +740,7 @@ class DefaultAgent(AbstractConversationAgent): if not entity: # Default name - entity_names.append((state.name, state.name, context)) + entity_names.append((state.name, state.entity_id, context)) continue if entity.aliases: @@ -742,10 +748,10 @@ class DefaultAgent(AbstractConversationAgent): if not alias.strip(): continue - entity_names.append((alias, alias, context)) + entity_names.append((alias, state.entity_id, context)) # Default name - entity_names.append((state.name, state.name, context)) + entity_names.append((state.name, state.entity_id, context)) # Expose all areas areas = ar.async_get(self.hass) @@ -785,7 +791,7 @@ class DefaultAgent(AbstractConversationAgent): if device_area is None: return None - return {"area": device_area.id} + return {"area": {"value": device_area.id, "text": device_area.name}} def _get_error_text( self, diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ea0a11ae657..75d28a4e732 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.0", "home-assistant-intents==2024.2.1"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.1"] } diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 26468f1fdb7..fe399659a56 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,4 +1,5 @@ """Module to coordinate user intentions.""" + from __future__ import annotations import asyncio @@ -401,17 +402,21 @@ class ServiceIntentHandler(IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - name: str | None = slots.get("name", {}).get("value") - if name == "all": + name_slot = slots.get("name", {}) + entity_id: str | None = name_slot.get("value") + entity_name: str | None = name_slot.get("text") + if entity_id == "all": # Don't match on name if targeting all entities - name = None + entity_id = None # Look up area first to fail early - area_name = slots.get("area", {}).get("value") + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + area_name = area_slot.get("text") area: area_registry.AreaEntry | None = None - if area_name is not None: + if area_id is not None: areas = area_registry.async_get(hass) - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( + area = areas.async_get_area(area_id) or areas.async_get_area_by_name( area_name ) if area is None: @@ -431,7 +436,7 @@ class ServiceIntentHandler(IntentHandler): states = list( async_match_states( hass, - name=name, + name=entity_id, area=area, domains=domains, device_classes=device_classes, @@ -442,8 +447,8 @@ class ServiceIntentHandler(IntentHandler): if not states: # No states matched constraints raise NoStatesMatchedError( - name=name, - area=area_name, + name=entity_name or entity_id, + area=area_name or area_id, domains=domains, device_classes=device_classes, ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 950cae8b322..d8cc1989904 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.4.0 hass-nabucasa==0.76.0 -hassil==1.6.0 +hassil==1.6.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240202.0 home-assistant-intents==2024.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 00b453a9b16..83261dedbb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1025,7 +1025,7 @@ hass-nabucasa==0.76.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.6.0 +hassil==1.6.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d84526af85f..908edcdeee5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -830,7 +830,7 @@ habluetooth==2.4.0 hass-nabucasa==0.76.0 # homeassistant.components.conversation -hassil==1.6.0 +hassil==1.6.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 468f3215cb7..034bfafc1f5 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1397,7 +1397,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'my cool light', + 'value': 'light.kitchen', }), }), 'intent': dict({ @@ -1422,7 +1422,7 @@ 'name': dict({ 'name': 'name', 'text': 'my cool light', - 'value': 'my cool light', + 'value': 'light.kitchen', }), }), 'intent': dict({ @@ -1498,7 +1498,7 @@ 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', 'slots': dict({ 'area': 'kitchen', - 'domain': 'light', + 'domain': 'lights', 'state': 'on', }), 'source': 'builtin', @@ -1572,7 +1572,7 @@ 'name': dict({ 'name': 'name', 'text': 'test light', - 'value': 'test light', + 'value': 'light.demo_1234', }), }), 'intent': dict({ @@ -1581,7 +1581,7 @@ 'match': True, 'sentence_template': '[] brightness [to] ', 'slots': dict({ - 'brightness': 100, + 'brightness': '100%', 'name': 'test light', }), 'source': 'builtin', @@ -1604,7 +1604,7 @@ 'name': dict({ 'name': 'name', 'text': 'test light', - 'value': 'test light', + 'value': 'light.demo_1234', }), }), 'intent': dict({ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index d7182aa3c2f..0cf343a3e20 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,4 +1,5 @@ """Test for the default agent.""" + from collections import defaultdict from unittest.mock import AsyncMock, patch @@ -85,8 +86,10 @@ async def test_exposed_areas( entity_registry: er.EntityRegistry, ) -> None: """Test that all areas are exposed.""" - area_kitchen = area_registry.async_get_or_create("kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom") + 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") entry = MockConfigEntry() entry.add_to_hass(hass) @@ -122,6 +125,9 @@ async def test_exposed_areas( # All is well for the exposed kitchen light assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name # Bedroom has no exposed entities result = await conversation.async_converse( @@ -195,7 +201,8 @@ async def test_unexposed_entities_skipped( entity_registry: er.EntityRegistry, ) -> None: """Test that unexposed entities are skipped in exposed areas.""" - area_kitchen = area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") # Both lights are in the kitchen exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") @@ -224,6 +231,9 @@ async def test_unexposed_entities_skipped( assert len(calls) == 1 assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name # Only one light should be returned hass.states.async_set(exposed_light.entity_id, "on") @@ -314,8 +324,10 @@ async def test_device_area_context( turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_off_calls = async_mock_service(hass, "light", "turn_off") - area_kitchen = area_registry.async_get_or_create("Kitchen") - area_bedroom = area_registry.async_get_or_create("Bedroom") + 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") # Create 2 lights in each area area_lights = defaultdict(list) @@ -363,13 +375,14 @@ async def test_device_area_context( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.intent is not None assert result.response.intent.slots["area"]["value"] == area_kitchen.id + assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name # Verify only kitchen lights were targeted assert {s.entity_id for s in result.response.matched_states} == { - e.entity_id for e in area_lights["kitchen"] + e.entity_id for e in area_lights[area_kitchen.id] } assert {c.data["entity_id"][0] for c in turn_on_calls} == { - e.entity_id for e in area_lights["kitchen"] + e.entity_id for e in area_lights[area_kitchen.id] } turn_on_calls.clear() @@ -386,13 +399,14 @@ async def test_device_area_context( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.intent is not None assert result.response.intent.slots["area"]["value"] == area_bedroom.id + assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } assert {c.data["entity_id"][0] for c in turn_on_calls} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } turn_on_calls.clear() @@ -409,13 +423,14 @@ async def test_device_area_context( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.intent is not None assert result.response.intent.slots["area"]["value"] == area_bedroom.id + assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } assert {c.data["entity_id"][0] for c in turn_off_calls} == { - e.entity_id for e in area_lights["bedroom"] + e.entity_id for e in area_lights[area_bedroom.id] } turn_off_calls.clear() @@ -463,7 +478,8 @@ async def test_error_no_device_in_area( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: """Test error message when area is missing a device/entity.""" - area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") result = await conversation.async_converse( hass, "turn on missing entity in the kitchen", None, Context(), None ) @@ -482,7 +498,7 @@ async def test_error_no_domain( """Test error message when no devices/entities exist for a domain.""" # We don't have a sentence for turning on all fans - fan_domain = MatchEntity(name="domain", value="fan", text="") + fan_domain = MatchEntity(name="domain", value="fan", text="fans") recognize_result = RecognizeResult( intent=Intent("HassTurnOn"), intent_data=IntentData([]), @@ -513,7 +529,8 @@ async def test_error_no_domain_in_area( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: """Test error message when no devices/entities for a domain exist in an area.""" - area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") result = await conversation.async_converse( hass, "turn on the lights in the kitchen", None, Context(), None ) @@ -526,13 +543,11 @@ async def test_error_no_domain_in_area( ) -async def test_error_no_device_class( - hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry -) -> None: +async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None: """Test error message when no entities of a device class exist.""" # We don't have a sentence for opening all windows - window_class = MatchEntity(name="device_class", value="window", text="") + window_class = MatchEntity(name="device_class", value="window", text="windows") recognize_result = RecognizeResult( intent=Intent("HassTurnOn"), intent_data=IntentData([]), @@ -563,7 +578,8 @@ async def test_error_no_device_class_in_area( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: """Test error message when no entities of a device class exist in an area.""" - area_registry.async_get_or_create("bedroom") + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") result = await conversation.async_converse( hass, "open bedroom windows", None, Context(), None ) @@ -600,7 +616,8 @@ async def test_no_states_matched_default_error( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: """Test default response when no states match and slots are missing.""" - area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") with patch( "homeassistant.components.conversation.default_agent.intent.async_handle", @@ -629,9 +646,9 @@ async def test_empty_aliases( entity_registry: er.EntityRegistry, ) -> None: """Test that empty aliases are not added to slot lists.""" - area_kitchen = area_registry.async_get_or_create("kitchen") - assert area_kitchen.id is not None - area_registry.async_update(area_kitchen.id, aliases={" "}) + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + area_kitchen = area_registry.async_update(area_kitchen.id, aliases={" "}) entry = MockConfigEntry() entry.add_to_hass(hass) @@ -643,11 +660,16 @@ async def test_empty_aliases( device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") - entity_registry.async_update_entity( - kitchen_light.entity_id, device_id=kitchen_device.id, aliases={" "} + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, + device_id=kitchen_device.id, + name="kitchen light", + aliases={" "}, ) hass.states.async_set( - kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + kitchen_light.entity_id, + "on", + attributes={ATTR_FRIENDLY_NAME: kitchen_light.name}, ) with patch( @@ -665,16 +687,16 @@ async def test_empty_aliases( assert slot_lists.keys() == {"area", "name"} areas = slot_lists["area"] assert len(areas.values) == 1 - assert areas.values[0].value_out == "kitchen" + assert areas.values[0].value_out == area_kitchen.id + assert areas.values[0].text_in.text == area_kitchen.normalized_name names = slot_lists["name"] assert len(names.values) == 1 - assert names.values[0].value_out == "kitchen light" + assert names.values[0].value_out == kitchen_light.entity_id + assert names.values[0].text_in.text == kitchen_light.name -async def test_all_domains_loaded( - hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry -) -> None: +async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None: """Test that sentences for all domains are always loaded.""" # light domain is not loaded From 1c960d300dca1dd3869728626c41a45896b2db05 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 2 Feb 2024 19:13:17 -0600 Subject: [PATCH 1339/1544] Bump intents to 2024.2.2 (#109412) Bump intents to 2024.2.2 --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 75d28a4e732..e4317052b04 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.1"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d8cc1989904..7746745da6b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ hass-nabucasa==0.76.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240202.0 -home-assistant-intents==2024.2.1 +home-assistant-intents==2024.2.2 httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 83261dedbb4..207e909fd60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1062,7 +1062,7 @@ holidays==0.41 home-assistant-frontend==20240202.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.1 +home-assistant-intents==2024.2.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 908edcdeee5..fd2a866437f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -858,7 +858,7 @@ holidays==0.41 home-assistant-frontend==20240202.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.1 +home-assistant-intents==2024.2.2 # homeassistant.components.home_connect homeconnect==0.7.2 From 2be71d53c55888c97822aa1bdc056dd979aed0cc Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 3 Feb 2024 06:39:35 +0000 Subject: [PATCH 1340/1544] Bump aiohomekit to 3.1.4 (#109414) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 799058b0e20..1617b907a26 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.3"], + "requirements": ["aiohomekit==3.1.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 207e909fd60..a9dbd9b441b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,7 +257,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.3 +aiohomekit==3.1.4 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd2a866437f..2782bf09e16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.1.3 +aiohomekit==3.1.4 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 From 14be7e4a7269327f16084c688b5351b78dc43605 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:41:25 +0100 Subject: [PATCH 1341/1544] Add Mill migrated ClimateEntityFeatures (#109415) --- homeassistant/components/mill/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index d0b15f5d8ff..2e7b22da833 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -99,6 +99,7 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: MillDataUpdateCoordinator, heater: mill.Heater From cd884de79e1d56363d73ef6d8d25571756c064fb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:47:09 +0100 Subject: [PATCH 1342/1544] Add new ClimateEntityFeature for Tado (#109416) --- homeassistant/components/tado/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 1193638c10e..dd0d6a22a08 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -131,7 +131,10 @@ def create_climate_entity(tado, name: str, zone_id: int, device_info: dict): zone_type = capabilities["type"] support_flags = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) supported_hvac_modes = [ TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], @@ -221,6 +224,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_name = None _attr_translation_key = DOMAIN _available = False + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From f01f033b3f3abcca828e534c2c8e30280be58ead Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:11:19 -0500 Subject: [PATCH 1343/1544] Add ClimateEntityFeatures to Nest (#109417) --- homeassistant/components/nest/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 03fb79eb78e..2d0186b2bfd 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -100,6 +100,7 @@ class ThermostatEntity(ClimateEntity): _attr_has_entity_name = True _attr_should_poll = False _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" @@ -246,7 +247,7 @@ class ThermostatEntity(ClimateEntity): def _get_supported_features(self) -> ClimateEntityFeature: """Compute the bitmap of supported features from the current state.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON if HVACMode.HEAT_COOL in self.hvac_modes: features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE if HVACMode.HEAT in self.hvac_modes or HVACMode.COOL in self.hvac_modes: From 4a1a5b9e87c151c9193a599b092be2d380a51fbd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:56:57 +0100 Subject: [PATCH 1344/1544] Adds migrated ClimateEntityFeature to Netatmo (#109418) --- homeassistant/components/netatmo/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 721e453e834..db12efb2f01 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -190,6 +190,7 @@ class NetatmoThermostat(NetatmoBaseEntity, ClimateEntity): _attr_supported_features = SUPPORT_FLAGS _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, netatmo_device: NetatmoRoom) -> None: """Initialize the sensor.""" From 66b6f81996ce69151fdd6119299266eb81d77d3f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:08:58 -0500 Subject: [PATCH 1345/1544] Add migrated ClimateEntityFeature to MQTT (#109419) --- homeassistant/components/mqtt/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 3df9db0d5d0..94311eeda61 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -610,6 +610,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED _attr_target_temperature_low: float | None = None _attr_target_temperature_high: float | None = None + _enable_turn_on_off_backwards_compatibility = False @staticmethod def config_schema() -> vol.Schema: From 966798b588e72ea85c4763648473c37e5498968b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:30:53 +0100 Subject: [PATCH 1346/1544] Add migrated ClimateEntityFeatures to advantage_air (#109420) * Add migrated ClimateEntityFeatures to advantage_air * AdvantageAirZone --- homeassistant/components/advantage_air/climate.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index d4f3c05902c..870a001a10f 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -83,6 +83,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" @@ -202,11 +203,16 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """AdvantageAir MyTemp Zone control.""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT_COOL] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 + _enable_turn_on_off_backwards_compatibility = False def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: """Initialize an AdvantageAir Zone control.""" From f8fde71ef3fc9e721eb1804bc6aab856a9ae93c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 04:02:19 +0100 Subject: [PATCH 1347/1544] Add new climate feature flags to airzone (#109423) --- homeassistant/components/airzone/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index f5a0e1b109e..2b4cae18086 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -117,6 +117,7 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -129,7 +130,11 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): super().__init__(coordinator, entry, system_zone_id, zone_data) self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) self._attr_target_temperature_step = API_TEMPERATURE_STEP self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ self.get_airzone_value(AZD_TEMP_UNIT) From 18a7aa20b46a76d3ee6f244b9d8e46fdc198cabb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:06:53 -0500 Subject: [PATCH 1348/1544] Adds new climate feature flags for airzone_cloud (#109424) --- homeassistant/components/airzone_cloud/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index e076edc1f5b..73333d346c5 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -144,8 +144,13 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @callback def _handle_coordinator_update(self) -> None: From d71dd12263c708a367f0a636ca30fa2da31722dc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:46:14 +0100 Subject: [PATCH 1349/1544] Add migrated climate feature flags to shelly (#109425) --- homeassistant/components/shelly/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 9c43c0b57b8..59343ca6d2f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -167,6 +167,7 @@ class BlockSleepingClimate( ) _attr_target_temperature_step = SHTRV_01_TEMPERATURE_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -448,6 +449,7 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): ) _attr_target_temperature_step = RPC_THERMOSTAT_SETTINGS["step"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize.""" From 183af926587dbc4d5f547926a6fd9723b48b5d7f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:11:39 -0500 Subject: [PATCH 1350/1544] Add migrated climate feature flags to smartthings (#109426) --- homeassistant/components/smartthings/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 656a198f42b..4c2afa45b7f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -162,6 +162,8 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" + _enable_turn_on_off_backwards_compatibility = False + def __init__(self, device): """Init the class.""" super().__init__(device) @@ -343,6 +345,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _hvac_modes: list[HVACMode] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device) -> None: """Init the class.""" From f1b041afbe17e7a1ba089d2d05d060b546569ffc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 00:42:10 -0500 Subject: [PATCH 1351/1544] Add migrated climate feature flags to smarttub (#109427) --- homeassistant/components/smarttub/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 9f1802e7327..4921fca022d 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -67,6 +67,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = list(PRESET_MODES.values()) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, spa): """Initialize the entity.""" From c571f36c6c49f771afa6616e80f2e60ec305e4ac Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:13:20 -0500 Subject: [PATCH 1352/1544] Add new climate feature flags to evohome (#109429) --- homeassistant/components/evohome/climate.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index a1c46f3d331..8b74d31cc0d 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -156,6 +156,7 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): """Base for an evohome Climate device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self) -> list[HVACMode]: @@ -190,7 +191,10 @@ class EvoZone(EvoChild, EvoClimateEntity): ] self._attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: @@ -372,6 +376,9 @@ class EvoController(EvoClimateEntity): ] if self._attr_preset_modes: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (system mode) for a controller. From 6c4b773bc1b39ac9ab9b38092441c02c29bdad5d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:32:47 -0500 Subject: [PATCH 1353/1544] Add migrated climate entity features to flexit (#109430) --- homeassistant/components/flexit/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index b833617f2ca..85d5e9f4eac 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -69,6 +69,7 @@ class Flexit(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, hub: ModbusHub, modbus_slave: int | None, name: str | None From e99a58ad53481442281d32a3b5ad636ed5a5297c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:43:32 +0100 Subject: [PATCH 1354/1544] Add new climate feature flags to flexit_bacnet (#109431) --- homeassistant/components/flexit_bacnet/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 7740bed73e1..0d8a381a014 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -62,13 +62,17 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): ] _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: FlexitCoordinator) -> None: """Initialize the Flexit unit.""" From 0c0be6d6a1919307e14a0212d009a66aa25d40fe Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:46:27 +0100 Subject: [PATCH 1355/1544] Add migrated climate feature flags to homekit_controller (#109433) --- homeassistant/components/homekit_controller/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 8cc4ec569dd..0ca85da3fa2 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -139,6 +139,7 @@ class HomeKitBaseClimateEntity(HomeKitEntity, ClimateEntity): """The base HomeKit Controller climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @callback def _async_reconfigure(self) -> None: From 2a5bb66c0618184fad708883594ce8d6cb8832b5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:33:42 -0500 Subject: [PATCH 1356/1544] Adds migrated climate entity feature for velbus (#109435) --- homeassistant/components/velbus/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ecdddd19289..9afbfc683a8 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -41,6 +41,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_preset_modes = list(PRESET_MODES) + _enable_turn_on_off_backwards_compatibility = False @property def target_temperature(self) -> float | None: From f2a9ef65917a6ce10b2b3b7ad2dcf14a003eebb6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:33:55 -0500 Subject: [PATCH 1357/1544] Add new climate feature flags to venstar (#109436) --- homeassistant/components/venstar/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 6359cc19e57..a9ee56c4dbb 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -108,6 +108,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] _attr_precision = PRECISION_HALVES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -130,6 +131,8 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if self._client.mode == self._client.MODE_AUTO: From 63ad3ebdf422c9fe4c23bb763af35a53dac11912 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sat, 3 Feb 2024 07:50:33 +0100 Subject: [PATCH 1358/1544] Add new OUIs for tplink (#109437) --- homeassistant/components/tplink/manifest.json | 66 ++++++++++++++- homeassistant/generated/dhcp.py | 82 ++++++++++++++++++- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a479314d649..a91e7e5a46f 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -156,6 +156,10 @@ "hostname": "k[lps]*", "macaddress": "54AF97*" }, + { + "hostname": "l[59]*", + "macaddress": "54AF97*" + }, { "hostname": "k[lps]*", "macaddress": "AC15A2*" @@ -177,21 +181,41 @@ "macaddress": "5CE931*" }, { - "hostname": "l5*", + "hostname": "l[59]*", "macaddress": "3C52A1*" }, { "hostname": "l5*", "macaddress": "5C628B*" }, + { + "hostname": "tp*", + "macaddress": "5C628B*" + }, { "hostname": "p1*", "macaddress": "482254*" }, + { + "hostname": "s5*", + "macaddress": "482254*" + }, { "hostname": "p1*", "macaddress": "30DE4B*" }, + { + "hostname": "p1*", + "macaddress": "3C52A1*" + }, + { + "hostname": "tp*", + "macaddress": "3C52A1*" + }, + { + "hostname": "s5*", + "macaddress": "3C52A1*" + }, { "hostname": "l9*", "macaddress": "A842A1*" @@ -199,6 +223,46 @@ { "hostname": "l9*", "macaddress": "3460F9*" + }, + { + "hostname": "hs*", + "macaddress": "704F57*" + }, + { + "hostname": "k[lps]*", + "macaddress": "74DA88*" + }, + { + "hostname": "p3*", + "macaddress": "788CB5*" + }, + { + "hostname": "p1*", + "macaddress": "CC32E5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "CC32E5*" + }, + { + "hostname": "hs*", + "macaddress": "CC32E5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "D80D17*" + }, + { + "hostname": "k[lps]*", + "macaddress": "D84732*" + }, + { + "hostname": "p1*", + "macaddress": "F0A731*" + }, + { + "hostname": "l9*", + "macaddress": "F0A731*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index a087c8ac483..a6722282e35 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -788,6 +788,11 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "54AF97*", }, + { + "domain": "tplink", + "hostname": "l[59]*", + "macaddress": "54AF97*", + }, { "domain": "tplink", "hostname": "k[lps]*", @@ -815,7 +820,7 @@ DHCP: list[dict[str, str | bool]] = [ }, { "domain": "tplink", - "hostname": "l5*", + "hostname": "l[59]*", "macaddress": "3C52A1*", }, { @@ -823,16 +828,41 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "l5*", "macaddress": "5C628B*", }, + { + "domain": "tplink", + "hostname": "tp*", + "macaddress": "5C628B*", + }, { "domain": "tplink", "hostname": "p1*", "macaddress": "482254*", }, + { + "domain": "tplink", + "hostname": "s5*", + "macaddress": "482254*", + }, { "domain": "tplink", "hostname": "p1*", "macaddress": "30DE4B*", }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "tp*", + "macaddress": "3C52A1*", + }, + { + "domain": "tplink", + "hostname": "s5*", + "macaddress": "3C52A1*", + }, { "domain": "tplink", "hostname": "l9*", @@ -843,6 +873,56 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "l9*", "macaddress": "3460F9*", }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "704F57*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "74DA88*", + }, + { + "domain": "tplink", + "hostname": "p3*", + "macaddress": "788CB5*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "CC32E5*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "D80D17*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "D84732*", + }, + { + "domain": "tplink", + "hostname": "p1*", + "macaddress": "F0A731*", + }, + { + "domain": "tplink", + "hostname": "l9*", + "macaddress": "F0A731*", + }, { "domain": "tuya", "macaddress": "105A17*", From a5646e0df2484541e1aeea82931ac4172d7e99c2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:33:03 -0500 Subject: [PATCH 1359/1544] Add migrated feature flags to vera (#109438) --- homeassistant/components/vera/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 85c1851b20e..93d0fbf2aee 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -53,6 +53,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__( self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData From 57279b1c7bdcb485e152b7dfd7ec7697b3ac8ddf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:13:52 -0500 Subject: [PATCH 1360/1544] Add migrated climate feature flags to vicare (#109439) --- homeassistant/components/vicare/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 7c47629530a..ba2665ac083 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -157,6 +157,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) _current_action: bool | None = None _current_mode: str | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 6c84e8dff0d362f76471f4d66a59e07620646f19 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:36:19 -0500 Subject: [PATCH 1361/1544] Add new climate feature flags to whirlpool (#109440) --- homeassistant/components/whirlpool/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 2d38d713859..48b9b99c1e2 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -103,10 +103,13 @@ class AirConEntity(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_swing_modes = SUPPORTED_SWING_MODES _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From a671d0bc6cd38d3ba77365c7fa3ad217aab8b1ec Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:40:50 -0500 Subject: [PATCH 1362/1544] Add migrated climate feature flags to xs1 (#109441) --- homeassistant/components/xs1/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index 4c4f6682ffa..949d2330347 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -54,6 +54,7 @@ class XS1ThermostatEntity(XS1DeviceEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device, sensor): """Initialize the actuator.""" From d04282a41ccbc3f8f961fcb6f46fec57dfb756be Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:37:14 -0500 Subject: [PATCH 1363/1544] Add new climate feature flags to yolink (#109442) --- homeassistant/components/yolink/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index 6e4495ee0b9..a1e2fdd90a2 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -62,6 +62,7 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """YoLink Climate Entity.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -86,6 +87,8 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) @callback From b6226acd2b88e3b6aeba9402b4a59b9d5cf4ed68 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:06:55 +0100 Subject: [PATCH 1364/1544] Add migrated climate feature flags to zha (#109443) --- homeassistant/components/zha/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 40da264d695..cbc759e7008 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -141,6 +141,7 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key: str = "thermostat" + _enable_turn_on_off_backwards_compatibility = False def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" From 13decb9b10c404f6dccffeda79a9909910502929 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:35:16 -0500 Subject: [PATCH 1365/1544] Add new climate feature flags to zhong_hong (#109444) --- homeassistant/components/zhong_hong/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index 1364dbe107a..fbada765cde 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -128,9 +128,13 @@ class ZhongHongClimate(ClimateEntity): ] _attr_should_poll = False _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hub, addr_out, addr_in): """Set up the ZhongHong climate devices.""" From 94464f220c9dfbd592001e8518ea27d0f61bce2c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:36:43 -0500 Subject: [PATCH 1366/1544] Add migrated climate feature flags to zwave_me (#109445) --- homeassistant/components/zwave_me/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index 7d654311213..35e0d745619 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -56,6 +56,7 @@ class ZWaveMeClimate(ZWaveMeEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" From 13fc69d8a8516d37ae7f36f4a5646ce0d8087092 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:37:49 -0500 Subject: [PATCH 1367/1544] Add migrated climate feature flags to teslemetry (#109446) --- homeassistant/components/teslemetry/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index b626d3ef759..748acbb8552 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -45,6 +45,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): | ClimateEntityFeature.PRESET_MODE ) _attr_preset_modes = ["off", "keep", "dog", "camp"] + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode | None: From 36bba95fd0d7014e465a4ff7a9dba492161a2f21 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:38:40 -0500 Subject: [PATCH 1368/1544] Add migrated climate feature flags for tessie (#109447) --- homeassistant/components/tessie/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index d143771ee2c..8eb69d619ff 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -56,6 +56,7 @@ class TessieClimateEntity(TessieEntity, ClimateEntity): TessieClimateKeeper.DOG, TessieClimateKeeper.CAMP, ] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 72ffdf4f4b289a8a73d9affc81133f58c66ac39d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:39:18 -0500 Subject: [PATCH 1369/1544] Add new climate feature flags to tfiac (#109448) --- homeassistant/components/tfiac/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 2e764b5c637..7e5999b7f02 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -83,8 +83,11 @@ class TfiacClimate(ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hass, client): """Init class.""" From 776e2da4e6f229aa9d88141e6ea38ed28c85ee65 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:39:37 -0500 Subject: [PATCH 1370/1544] Add migrated climate feature flags to tolo (#109449) --- homeassistant/components/tolo/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 05afce41ff3..033a4c5b51c 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -58,6 +58,7 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): ) _attr_target_temperature_step = 1 _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry From 3181358484b7c94d4b6b188fcab7100be52feaae Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:40:17 -0500 Subject: [PATCH 1371/1544] Add migrated climate feature flags to toon (#109450) --- homeassistant/components/toon/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index cc51bb03fec..16fbdbdd356 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -51,6 +51,7 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From db91a40b556deda7b531cd5f1f7c263005318400 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:41:08 -0500 Subject: [PATCH 1372/1544] Add migrated climate feature flags to touchline (#109451) --- homeassistant/components/touchline/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index ed3d4500db1..5004646a667 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -69,6 +69,7 @@ class Touchline(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, touchline_thermostat): """Initialize the Touchline device.""" From 39df394414cd39abd12e306304af4c4693490065 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:41:28 -0500 Subject: [PATCH 1373/1544] Add migrated climate feature flags to schluter (#109452) --- homeassistant/components/schluter/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py index c8c0d76690d..5d747c8f345 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -81,6 +81,7 @@ class SchluterThermostat(CoordinatorEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, serial_number, api, session_id): """Initialize the thermostat.""" From cba67d15255c8b2d0e648c6b88aa23513a305a1c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:42:37 -0500 Subject: [PATCH 1374/1544] Add new climate feature flags to screenlogic (#109454) --- homeassistant/components/screenlogic/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 7cdfbba10c0..6d95f06a49c 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -81,8 +81,12 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" From 8cfe3821da7844190c3330e51cfff09f2c286b58 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:44:04 -0500 Subject: [PATCH 1375/1544] Add migrated climate feature flags to senz (#109455) --- homeassistant/components/senz/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index a94941ac642..c921e1ac1da 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -45,6 +45,7 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): _attr_min_temp = 5 _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From fecdfbfb9f7f0b16fb65cdc7ff3058795075c678 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:45:06 -0500 Subject: [PATCH 1376/1544] Add new climate feature flags to stiebel_eltron (#109457) --- homeassistant/components/stiebel_eltron/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 88cce6c52d7..cedd1b3dd90 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -73,9 +73,13 @@ class StiebelEltron(ClimateEntity): _attr_hvac_modes = SUPPORT_HVAC _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, ste_data): """Initialize the unit.""" From 5991b06574c8f54be915c5f41d793aa41a5c0249 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:51:38 -0500 Subject: [PATCH 1377/1544] Add new climate feature flags to oem (#109461) --- homeassistant/components/oem/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 1b600b25d94..86c770ec82d 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -66,8 +66,13 @@ class ThermostatDevice(ClimateEntity): """Interface class for the oemthermostat module.""" _attr_hvac_modes = SUPPORT_HVAC - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat, name): """Initialize the device.""" From 5843c93371cd14bf9a2691d66d6a1dda5af4aed2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 2 Feb 2024 22:54:01 -0500 Subject: [PATCH 1378/1544] Add migrated climate feature flags to opentherm_gw (#109462) --- homeassistant/components/opentherm_gw/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index bcad621eb82..0b9cd1862be 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -84,6 +84,7 @@ class OpenThermClimate(ClimateEntity): _away_state_a = False _away_state_b = False _current_operation: HVACAction | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, gw_dev, options): """Initialize the device.""" From e98da8596a855fa83aba8c1e15cab9afa6d54089 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 06:25:52 -0500 Subject: [PATCH 1379/1544] Add migrated climate feature flags to overkiz (#109463) --- .../overkiz/climate_entities/atlantic_electrical_heater.py | 1 + ...tic_electrical_heater_with_adjustable_temperature_setpoint.py | 1 + .../overkiz/climate_entities/atlantic_electrical_towel_dryer.py | 1 + .../climate_entities/atlantic_heat_recovery_ventilation.py | 1 + .../overkiz/climate_entities/atlantic_pass_apc_heating_zone.py | 1 + .../overkiz/climate_entities/atlantic_pass_apc_zone_control.py | 1 + .../climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py | 1 + .../climate_entities/somfy_heating_temperature_interface.py | 1 + .../components/overkiz/climate_entities/somfy_thermostat.py | 1 + .../climate_entities/valve_heating_temperature_interface.py | 1 + 10 files changed, 10 insertions(+) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index 867e977276d..2678986574d 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -53,6 +53,7 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 14237b4601b..36e958fb49c 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -75,6 +75,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index b053611de9b..fefaa75a114 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -45,6 +45,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py index 115a30a7c36..5876f7df4a7 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -54,6 +54,7 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index 90bc3e40404..25dab7c1d7e 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -83,6 +83,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index 1ef0f9bf400..fe9f20b05fc 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -30,6 +30,7 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py index 162b9b4fce6..9b956acd014 100644 --- a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py @@ -90,6 +90,7 @@ class HitachiAirToAirHeatPumpHLRRWIFI(OverkizEntity, ClimateEntity): _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py index cc470dee032..f98865456e1 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py @@ -81,6 +81,7 @@ class SomfyHeatingTemperatureInterface(OverkizEntity, ClimateEntity): # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 9a81b6d5bd3..2b6840b463d 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -64,6 +64,7 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index b58c29a6121..79c360a5f93 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -58,6 +58,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator From a77bcccbbb8ed0aab1fcd7cc344e6c3ccda7b25c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:06:21 -0500 Subject: [PATCH 1380/1544] Adds migrated climate feature flags for proliphix (#109465) --- homeassistant/components/proliphix/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index 5f841441d59..797fd751197 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -60,6 +60,7 @@ class ProliphixThermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, pdp): """Initialize the thermostat.""" From f3f69a810743b88b35595c223d2d3bba07c02e99 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:50:24 +0100 Subject: [PATCH 1381/1544] Add new climate feature flags to radiotherm (#109466) --- homeassistant/components/radiotherm/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index f5ea14e8f4e..4ab57fd6821 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -106,6 +106,7 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_precision = PRECISION_HALVES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" @@ -113,7 +114,10 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): self._attr_unique_id = self.init_data.mac self._attr_fan_modes = CT30_FAN_OPERATION_LIST self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) if not isinstance(self.device, radiotherm.thermostat.CT80): return From 1c0a6970e25e869da0c5cdfc8dbb6d2bc616afa6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:05:46 -0500 Subject: [PATCH 1382/1544] Adds new climate feature flags to maxcube (#109467) --- homeassistant/components/maxcube/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 2ef451b04a7..f3d302fc209 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -68,8 +68,12 @@ class MaxCubeClimate(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.HEAT] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, handler, device): """Initialize MAX! Cube ClimateEntity.""" From 2ac4bb8e9f4d7a2f7222322c8a3744094da71e46 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 05:18:40 -0500 Subject: [PATCH 1383/1544] Add new feature flags to melcloud (#109468) --- homeassistant/components/melcloud/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9d2a4f08257..ed37ff76b76 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -114,6 +114,7 @@ class MelCloudClimate(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: MelCloudDevice) -> None: """Initialize the climate.""" @@ -137,6 +138,8 @@ class AtaDeviceClimate(MelCloudClimate): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None: From 490101fa92740a22368c31e49fbaace8f9673f7d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:05:33 -0500 Subject: [PATCH 1384/1544] Adds new climate feature flags to melissa (#109469) --- homeassistant/components/melissa/climate.py | 6 +++++- tests/components/melissa/test_climate.py | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py index 9facb18ed05..f94c3af6d9a 100644 --- a/homeassistant/components/melissa/climate.py +++ b/homeassistant/components/melissa/climate.py @@ -57,9 +57,13 @@ class MelissaClimate(ClimateEntity): _attr_hvac_modes = OP_MODES _attr_supported_features = ( - ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, serial_number, init_data): """Initialize the climate device.""" diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index 4568eaf2e77..dc2ca4391f1 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -223,7 +223,10 @@ async def test_supported_features(hass: HomeAssistant) -> None: device = (await api.async_fetch_devices())[_SERIAL] thermostat = MelissaClimate(api, _SERIAL, device) features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) assert thermostat.supported_features == features From 49445c46a04b832f5a42b94d5baa2fd6b61bdf9c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:05:16 -0500 Subject: [PATCH 1385/1544] Add migrated climate feature flags to moehlenhoff (#109470) --- homeassistant/components/moehlenhoff_alpha2/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 23a39084f9f..063628d6d32 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -46,6 +46,7 @@ class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None: """Initialize Alpha2 ClimateEntity.""" From bb8a74a3f43d7991d510ef4e7d8119b4ab676ef7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 02:03:38 -0500 Subject: [PATCH 1386/1544] Add new climate feature flags to mysensors (#109471) Adds new climate feature flags to mysensors --- homeassistant/components/mysensors/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d532135304a..0058fca021e 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -70,11 +70,12 @@ class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST + _enable_turn_on_off_backwards_compatibility = False @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - features = ClimateEntityFeature(0) + features = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON set_req = self.gateway.const.SetReq if set_req.V_HVAC_SPEED in self._values: features = features | ClimateEntityFeature.FAN_MODE From 650ab704440153975c73ca65e1eae82d2b6b3cca Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:26:17 +0100 Subject: [PATCH 1387/1544] Add migrated climate feature flags to nexia (#109472) --- homeassistant/components/nexia/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 32ac8b5320a..63caeb445b7 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -153,6 +153,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone From 3bf5fa9302a43a391aa4f72ee29f74c176f80f72 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:03:14 -0500 Subject: [PATCH 1388/1544] Adds migrated climate feature flags to nobo_hub (#109473) --- homeassistant/components/nobo_hub/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 7041d097f3e..ca8ee08885d 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -81,6 +81,7 @@ class NoboZone(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 # Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False + _enable_turn_on_off_backwards_compatibility = False def __init__(self, zone_id, hub: nobo, override_type) -> None: """Initialize the climate device.""" From 463320c8eed6e4e66c9666d9dde176b6eae63d26 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:03:34 -0500 Subject: [PATCH 1389/1544] Adds migrated climate feature flags in nuheat (#109474) --- homeassistant/components/nuheat/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 13a46c0b32f..b2ebbfa8485 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -78,6 +78,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_preset_modes = PRESET_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" From 92ebc5b43668fd83ee425966e9543431cae0756f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:03:44 -0500 Subject: [PATCH 1390/1544] Adds new climate feature flags to ambiclimate (#109475) --- homeassistant/components/ambiclimate/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index fc192d8658f..58b2334260e 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -153,10 +153,15 @@ class AmbiclimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, heater: AmbiclimateDevice, store: Store[dict[str, Any]]) -> None: """Initialize the thermostat.""" From 5cce878b859b6172d8b89a43a3b0ae951698e9c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 03:26:04 +0100 Subject: [PATCH 1391/1544] Adds new climate feature flags in baf (#109476) --- homeassistant/components/baf/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index 531659e901f..907e8ff2356 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -33,10 +33,15 @@ async def async_setup_entry( class BAFAutoComfort(BAFEntity, ClimateEntity): """BAF climate auto comfort.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_translation_key = "auto_comfort" + _enable_turn_on_off_backwards_compatibility = False @callback def _async_update_attrs(self) -> None: From 4b8cb35ba0083727cd606932bf15dffebe14322c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:03:59 -0500 Subject: [PATCH 1392/1544] Adds migrated climate feature flags in balboa (#109477) --- homeassistant/components/balboa/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 0ca8b1a3acc..b9cce73de75 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -63,6 +63,7 @@ class BalboaClimateEntity(BalboaEntity, ClimateEntity): ) _attr_translation_key = DOMAIN _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, client: SpaClient) -> None: """Initialize the climate entity.""" From 150fb151fa978c3b9a17748ee15c8b5ab2d97d85 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 05:18:00 -0500 Subject: [PATCH 1393/1544] Add new climate feature flags to blebox (#109478) --- homeassistant/components/blebox/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index e4ac8985ebd..1350f1f29a2 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -53,8 +53,13 @@ async def async_setup_entry( class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity): """Representation of a BleBox climate feature (saunaBox).""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_modes(self): From 2724b115daca23be28c612e86a9450ad45e8b12f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:04:11 -0500 Subject: [PATCH 1394/1544] Adds new climate feature flags to broadlink (#109479) --- homeassistant/components/broadlink/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index 6937d6bb0da..dd37d270f9e 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -35,9 +35,14 @@ class BroadlinkThermostat(ClimateEntity, BroadlinkEntity): _attr_has_entity_name = True _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: BroadlinkDevice) -> None: """Initialize the climate entity.""" From cc8e9ac1414abf603ce74bcdfe32f1e73d434489 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 01:04:26 -0500 Subject: [PATCH 1395/1544] Adds new climate feature flags to bsblan (#109480) --- homeassistant/components/bsblan/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 609d5ab6e83..511701cb538 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -73,12 +73,16 @@ class BSBLANClimate( _attr_name = None # Determine preset modes _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_preset_modes = PRESET_MODES # Determine hvac modes _attr_hvac_modes = HVAC_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 93b7ffa807ca84892aa7dd9a77729902448dbbc8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 05:07:56 -0500 Subject: [PATCH 1396/1544] Add new climate feature flags to demo (#109481) --- homeassistant/components/demo/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index b857f98e2da..745a2473939 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -97,6 +97,7 @@ class DemoClimate(ClimateEntity): _attr_name = None _attr_should_poll = False _attr_translation_key = "ubercool" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -137,6 +138,9 @@ class DemoClimate(ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._target_temperature = target_temperature self._target_humidity = target_humidity self._unit_of_measurement = unit_of_measurement From 280d7ef4ec06097908385d859ba049e375d4ca39 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 05:17:20 -0500 Subject: [PATCH 1397/1544] Add new climate feature flags to deconz (#109482) --- homeassistant/components/deconz/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index eb1d0d6b672..35a0e810c9e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -100,6 +100,7 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): TYPE = DOMAIN _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: Thermostat, gateway: DeconzGateway) -> None: """Set up thermostat device.""" @@ -119,7 +120,11 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): HVAC_MODE_TO_DECONZ[item]: item for item in self._attr_hvac_modes } - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) if device.fan_mode: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE From e6300274559bb81f273265125d7c4d22e9735789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 3 Feb 2024 08:16:28 +0100 Subject: [PATCH 1398/1544] Extend the history of Elvia history to 3 years (#109490) Extend the history of Elvia data to 3 years --- homeassistant/components/elvia/config_flow.py | 4 ++- homeassistant/components/elvia/importer.py | 36 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/elvia/config_flow.py b/homeassistant/components/elvia/config_flow.py index e65c93b09a6..fb50842e39b 100644 --- a/homeassistant/components/elvia/config_flow.py +++ b/homeassistant/components/elvia/config_flow.py @@ -35,8 +35,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._api_token = api_token = user_input[CONF_API_TOKEN] client = Elvia(meter_value_token=api_token).meter_value() try: + end_time = dt_util.utcnow() results = await client.get_meter_values( - start_time=(dt_util.now() - timedelta(hours=1)).isoformat() + start_time=(end_time - timedelta(hours=1)).isoformat(), + end_time=end_time.isoformat(), ) except ElviaError.AuthError as exception: diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 69e3d64d09d..097db51cab8 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING, cast -from elvia import Elvia +from elvia import Elvia, error as ElviaError from homeassistant.components.recorder.models import StatisticData, StatisticMetaData from homeassistant.components.recorder.statistics import ( @@ -68,21 +68,37 @@ class ElviaImporter: ) if not last_stats: - # First time we insert 1 years of data (if available) + # First time we insert 3 years of data (if available) + hourly_data: list[MeterValueTimeSeries] = [] until = dt_util.utcnow() - hourly_data = await self._fetch_hourly_data( - since=until - timedelta(days=365), - until=until, - ) + for year in (3, 2, 1): + try: + year_hours = await self._fetch_hourly_data( + since=until - timedelta(days=365 * year), + until=until - timedelta(days=365 * (year - 1)), + ) + except ElviaError.ElviaException: + # This will raise if the contract have no data for the + # year, we can safely ignore this + continue + hourly_data.extend(year_hours) + if hourly_data is None or len(hourly_data) == 0: + LOGGER.error("No data available for the metering point") return last_stats_time = None _sum = 0.0 else: - hourly_data = await self._fetch_hourly_data( - since=dt_util.utc_from_timestamp(last_stats[statistic_id][0]["end"]), - until=dt_util.utcnow(), - ) + try: + hourly_data = await self._fetch_hourly_data( + since=dt_util.utc_from_timestamp( + last_stats[statistic_id][0]["end"] + ), + until=dt_util.utcnow(), + ) + except ElviaError.ElviaException as err: + LOGGER.error("Error fetching data: %s", err) + return if ( hourly_data is None From cb03a6e29b8e7514d9c9a48801deaf2caac0f33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 3 Feb 2024 09:14:52 +0100 Subject: [PATCH 1399/1544] Change IoT class for Traccar Client (#109493) --- homeassistant/components/traccar/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 978a0b2f507..c3b9e540ab6 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/traccar", - "iot_class": "local_polling", + "iot_class": "cloud_push", "loggers": ["pytraccar"], "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 21186272bb6..2b97365b555 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6168,7 +6168,7 @@ "traccar": { "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", + "iot_class": "cloud_push", "name": "Traccar Client" }, "traccar_server": { From 0a627aed6d5734e4cab624ff43fe997d8eb9cd9c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 3 Feb 2024 11:51:23 +0100 Subject: [PATCH 1400/1544] Fix Tankerkoenig diagnostics file to use right format (#109494) Fix tankerkoenig diagnostics file --- homeassistant/components/tankerkoenig/diagnostics.py | 6 +++++- .../components/tankerkoenig/snapshots/test_diagnostics.ambr | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py index 811ec07ef19..d5fd7c8cada 100644 --- a/homeassistant/components/tankerkoenig/diagnostics.py +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for Tankerkoenig.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -27,6 +28,9 @@ async def async_get_config_entry_diagnostics( diag_data = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "data": coordinator.data, + "data": { + station_id: asdict(price_info) + for station_id, price_info in coordinator.data.items() + }, } return diag_data diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index a27a210c46e..f52cb3a88a5 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -3,8 +3,10 @@ dict({ 'data': dict({ '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8': dict({ - '__type': "", - 'repr': "PriceInfo(status=, e5=1.719, e10=1.659, diesel=1.659)", + 'diesel': 1.659, + 'e10': 1.659, + 'e5': 1.719, + 'status': 'open', }), }), 'entry': dict({ From ef6fed506794acde85096998d3b967b5a8bc5256 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 3 Feb 2024 14:42:00 +0100 Subject: [PATCH 1401/1544] Bump version to 2024.2.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 4f720539b77..2cbb5565ef0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -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, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 68b4d47d662..d75d8efdc08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b3" +version = "2024.2.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4b2adab24dadb1e14b54ab1e6888d956f63b75e6 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:02:21 +0100 Subject: [PATCH 1402/1544] Revert "Add webhook support to tedee integration (#106846)" (#109408) --- homeassistant/components/tedee/__init__.py | 80 +------------- homeassistant/components/tedee/config_flow.py | 8 +- homeassistant/components/tedee/coordinator.py | 26 +---- homeassistant/components/tedee/manifest.json | 2 +- tests/components/tedee/conftest.py | 7 +- tests/components/tedee/test_config_flow.py | 43 +++----- tests/components/tedee/test_init.py | 104 +----------------- tests/components/tedee/test_lock.py | 34 +----- 8 files changed, 30 insertions(+), 274 deletions(-) diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index cbc608d03a6..eeb0f8e0d5a 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -1,25 +1,12 @@ """Init the tedee component.""" -from collections.abc import Awaitable, Callable -from http import HTTPStatus import logging -from typing import Any -from aiohttp.hdrs import METH_POST -from aiohttp.web import Request, Response -from pytedee_async.exception import TedeeWebhookException - -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.webhook import ( - async_generate_url as webhook_generate_url, - async_register as webhook_register, - async_unregister as webhook_unregister, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, NAME +from .const import DOMAIN from .coordinator import TedeeApiCoordinator PLATFORMS = [ @@ -50,38 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - async def unregister_webhook(_: Any) -> None: - await coordinator.async_unregister_webhook() - webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - - async def register_webhook() -> None: - webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - webhook_name = "Tedee" - if entry.title != NAME: - webhook_name = f"{NAME} {entry.title}" - - webhook_register( - hass, - DOMAIN, - webhook_name, - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), - allowed_methods=[METH_POST], - ) - _LOGGER.debug("Registered Tedee webhook at hass: %s", webhook_url) - - try: - await coordinator.async_register_webhook(webhook_url) - except TedeeWebhookException as ex: - _LOGGER.warning("Failed to register Tedee webhook from bridge: %s", ex) - else: - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) - - entry.async_create_background_task( - hass, register_webhook(), "tedee_register_webhook" - ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -90,34 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -def get_webhook_handler( - coordinator: TedeeApiCoordinator, -) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: - """Return webhook handler.""" - - async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request - ) -> Response | None: - # Handle http post calls to the path. - if not request.body_exists: - return HomeAssistantView.json( - result="No Body", status_code=HTTPStatus.BAD_REQUEST - ) - - body = await request.json() - try: - coordinator.webhook_received(body) - except TedeeWebhookException as ex: - return HomeAssistantView.json( - result=str(ex), status_code=HTTPStatus.BAD_REQUEST - ) - - return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) - - return async_webhook_handler diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 8bd9efd2b17..075a4c998ea 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -11,9 +11,8 @@ from pytedee_async import ( ) import voluptuous as vol -from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -62,10 +61,7 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") await self.async_set_unique_id(local_bridge.serial) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=NAME, - data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, - ) + return self.async_create_entry(title=NAME, data=user_input) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index cdd907b2e58..c846f2a8d9a 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -3,7 +3,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time -from typing import Any from pytedee_async import ( TedeeClient, @@ -11,7 +10,6 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, TedeeLock, - TedeeWebhookException, ) from pytedee_async.bridge import TedeeBridge @@ -25,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=20) GET_LOCKS_INTERVAL_SECONDS = 3600 _LOGGER = logging.getLogger(__name__) @@ -55,7 +53,6 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] - self.tedee_webhook_id: int | None = None @property def bridge(self) -> TedeeBridge: @@ -106,27 +103,6 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): except (TedeeClientException, TimeoutError) as ex: raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex - def webhook_received(self, message: dict[str, Any]) -> None: - """Handle webhook message.""" - self.tedee_client.parse_webhook_message(message) - self.async_set_updated_data(self.tedee_client.locks_dict) - - async def async_register_webhook(self, webhook_url: str) -> None: - """Register the webhook at the Tedee bridge.""" - self.tedee_webhook_id = await self.tedee_client.register_webhook(webhook_url) - - async def async_unregister_webhook(self) -> None: - """Unregister the webhook at the Tedee bridge.""" - if self.tedee_webhook_id is not None: - try: - await self.tedee_client.delete_webhook(self.tedee_webhook_id) - except TedeeWebhookException as ex: - _LOGGER.warning( - "Failed to unregister Tedee webhook from bridge: %s", ex - ) - else: - _LOGGER.debug("Unregistered Tedee webhook") - def _async_add_remove_locks(self) -> None: """Add new locks, remove non-existing locks.""" if not self._locks_last_update: diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 0a13b2266fa..1776e3b7ab2 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -3,7 +3,7 @@ "name": "Tedee", "codeowners": ["@patrickhilker", "@zweckj"], "config_flow": true, - "dependencies": ["http", "webhook"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "requirements": ["pytedee-async==0.2.13"] diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index a633b1642ea..21fb4047ab3 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -10,13 +10,11 @@ from pytedee_async.lock import TedeeLock import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" - @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -27,7 +25,6 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_LOCAL_ACCESS_TOKEN: "api_token", CONF_HOST: "192.168.1.42", - CONF_WEBHOOK_ID: WEBHOOK_ID, }, unique_id="0000-0000", ) @@ -62,8 +59,6 @@ def mock_tedee(request) -> Generator[MagicMock, None, None]: tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") tedee.parse_webhook_message.return_value = None - tedee.register_webhook.return_value = 1 - tedee.delete_webhooks.return_value = None locks_json = json.loads(load_fixture("locks.json", DOMAIN)) diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 68a61842fc3..bc5b73aa4a9 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Tedee config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from pytedee_async import ( TedeeClientException, @@ -10,12 +10,10 @@ import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import WEBHOOK_ID - from tests.common import MockConfigEntry FLOW_UNIQUE_ID = "112233445566778899" @@ -24,30 +22,25 @@ LOCAL_ACCESS_TOKEN = "api_token" async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: """Test config flow with one bridge.""" - with patch( - "homeassistant.components.tedee.config_flow.webhook_generate_id", - return_value=WEBHOOK_ID, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.62", - CONF_LOCAL_ACCESS_TOKEN: "token", - }, - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["data"] == { + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_HOST: "192.168.1.62", CONF_LOCAL_ACCESS_TOKEN: "token", - CONF_WEBHOOK_ID: WEBHOOK_ID, - } + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + } async def test_flow_already_configured( diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 05fb2c1d6eb..ca64c01a983 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -1,27 +1,15 @@ """Test initialization of tedee.""" -from http import HTTPStatus -from typing import Any from unittest.mock import MagicMock -from urllib.parse import urlparse -from pytedee_async.exception import ( - TedeeAuthException, - TedeeClientException, - TedeeWebhookException, -) +from pytedee_async.exception import TedeeAuthException, TedeeClientException import pytest from syrupy import SnapshotAssertion -from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import WEBHOOK_ID - from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator async def test_load_unload_config_entry( @@ -62,62 +50,6 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_cleanup_on_shutdown( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_tedee: MagicMock, -) -> None: - """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - mock_tedee.delete_webhook.assert_called_once() - - -async def test_webhook_cleanup_errors( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_tedee: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the webhook is cleaned up on shutdown.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - mock_tedee.delete_webhook.side_effect = TedeeWebhookException("") - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - mock_tedee.delete_webhook.assert_called_once() - assert "Failed to unregister Tedee webhook from bridge" in caplog.text - - -async def test_webhook_registration_errors( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_tedee: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the webhook is cleaned up on shutdown.""" - mock_tedee.register_webhook.side_effect = TedeeWebhookException("") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - - mock_tedee.register_webhook.assert_called_once() - assert "Failed to register Tedee webhook from bridge" in caplog.text - - async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -135,37 +67,3 @@ async def test_bridge_device( ) assert device assert device == snapshot - - -@pytest.mark.parametrize( - ("body", "expected_code", "side_effect"), - [ - ({"hello": "world"}, HTTPStatus.OK, None), # Success - (None, HTTPStatus.BAD_REQUEST, None), # Missing data - ({}, HTTPStatus.BAD_REQUEST, TedeeWebhookException), # Error - ], -) -async def test_webhook_post( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_tedee: MagicMock, - hass_client_no_auth: ClientSessionGenerator, - body: dict[str, Any], - expected_code: HTTPStatus, - side_effect: Exception, -) -> None: - """Test webhook callback.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - client = await hass_client_no_auth() - webhook_url = async_generate_url(hass, WEBHOOK_ID) - mock_tedee.parse_webhook_message.side_effect = side_effect - resp = await client.post(urlparse(webhook_url).path, json=body) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - assert resp.status == expected_code diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 2f8b1e2b36d..fca1ae2b07f 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -1,10 +1,9 @@ """Tests for tedee lock.""" from datetime import timedelta from unittest.mock import MagicMock -from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock, TedeeLockState +from pytedee_async import TedeeLock from pytedee_async.exception import ( TedeeClientException, TedeeDataUpdateException, @@ -18,21 +17,15 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, STATE_LOCKING, - STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.components.webhook import async_generate_url from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import WEBHOOK_ID - from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("init_integration") @@ -273,28 +266,3 @@ async def test_new_lock( assert state state = hass.states.get("lock.lock_6g7h") assert state - - -async def test_webhook_update( - hass: HomeAssistant, - mock_tedee: MagicMock, - hass_client_no_auth: ClientSessionGenerator, -) -> None: - """Test updated data set through webhook.""" - - state = hass.states.get("lock.lock_1a2b") - assert state - assert state.state == STATE_UNLOCKED - - webhook_data = {"dummystate": 6} - mock_tedee.locks_dict[ - 12345 - ].state = TedeeLockState.LOCKED # is updated in the lib, so mock and assert in L296 - client = await hass_client_no_auth() - webhook_url = async_generate_url(hass, WEBHOOK_ID) - await client.post(urlparse(webhook_url).path, json=webhook_data) - mock_tedee.parse_webhook_message.assert_called_once_with(webhook_data) - - state = hass.states.get("lock.lock_1a2b") - assert state - assert state.state == STATE_LOCKED From 5f014f42ac93f7bf8cd20525201f1b60a42b0d49 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 4 Feb 2024 13:26:51 +0100 Subject: [PATCH 1403/1544] Avoid duplicate entity names in proximity (#109413) * avoid duplicate config entry title * consecutive range 2..10 * use existing logic --- .../components/proximity/config_flow.py | 17 +++-- .../components/proximity/test_config_flow.py | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 231a50c6c00..f3306bebf39 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, ) +from homeassistant.util import slugify from .const import ( CONF_IGNORED_ZONES, @@ -89,11 +90,19 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match(user_input) - zone = self.hass.states.get(user_input[CONF_ZONE]) + title = cast(State, self.hass.states.get(user_input[CONF_ZONE])).name - return self.async_create_entry( - title=cast(State, zone).name, data=user_input - ) + slugified_existing_entry_titles = [ + slugify(e.title) for e in self._async_current_entries() + ] + + possible_title = title + tries = 1 + while slugify(possible_title) in slugified_existing_entry_titles: + tries += 1 + possible_title = f"{title} {tries}" + + return self.async_create_entry(title=possible_title, data=user_input) return self.async_show_form( step_id="user", diff --git a/tests/components/proximity/test_config_flow.py b/tests/components/proximity/test_config_flow.py index 92b924be1ce..3c94e941227 100644 --- a/tests/components/proximity/test_config_flow.py +++ b/tests/components/proximity/test_config_flow.py @@ -185,3 +185,67 @@ async def test_abort_duplicated_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" await hass.async_block_till_done() + + +async def test_avoid_duplicated_title(hass: HomeAssistant) -> None: + """Test if we avoid duplicate titles.""" + MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ).add_to_hass(hass) + + MockConfigEntry( + domain=DOMAIN, + title="home 3", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ).add_to_hass(hass) + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test3"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 10, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "home 2" + + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test4"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 10, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "home 4" + + await hass.async_block_till_done() From 3e2f97d105f9cfacdc276d13e474f2097c05785b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 18:47:05 -0500 Subject: [PATCH 1404/1544] Add ClimateEntityFeatures to airtouch4 (#109421) * Add ClimateEntityFeatures to airtouch4 * adapt --- homeassistant/components/airtouch4/climate.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index bd1c481ce65..89afddad76e 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): _attr_name = None _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" @@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" From 38288dd68e2b282a298d06eeee0aa13e962d0172 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:23:06 -0500 Subject: [PATCH 1405/1544] Add new climate feature flags for airtouch5 (#109422) * Add new climate feature flags for airtouch5 * adapt --- homeassistant/components/airtouch5/climate.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index 829915ce6d1..ee92f68c0ed 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -120,15 +120,12 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False class Airtouch5AC(Airtouch5ClimateEntity): """Representation of the AC unit. Used to control the overall HVAC Mode.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None: """Initialise the Climate Entity.""" super().__init__(client) @@ -152,6 +149,14 @@ class Airtouch5AC(Airtouch5ClimateEntity): if ability.supports_mode_heat: self._attr_hvac_modes.append(HVACMode.HEAT) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + self._attr_fan_modes = [] if ability.supports_fan_speed_quiet: self._attr_fan_modes.append(FAN_DIFFUSE) @@ -262,7 +267,10 @@ class Airtouch5Zone(Airtouch5ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) def __init__( From f2de666c547708af430043faf38be2daef97d690 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 08:57:26 -0500 Subject: [PATCH 1406/1544] Add new climate feature flags to esphome (#109428) --- homeassistant/components/esphome/climate.py | 3 + .../esphome/snapshots/test_climate.ambr | 38 +++++++++++++ tests/components/esphome/test_climate.py | 57 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 tests/components/esphome/snapshots/test_climate.ambr diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 5c265068216..9c2177800f3 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -137,6 +137,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" + _enable_turn_on_off_backwards_compatibility = False @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -179,6 +180,8 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.FAN_MODE if self.swing_modes: features |= ClimateEntityFeature.SWING_MODE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON self._attr_supported_features = features def _get_precision(self) -> float: diff --git a/tests/components/esphome/snapshots/test_climate.ambr b/tests/components/esphome/snapshots/test_climate.ambr new file mode 100644 index 00000000000..69d721ecb94 --- /dev/null +++ b/tests/components/esphome/snapshots/test_climate.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_climate_entity_attributes[climate-entity-attributes] + ReadOnlyDict({ + 'current_temperature': 30, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'high', + 'fan1', + 'fan2', + ]), + 'friendly_name': 'Test my climate', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_mode': 'none', + 'preset_modes': list([ + 'away', + 'activity', + 'preset1', + 'preset2', + ]), + 'supported_features': , + 'swing_mode': 'both', + 'swing_modes': list([ + 'both', + 'off', + ]), + 'target_temp_step': 2, + 'temperature': 20, + }) +# --- diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index cb9a084d094..dbdee826137 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -14,6 +14,7 @@ from aioesphomeapi import ( ClimateState, ClimateSwingMode, ) +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -432,3 +433,59 @@ async def test_climate_entity_with_inf_value( assert attributes[ATTR_MIN_HUMIDITY] == 10 assert ATTR_TEMPERATURE not in attributes assert attributes[ATTR_CURRENT_TEMPERATURE] is None + + +async def test_climate_entity_attributes( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, + snapshot: SnapshotAssertion, +) -> None: + """Test a climate entity sets correct attributes.""" + entity_info = [ + ClimateInfo( + object_id="myclimate", + key=1, + name="my climate", + unique_id="my_climate", + supports_current_temperature=True, + visual_target_temperature_step=2, + visual_current_temperature_step=2, + supports_action=True, + visual_min_temperature=10.0, + visual_max_temperature=30.0, + supported_fan_modes=[ClimateFanMode.LOW, ClimateFanMode.HIGH], + supported_modes=[ + ClimateMode.COOL, + ClimateMode.HEAT, + ClimateMode.AUTO, + ClimateMode.OFF, + ], + supported_presets=[ClimatePreset.AWAY, ClimatePreset.ACTIVITY], + supported_custom_presets=["preset1", "preset2"], + supported_custom_fan_modes=["fan1", "fan2"], + supported_swing_modes=[ClimateSwingMode.BOTH, ClimateSwingMode.OFF], + ) + ] + states = [ + ClimateState( + key=1, + mode=ClimateMode.COOL, + action=ClimateAction.COOLING, + current_temperature=30, + target_temperature=20, + fan_mode=ClimateFanMode.AUTO, + swing_mode=ClimateSwingMode.BOTH, + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("climate.test_myclimate") + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes == snapshot(name="climate-entity-attributes") From af07ac120ea197db05527c9f86974cebae8dea2b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 05:40:37 -0500 Subject: [PATCH 1407/1544] Add new climate feature flags to tuya (#109434) --- homeassistant/components/tuya/climate.py | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 9f20df98370..45adb532705 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -127,6 +127,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -277,6 +278,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): self._attr_swing_modes.append(SWING_VERTICAL) + if DPCode.SWITCH in self.device.function: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() @@ -476,23 +482,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def turn_on(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": True}]) - return - - # Fake turn on - for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL): - if mode not in self.hvac_modes: - continue - self.set_hvac_mode(mode) - break + self._send_command([{"code": DPCode.SWITCH, "value": True}]) def turn_off(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": False}]) - return - - # Fake turn off - if HVACMode.OFF in self.hvac_modes: - self.set_hvac_mode(HVACMode.OFF) + self._send_command([{"code": DPCode.SWITCH, "value": False}]) From ac2e05b5c037dbd09bcef3f1008464261648c9f9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 05:39:04 -0500 Subject: [PATCH 1408/1544] Add climate feature flags to spider (#109456) --- homeassistant/components/spider/climate.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 1498c4b0039..15ba19e9b3a 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -43,6 +43,7 @@ class SpiderThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, thermostat): """Initialize the thermostat.""" @@ -53,6 +54,13 @@ class SpiderThermostat(ClimateEntity): for operation_value in thermostat.operation_values: if operation_value in SPIDER_STATE_TO_HA: self.support_hvac.append(SPIDER_STATE_TO_HA[operation_value]) + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if thermostat.has_fan_mode: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE @property def device_info(self) -> DeviceInfo: @@ -65,15 +73,6 @@ class SpiderThermostat(ClimateEntity): name=self.thermostat.name, ) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.thermostat.has_fan_mode: - return ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - return ClimateEntityFeature.TARGET_TEMPERATURE - @property def unique_id(self): """Return the id of the thermostat, if any.""" From 97446a5af3c7914d4ab40dddc3286f167f333c56 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 08:59:34 -0500 Subject: [PATCH 1409/1544] Add migrated climate feature flag to switchbee (#109458) --- homeassistant/components/switchbee/climate.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 8dd740262f9..1fc5cfcba12 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -1,4 +1,5 @@ """Support for SwitchBee climate.""" + from __future__ import annotations from typing import Any @@ -87,11 +88,9 @@ async def async_setup_entry( class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], ClimateEntity): """Representation of a SwitchBee climate.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) _attr_fan_modes = SUPPORTED_FAN_MODES _attr_target_temperature_step = 1 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -106,6 +105,13 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_attrs_from_coordinator() @callback From e7203d6015eecc823ef20490dc61cf3cfc623f01 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 09:00:31 -0500 Subject: [PATCH 1410/1544] Add new climate feature flags to switcher_kis (#109459) --- homeassistant/components/switcher_kis/climate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 272d3ccf6ef..01c4814f985 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -86,6 +86,7 @@ class SwitcherClimateEntity( _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote @@ -118,6 +119,10 @@ class SwitcherClimateEntity( if features["swing"] and not remote.separated_swing_command: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + # There is always support for off + minimum one other mode so no need to check + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_data(True) @callback From 855edba3a2258331e52c0c51ca33d2e413ba2b47 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 05:37:52 -0500 Subject: [PATCH 1411/1544] Add new climate feature flags for plugwise (#109464) --- homeassistant/components/plugwise/climate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 84e0619773b..8e4dccb9e05 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -45,6 +45,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False _previous_mode: str = "heating" @@ -62,6 +63,11 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self.gateway_data = coordinator.data.devices[gateway_id] # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + if HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if ( self.cdr_gateway["cooling_present"] and self.cdr_gateway["smile_name"] != "Adam" From 500b0a9b52bad0aaaaeb9fcbe13a856c86526e24 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 4 Feb 2024 15:01:06 +0100 Subject: [PATCH 1412/1544] Correct flow rate conversion review after merge (#109501) --- homeassistant/components/sensor/recorder.py | 9 ++++++++- tests/components/number/test_init.py | 4 ++-- tests/components/sensor/test_recorder.py | 13 +++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 1aba934aba4..9a0ecbeb9a5 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -36,7 +36,13 @@ from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass +from .const import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + DOMAIN, + SensorStateClass, + UnitOfVolumeFlowRate, +) _LOGGER = logging.getLogger(__name__) @@ -52,6 +58,7 @@ EQUIVALENT_UNITS = { "RPM": REVOLUTIONS_PER_MINUTE, "ft3": UnitOfVolume.CUBIC_FEET, "m3": UnitOfVolume.CUBIC_METERS, + "ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, } # Keep track of entities for which a warning about decreasing value has been logged diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 9c66b45df25..279ffbfbbaa 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -688,7 +688,7 @@ async def test_restore_number_restore_state( 38.0, ), ( - SensorDeviceClass.VOLUME_FLOW_RATE, + NumberDeviceClass.VOLUME_FLOW_RATE, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, @@ -696,7 +696,7 @@ async def test_restore_number_restore_state( "13.2", ), ( - SensorDeviceClass.VOLUME_FLOW_RATE, + NumberDeviceClass.VOLUME_FLOW_RATE, UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, UnitOfVolumeFlowRate.LITERS_PER_MINUTE, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 34aaeda6740..2dcc873ca8b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2422,6 +2422,7 @@ def test_list_statistic_ids_unsupported( (None, "kW", "Wh", "power", 13.050847, -10, 30), # Can't downgrade from ft³ to ft3 or from m³ to m3 (None, "ft³", "ft3", "volume", 13.050847, -10, 30), + (None, "ft³/min", "ft³/m", "volume_flow_rate", 13.050847, -10, 30), (None, "m³", "m3", "volume", 13.050847, -10, 30), ], ) @@ -2887,6 +2888,17 @@ def test_compile_hourly_statistics_convert_units_1( (None, "RPM", "rpm", None, None, 13.050847, 13.333333, -10, 30), (None, "rpm", "RPM", None, None, 13.050847, 13.333333, -10, 30), (None, "ft3", "ft³", None, "volume", 13.050847, 13.333333, -10, 30), + ( + None, + "ft³/m", + "ft³/min", + None, + "volume_flow_rate", + 13.050847, + 13.333333, + -10, + 30, + ), (None, "m3", "m³", None, "volume", 13.050847, 13.333333, -10, 30), ], ) @@ -3010,6 +3022,7 @@ def test_compile_hourly_statistics_equivalent_units_1( (None, "RPM", "rpm", None, 13.333333, -10, 30), (None, "rpm", "RPM", None, 13.333333, -10, 30), (None, "ft3", "ft³", None, 13.333333, -10, 30), + (None, "ft³/m", "ft³/min", None, 13.333333, -10, 30), (None, "m3", "m³", None, 13.333333, -10, 30), ], ) From 856780ed30fdc3ed64c7c4c2ee79faf406f94284 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 3 Feb 2024 20:20:17 +0100 Subject: [PATCH 1413/1544] Bump easyenergy lib to v2.1.1 (#109510) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6f57ea6ed5f..4dcce0fd705 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==2.1.0"] + "requirements": ["easyenergy==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9dbd9b441b..6fbd5fd0c2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -746,7 +746,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.1.0 +easyenergy==2.1.1 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2782bf09e16..a436b6379c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -612,7 +612,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==2.1.0 +easyenergy==2.1.1 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 From ceeef1eaccb256f8441f14ed6bda08175ce17d22 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 4 Feb 2024 11:30:30 +0100 Subject: [PATCH 1414/1544] Move climate feature flags to child classes for airzone_cloud (#109515) --- .../components/airzone_cloud/climate.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 73333d346c5..1bab9dd6c33 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -144,11 +144,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" _attr_name = None - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False @@ -180,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): class AirzoneDeviceClimate(AirzoneClimate): """Define an Airzone Cloud Device base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -217,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate): class AirzoneDeviceGroupClimate(AirzoneClimate): """Define an Airzone Cloud DeviceGroup base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { From d99ba75ed8269caf3c5b371988b5ab9ab04747ba Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 3 Feb 2024 18:30:00 -0500 Subject: [PATCH 1415/1544] Prevent Flo devices and entities from going unavailable when a single refresh fails (#109522) * Prevent Flo devices and entities from going unavailable when a single refresh fails * review comment --- homeassistant/components/flo/device.py | 7 ++++++- tests/components/flo/test_device.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 7aacb1b262a..3b7469686b4 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -18,6 +18,8 @@ from .const import DOMAIN as FLO_DOMAIN, LOGGER class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Flo device object.""" + _failure_count: int = 0 + def __init__( self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str ) -> None: @@ -43,8 +45,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable= await self.send_presence_ping() await self._update_device() await self._update_consumption_data() + self._failure_count = 0 except RequestError as error: - raise UpdateFailed(error) from error + self._failure_count += 1 + if self._failure_count > 3: + raise UpdateFailed(error) from error @property def location_id(self) -> str: diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 5d619f9e91f..6a633c774ed 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -93,4 +93,8 @@ async def test_device( "homeassistant.components.flo.device.FloDeviceDataUpdateCoordinator.send_presence_ping", side_effect=RequestError, ), pytest.raises(UpdateFailed): + # simulate 4 updates failing + await valve._async_update_data() + await valve._async_update_data() + await valve._async_update_data() await valve._async_update_data() From 1df5ad23efcbe6547edb8fdd5323b3b5c5f46a5b Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Sun, 4 Feb 2024 22:25:31 +1300 Subject: [PATCH 1416/1544] Fix empty error modal when adding duplicate Thread integration (#109530) --- homeassistant/components/thread/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/thread/strings.json b/homeassistant/components/thread/strings.json index 0a9cf0004bc..474999b06bd 100644 --- a/homeassistant/components/thread/strings.json +++ b/homeassistant/components/thread/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, "step": { "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" From f16c0bd559fef9fc0736bd076ef7525fee1a5487 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 19:18:25 -0500 Subject: [PATCH 1417/1544] Add new climate feature flags to ccm15 (#109534) Adds new climate feature flags to ccm15 --- homeassistant/components/ccm15/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 30896d12299..1f90f317fe0 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -64,8 +64,11 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator From 9ebf985010e7edad9bdf8086c4cefaef332f2942 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:28:10 -0500 Subject: [PATCH 1418/1544] Add new climate feature flags to comelit (#109535) Adds new climate feature flags to comelit --- homeassistant/components/comelit/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 2c2d31514a5..877afd1414e 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -91,11 +91,16 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = 30 _attr_min_temp = 5 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 482032cb8706ae637f2066fe6973dac0c643a369 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:22:43 -0500 Subject: [PATCH 1419/1544] Add migrated climate feature flags to coolmaster (#109536) Adds migrated climate feature flags to coolmaster --- homeassistant/components/coolmaster/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index de0a7029ac6..ecb604a14cc 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -54,6 +54,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" From 79846d5668e6074ebf29f50388987df8a9913da0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:23:23 -0500 Subject: [PATCH 1420/1544] Add migrated climate feature flags to daikin (#109537) Adds migrated climate feature flags to daikin --- homeassistant/components/daikin/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 047acd3cccf..c6bab19aa8a 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -128,6 +128,7 @@ class DaikinClimate(ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes: list[str] _attr_swing_modes: list[str] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api: DaikinApi) -> None: """Initialize the climate device.""" From a62c05b983d93dc9d1c6d1aae739ae07cb6a721b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:26:53 -0500 Subject: [PATCH 1421/1544] Add migrated climate feature flags to devolo home control (#109538) Adds migrated climate feature flags to devolo home control --- homeassistant/components/devolo_home_control/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index e27d5a315a5..9f17a653673 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -56,6 +56,7 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit _attr_precision = PRECISION_TENTHS _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str From 3a08e3bec691fbca5fb887a024b508f713649527 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:27:04 -0500 Subject: [PATCH 1422/1544] Add new climate feature flags to duotecno (#109539) Adds new climate feature flags to duotecno --- homeassistant/components/duotecno/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 22be40a812e..3df80721af4 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -47,12 +47,16 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _unit: SensUnit _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HVACMODE_REVERSE) _attr_preset_modes = list(PRESETMODES) _attr_translation_key = "duotecno" + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float | None: From c02e96c5c0a8fd23310c9f69a05221994aebce83 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:28:49 -0500 Subject: [PATCH 1423/1544] Add new climate feature flags to ecobee (#109540) Adds new climate feature flags to ecobee --- homeassistant/components/ecobee/climate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index e15a8e1d3d8..58a3cb09997 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -323,6 +323,7 @@ class Thermostat(ClimateEntity): _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True + _enable_turn_on_off_backwards_compatibility = False def __init__( self, data: EcobeeData, thermostat_index: int, thermostat: dict @@ -375,6 +376,10 @@ class Thermostat(ClimateEntity): supported = supported | ClimateEntityFeature.TARGET_HUMIDITY if self.has_aux_heat: supported = supported | ClimateEntityFeature.AUX_HEAT + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + supported = ( + supported | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) return supported @property From 8c0cd6bbabccf1f5bafbb64962dce5dc8e1648a4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:30:09 -0500 Subject: [PATCH 1424/1544] Add new climate feature flags to econet (#109541) Adds new climate feature flags to econet --- homeassistant/components/econet/climate.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index f5328da4776..ac812a07566 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -66,6 +66,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat): """Initialize.""" @@ -79,12 +80,13 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ha_mode = ECONET_STATE_TO_HA[mode] self._attr_hvac_modes.append(ha_mode) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self._econet.supports_humidifier: - return SUPPORT_FLAGS_THERMOSTAT | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS_THERMOSTAT + self._attr_supported_features |= SUPPORT_FLAGS_THERMOSTAT + if thermostat.supports_humidifier: + self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): From 14ad2e91f3e80ffff72aa2c0b60b1ffd4ac39a52 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:31:23 -0500 Subject: [PATCH 1425/1544] Add new climate feature flags to electrasmart (#109542) Adds new climate feature flags to electrasmart --- homeassistant/components/electrasmart/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 086a5288f77..9f6e7cbddf5 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -111,6 +111,7 @@ class ElectraClimateEntity(ClimateEntity): _attr_hvac_modes = ELECTRA_MODES _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" @@ -121,6 +122,8 @@ class ElectraClimateEntity(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) swing_modes: list = [] From 12e32fb7998a640ef8de8c63077831ce9c8b765b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 18:46:47 -0500 Subject: [PATCH 1426/1544] Adds new climate feature flags to elkm1 (#109543) --- homeassistant/components/elkm1/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index c1e6dc7b034..97b16b14954 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -79,6 +79,8 @@ class ElkThermostat(ElkEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_min_temp = 1 _attr_max_temp = 99 @@ -87,6 +89,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: From 439f1766a0b36a4ea7cac3fae9abe0762359b4a3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 21:29:29 -0500 Subject: [PATCH 1427/1544] Add new climate feature flags to ephember (#109544) Adds new climate feature flags to ephember --- homeassistant/components/ephember/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 3735b4d16c2..047b9234b82 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -83,6 +83,7 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ember, zone): """Initialize the thermostat.""" @@ -100,6 +101,9 @@ class EphEmberThermostat(ClimateEntity): if self._hot_water: self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): From 9cde864224a4ac3fd9a6eac22c8d9a7fb71267c0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:24:28 -0500 Subject: [PATCH 1428/1544] Add new climate feature flags to escea (#109545) Adds new climate feature flags to escea --- homeassistant/components/escea/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 71c8a403f8f..021cfd26764 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -82,10 +82,14 @@ class ControllerEntity(ClimateEntity): _attr_precision = PRECISION_WHOLE _attr_should_poll = False _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" From 38fcc88c574fb97d3c96d2035c55501dfe736c52 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:24:18 -0500 Subject: [PATCH 1429/1544] Add new climate feature flags to freedompro (#109546) Adds new climate feature flags to freedompro --- homeassistant/components/freedompro/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 7a4b0473600..3bb62cb23fb 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -64,10 +64,15 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_current_temperature = 0 _attr_target_temperature = 0 _attr_hvac_mode = HVACMode.OFF + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 46016004fa7339efd7d102fac71d9f02198a531b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:32:35 -0500 Subject: [PATCH 1430/1544] Add migrated climate feature flags to fritzbox (#109547) Adds migrated climate feature flags to fritzbox --- homeassistant/components/fritzbox/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index b76e0fda18a..8dc19c199a3 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -80,6 +80,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float: From 384070c15857ee1a05d3249974741cd830994fe1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:38:09 -0500 Subject: [PATCH 1431/1544] Add new climate feature flags to generic_thermostat (#109548) Adds new climate feature flags to generic_thermostat --- homeassistant/components/generic_thermostat/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 7bc6c63697c..3a964204b70 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -178,6 +178,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Representation of a Generic Thermostat device.""" _attr_should_poll = False + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -225,7 +226,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp = target_temp self._attr_temperature_unit = unit self._attr_unique_id = unique_id - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) if len(presets): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = [PRESET_NONE] + list(presets.keys()) From dafdcd369c6bc6f9d10cc018727004d658d82565 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:23:59 -0500 Subject: [PATCH 1432/1544] Add new climate feature flags to geniushub (#109549) Adds new climate feature flags to geniushub --- homeassistant/components/geniushub/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index bafda44501b..cb817c64930 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -50,8 +50,12 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): """Representation of a Genius Hub climate device.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, broker, zone) -> None: """Initialize the climate device.""" From 67362db547e6a9ca267f71dc91d74176032f35c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:26:44 -0500 Subject: [PATCH 1433/1544] Add new climate feature flags to gree (#109550) Adds new climate feature flags to gree --- homeassistant/components/gree/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 8d50cdf2aed..1d061c06901 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -113,6 +113,8 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] @@ -120,6 +122,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" From e1699b4d6503115cb7441ec371778bdb1205b429 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:29:56 -0500 Subject: [PATCH 1434/1544] Add new climate feature flags to heatmiser (#109551) Adds new climate feature flags to heatmiser --- homeassistant/components/heatmiser/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 24a0c88b45a..566a4696a73 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -76,7 +76,12 @@ class HeatmiserV3Thermostat(ClimateEntity): """Representation of a HeatmiserV3 thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, therm, device, uh1): """Initialize the thermostat.""" From bf4bc9d935d94a4e70516cc4a1ec21d451835022 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:27:30 -0500 Subject: [PATCH 1435/1544] Add new climate feature flags to hisense (#109552) Adds new climate feature flags to hisense --- homeassistant/components/hisense_aehw4a1/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index ca5ec694eab..0e3fa9981c1 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -144,6 +144,8 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes = FAN_MODES _attr_swing_modes = SWING_MODES @@ -152,6 +154,7 @@ class ClimateAehW4a1(ClimateEntity): _attr_target_temperature_step = 1 _previous_state: HVACMode | str | None = None _on: str | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Initialize the climate device.""" From 4d7abbf8c547abee51f98e800cc186a76cad91a0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:27:42 -0500 Subject: [PATCH 1436/1544] Add new climate feature flags to hive (#109553) Adds new climate feature flags to hive --- homeassistant/components/hive/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 99de8b99675..8085719d8c5 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -92,8 +92,12 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] _attr_preset_modes = [PRESET_BOOST, PRESET_NONE] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hive_session, hive_device): """Initialize the Climate device.""" From 75c0c7bda0515ae7f2da76beef54863ca64d19dc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:40:02 -0500 Subject: [PATCH 1437/1544] Add new climate feature flags to homematic (#109554) Adds new climate feature flags to homematic --- homeassistant/components/homematic/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index c1dead1835e..76d9dff4d46 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -56,9 +56,13 @@ class HMThermostat(HMDevice, ClimateEntity): """Representation of a Homematic thermostat.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: From 83c487a3192292daa56bfa8314084851521b1b41 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:28:45 -0500 Subject: [PATCH 1438/1544] Add migrated climate feature flags to homematicip_cloud (#109555) Adds migrated climate feature flags to homematicip_cloud --- homeassistant/components/homematicip_cloud/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 09d00e9bee1..63b78e91a2f 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -70,6 +70,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" From 32b25c7e538b5ccb81a665b648eeba2029f6dfec Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:41:07 -0500 Subject: [PATCH 1439/1544] Add new climate feature flags to honeywell (#109556) Adds new climate feature flags to honeywell --- homeassistant/components/honeywell/climate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 803ca1da1aa..efd06ba2905 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -143,6 +143,7 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_translation_key = "honeywell" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -187,6 +188,10 @@ class HoneywellUSThermostat(ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if device._data.get("canControlHumidification"): self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY From 122652b3967a600a81ad5e03f1ecdeab0a76a81f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:28:33 -0500 Subject: [PATCH 1440/1544] Add new climate feature flags to huum (#109557) Adds new climate feature flags to huum --- homeassistant/components/huum/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index dcf025082cc..2bc3c626deb 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -41,7 +41,11 @@ class HuumDevice(ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = 110 @@ -51,6 +55,7 @@ class HuumDevice(ClimateEntity): _target_temperature: int | None = None _status: HuumStatusResponse | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, huum_handler: Huum, unique_id: str) -> None: """Initialize the heater.""" From 064f412da44017590a3cbb8cfc0c881a0ceb3705 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:29:40 -0500 Subject: [PATCH 1441/1544] Add new climate feature flags to iaqualink (#109558) --- homeassistant/components/iaqualink/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index b7dbe43fca9..5a81ad3d681 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -42,7 +42,12 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): """Representation of a thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, dev: AqualinkThermostat) -> None: """Initialize AquaLink thermostat.""" From 42bf086c97f23307fde2b8f4fcb8a924bb6546a5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:41:53 -0500 Subject: [PATCH 1442/1544] Add migrated climate feature flags to incomfort (#109559) --- homeassistant/components/incomfort/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index cae73495438..0dba00ff416 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -42,6 +42,7 @@ class InComfortClimate(IncomfortChild, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, client, heater, room) -> None: """Initialize the climate device.""" From 30b9a28502bdf9fdd9a24fd67063072571bea42c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 04:42:32 -0500 Subject: [PATCH 1443/1544] Add new climate feature flags to insteon (#109560) --- homeassistant/components/insteon/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 74fb11491c0..22bd776e1c8 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -87,10 +87,13 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = list(HVAC_MODES.values()) _attr_fan_modes = list(FAN_MODES.values()) _attr_min_humidity = 1 + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: From 1000fae905de03c752154ece4f3469a0fe46b5b8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:28:15 -0500 Subject: [PATCH 1444/1544] Add new climate feature flags to intellifire (#109562) --- homeassistant/components/intellifire/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 5d305db8feb..9fed9c08bb6 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -49,10 +49,15 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = 0 _attr_max_temp = 37 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS last_temp = DEFAULT_THERMOSTAT_TEMP + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From cb02c2e6d07ae4e661ad3c1dc4f2bbecdfecdafc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 09:02:52 -0500 Subject: [PATCH 1445/1544] Fix new climate feature flags in intesishome (#109563) --- homeassistant/components/intesishome/climate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 285be2c9cea..64f52fae0a6 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -146,6 +146,7 @@ class IntesisAC(ClimateEntity): _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" @@ -175,10 +176,6 @@ class IntesisAC(ClimateEntity): self._power_consumption_heat = None self._power_consumption_cool = None - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) - # Setpoint support if controller.has_setpoint_control(ih_device_id): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE @@ -208,6 +205,11 @@ class IntesisAC(ClimateEntity): self._attr_hvac_modes.extend(mode_list) self._attr_hvac_modes.append(HVACMode.OFF) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Subscribe to event updates.""" _LOGGER.debug("Added climate device with state: %s", repr(self._ih_device)) From b3c257fb7912b516c5932894217079bbb503aa1f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:10:34 -0500 Subject: [PATCH 1446/1544] Add new climate feature flags to isy994 (#109564) --- homeassistant/components/isy994/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 3ac2fd18473..06b73978456 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -82,9 +82,12 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1.0 _attr_fan_modes = [FAN_AUTO, FAN_ON] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" From 25063821e1898ef49a7d0d88028655dfe026f7df Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:30:55 -0500 Subject: [PATCH 1447/1544] Add new climate feature flags to izone (#109565) --- homeassistant/components/izone/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 75eb19b2978..e85b7ef4d56 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -144,12 +144,17 @@ class ControllerDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" self._controller = controller - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, # otherwise the unit determines which zone to use as target. See interface manual p. 8 From 514ce59a8fca64612234af792f15fee8cb60ab84 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 09:03:14 -0500 Subject: [PATCH 1448/1544] Add new climate feature flags to lcn (#109566) --- homeassistant/components/lcn/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 4f40bcd25cd..d1e92d54fb1 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -69,7 +69,7 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -95,6 +95,11 @@ class LcnClimate(LcnEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.HEAT] if self.is_lockable: self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From d3aa7375f0eb71a837e0f5f4a08a653dd119eafe Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:30:08 -0500 Subject: [PATCH 1449/1544] Add new climate feature flags to lightwave (#109568) --- homeassistant/components/lightwave/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 60108aba024..5e89e4f8145 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -47,9 +47,14 @@ class LightwaveTrv(ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = DEFAULT_MIN_TEMP _attr_max_temp = DEFAULT_MAX_TEMP - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 0.5 _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, device_id, lwlink, serial): """Initialize LightwaveTrv entity.""" From 838b1338b80da871a22f3d2f5e4b9b04e82604a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:31:05 -0500 Subject: [PATCH 1450/1544] Add migrated climate feature flags to livisi (#109569) --- homeassistant/components/livisi/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 952363650d6..6990dabff1d 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -67,6 +67,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From f91f98e30987febb695daf32a162107392ab9c07 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 20:11:02 -0500 Subject: [PATCH 1451/1544] Add new climate feature flags to lookin (#109570) --- homeassistant/components/lookin/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index f09bedab201..1bee2d14295 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -97,6 +97,8 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes: list[str] = LOOKIN_FAN_MODE_IDX_TO_HASS _attr_swing_modes: list[str] = LOOKIN_SWING_MODE_IDX_TO_HASS @@ -104,6 +106,7 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): _attr_min_temp = MIN_TEMP _attr_max_temp = MAX_TEMP _attr_target_temperature_step = PRECISION_WHOLE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, From 40636f22734058e93ac201e17571c40ffc3e7bad Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 09:03:48 -0500 Subject: [PATCH 1452/1544] Add new climate feature flags to lyric (#109571) --- homeassistant/components/lyric/climate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 90d9e407cb2..ecf9b50474d 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -173,6 +173,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -231,6 +232,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self._attr_supported_features | ClimateEntityFeature.FAN_MODE ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + super().__init__( coordinator, location, From 7be6aa455e6d7f553c3236877a9d6b2a7c5d790d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 09:15:00 -0500 Subject: [PATCH 1453/1544] Add back logging for core for feature flags in climate (#109572) --- homeassistant/components/climate/__init__.py | 3 - tests/components/climate/test_init.py | 62 -------------------- 2 files changed, 65 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index bf663fac365..43d98ad6bbd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -339,9 +339,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _report_turn_on_off(feature: str, method: str) -> None: """Log warning not implemented turn on/off feature.""" - module = type(self).__module__ - if module and "custom_components" not in module: - return report_issue = self._suggest_report_issue() if feature.startswith("TURN"): message = ( diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index f764ad77aa9..0e4e70796f0 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -684,65 +684,3 @@ async def test_no_warning_integration_has_migrated( " implements HVACMode(s): off, heat and therefore implicitly supports the off, heat methods" not in caplog.text ) - - -async def test_no_warning_on_core_integrations_for_on_off_feature_flags( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None -) -> None: - """Test we don't warn on core integration on new turn_on/off feature flags.""" - - class MockClimateEntityTest(MockClimateEntity): - """Mock Climate device.""" - - def turn_on(self) -> None: - """Turn on.""" - - def turn_off(self) -> None: - """Turn off.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_setup_entry_climate_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - ) -> None: - """Set up test climate platform via config entry.""" - async_add_entities( - [MockClimateEntityTest(name="test", entity_id="climate.test")] - ) - - mock_integration( - hass, - MockModule( - "test", - async_setup_entry=async_setup_entry_init, - ), - built_in=False, - ) - mock_platform( - hass, - "test.climate", - MockPlatform(async_setup_entry=async_setup_entry_climate_platform), - ) - - with patch.object( - MockClimateEntityTest, "__module__", "homeassistant.components.test.climate" - ): - config_entry = MockConfigEntry(domain="test") - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.test") - assert state is not None - - assert ( - "does not set ClimateEntityFeature.TURN_OFF but implements the turn_off method." - not in caplog.text - ) From b14add591450a634c469efb8dc48aeec25a0bfa0 Mon Sep 17 00:00:00 2001 From: Matrix Date: Sun, 4 Feb 2024 15:27:57 +0800 Subject: [PATCH 1454/1544] Fix yolink abnormal status when LeakSensor detection mode changes to "no water detect" (#109575) Add no water detect support --- homeassistant/components/yolink/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 0650cc3a203..0762a3b5c60 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -63,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="leak_state", device_class=BinarySensorDeviceClass.MOISTURE, - value=lambda value: value == "alert" if value is not None else None, + value=lambda value: value in ("alert", "full") if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, ), YoLinkBinarySensorEntityDescription( From dfc26e45098aa512eb8f9e02eda9aa47538884bb Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 08:56:23 -0500 Subject: [PATCH 1455/1544] Fix group sensor uom's in not convertable device classes (#109580) --- homeassistant/components/group/sensor.py | 37 +++++++++- tests/components/group/test_sensor.py | 86 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 3c8f7059901..47695a275fc 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,5 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" + from __future__ import annotations from collections.abc import Callable @@ -13,6 +14,7 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, @@ -313,6 +315,7 @@ class SensorGroup(GroupEntity, SensorEntity): self._device_class = device_class self._native_unit_of_measurement = unit_of_measurement self._valid_units: set[str | None] = set() + self._can_convert: bool = False self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() @@ -352,10 +355,18 @@ class SensorGroup(GroupEntity, SensorEntity): self._valid_units and (uom := state.attributes["unit_of_measurement"]) in self._valid_units + and self._can_convert is True ): numeric_state = UNIT_CONVERTERS[self.device_class].convert( numeric_state, uom, self.native_unit_of_measurement ) + if ( + self._valid_units + and (uom := state.attributes["unit_of_measurement"]) + not in self._valid_units + ): + raise HomeAssistantError("Not a valid unit") + sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) @@ -536,8 +547,21 @@ class SensorGroup(GroupEntity, SensorEntity): unit_of_measurements.append(_unit_of_measurement) # Ensure only valid unit of measurements for the specific device class can be used - if (device_class := self.device_class) in UNIT_CONVERTERS and all( - x in UNIT_CONVERTERS[device_class].VALID_UNITS for x in unit_of_measurements + if ( + # Test if uom's in device class is convertible + (device_class := self.device_class) in UNIT_CONVERTERS + and all( + uom in UNIT_CONVERTERS[device_class].VALID_UNITS + for uom in unit_of_measurements + ) + ) or ( + # Test if uom's in device class is not convertible + device_class + and device_class not in UNIT_CONVERTERS + and device_class in DEVICE_CLASS_UNITS + and all( + uom in DEVICE_CLASS_UNITS[device_class] for uom in unit_of_measurements + ) ): async_delete_issue( self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class" @@ -546,6 +570,7 @@ class SensorGroup(GroupEntity, SensorEntity): self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class" ) return unit_of_measurements[0] + if device_class: async_create_issue( self.hass, @@ -587,5 +612,13 @@ class SensorGroup(GroupEntity, SensorEntity): if ( device_class := self.device_class ) in UNIT_CONVERTERS and self.native_unit_of_measurement: + self._can_convert = True return UNIT_CONVERTERS[device_class].VALID_UNITS + if ( + device_class + and (device_class) in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement + ): + valid_uoms: set = DEVICE_CLASS_UNITS[device_class] + return valid_uoms return set() diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index aa4901e689c..ec6905a500f 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -1,4 +1,5 @@ """The tests for the Group Sensor platform.""" + from __future__ import annotations from math import prod @@ -27,6 +28,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -557,6 +559,90 @@ async def test_sensor_calculated_result_fails_on_uom(hass: HomeAssistant) -> Non assert state.attributes.get("unit_of_measurement") == "kWh" +async def test_sensor_calculated_properties_not_convertible_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor calculating device_class, state_class and unit of measurement when device class not convertible.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_sum", + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_sum_sensor", + } + } + + entity_ids = config["sensor"]["entities"] + + hass.states.async_set( + entity_ids[0], + VALUES[0], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + hass.states.async_set( + entity_ids[1], + VALUES[1], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": PERCENTAGE, + }, + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == str(sum(VALUES)) + assert state.attributes.get("device_class") == "humidity" + assert state.attributes.get("state_class") == "measurement" + assert state.attributes.get("unit_of_measurement") == "%" + + assert ( + "Unable to use state. Only entities with correct unit of measurement is" + " supported when having a device class" + ) not in caplog.text + + hass.states.async_set( + entity_ids[2], + VALUES[2], + { + "device_class": SensorDeviceClass.HUMIDITY, + "state_class": SensorStateClass.MEASUREMENT, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sum") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("device_class") == "humidity" + assert state.attributes.get("state_class") == "measurement" + assert state.attributes.get("unit_of_measurement") == "%" + + assert ( + "Unable to use state. Only entities with correct unit of measurement is" + " supported when having a device class, entity sensor.test_3, value 15.3 with" + " device class humidity and unit of measurement None excluded from calculation" + " in sensor.test_sum" + ) in caplog.text + + async def test_last_sensor(hass: HomeAssistant) -> None: """Test the last sensor.""" config = { From 94e1eaa15d00d2f872f6d52a1b105214e3b9dbf4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 3 Feb 2024 21:52:33 -0500 Subject: [PATCH 1456/1544] Fix overkiz climate feature flags for valve heating (#109582) * Fix overkiz climate feature flags for valve heating * Update homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py --- .../climate_entities/valve_heating_temperature_interface.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index 79c360a5f93..7b7493a37bb 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -51,10 +51,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN From 0a25788822dea66e48cb2dfc6a79588030abdc5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Feb 2024 03:17:13 -0600 Subject: [PATCH 1457/1544] Bump yalexs-ble to 2.4.1 (#109585) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.4.0...v2.4.1 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index d0f2a27522d..97963b19378 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==1.10.0", "yalexs-ble==2.4.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index dcd7e57ce1f..c9ed4bc6a8f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.4.0"] + "requirements": ["yalexs-ble==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6fbd5fd0c2e..c16f2799fd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2880,7 +2880,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.4.0 +yalexs-ble==2.4.1 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a436b6379c0..6c938651ce3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2206,7 +2206,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.4.0 +yalexs-ble==2.4.1 # homeassistant.components.august yalexs==1.10.0 From 4fca06256bb657559a03d231a5dbb71d5ac926bb Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 4 Feb 2024 01:13:35 -0800 Subject: [PATCH 1458/1544] Fix Google generative AI service example (#109594) Update strings.json --- .../components/google_generative_ai_conversation/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 76e6135b14d..306072f33a8 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -35,7 +35,7 @@ "prompt": { "name": "Prompt", "description": "The prompt", - "example": "Describe what you see in these images:" + "example": "Describe what you see in these images" }, "image_filename": { "name": "Image filename", From ba0c065750d4c43fee386e1e9941e140cbfb0baf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 4 Feb 2024 11:29:32 +0100 Subject: [PATCH 1459/1544] Bugfix lamarzocco issue (#109596) --- homeassistant/components/lamarzocco/number.py | 2 +- homeassistant/components/lamarzocco/sensor.py | 2 ++ tests/components/lamarzocco/fixtures/current_status.json | 7 ++----- .../components/lamarzocco/snapshots/test_diagnostics.ambr | 5 +---- tests/components/lamarzocco/snapshots/test_sensor.ambr | 6 ++++++ 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 76632d4a5b8..bf866872f5b 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -79,7 +79,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( value=int(value) ), - native_value_fn=lambda lm: lm.current_status["dose_k5"], + native_value_fn=lambda lm: lm.current_status["dose_hot_water"], supported_fn=lambda coordinator: coordinator.lm.model_name in ( LaMarzoccoModel.GS3_AV, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c46b965850c..ea5a5e184e1 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -62,6 +62,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( key="current_temp_coffee", translation_key="current_temp_coffee", native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), @@ -70,6 +71,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( key="current_temp_steam", translation_key="current_temp_steam", native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, value_fn=lambda lm: lm.current_status.get("steam_temp", 0), diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json index 4f208607c17..f99c3d5c331 100644 --- a/tests/components/lamarzocco/fixtures/current_status.json +++ b/tests/components/lamarzocco/fixtures/current_status.json @@ -43,7 +43,7 @@ "dose_k2": 1023, "dose_k3": 1023, "dose_k4": 1023, - "dose_k5": 1023, + "dose_hot_water": 1023, "prebrewing_ton_k1": 3, "prebrewing_toff_k1": 5, "prebrewing_ton_k2": 3, @@ -52,11 +52,8 @@ "prebrewing_toff_k3": 5, "prebrewing_ton_k4": 3, "prebrewing_toff_k4": 5, - "prebrewing_ton_k5": 3, - "prebrewing_toff_k5": 5, "preinfusion_k1": 4, "preinfusion_k2": 4, "preinfusion_k3": 4, - "preinfusion_k4": 4, - "preinfusion_k5": 4 + "preinfusion_k4": 4 } diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index 2462d4a125d..ec44100fe1e 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -198,11 +198,11 @@ 'coffee_boiler_on': True, 'coffee_set_temp': 95, 'coffee_temp': 93, + 'dose_hot_water': 1023, 'dose_k1': 1023, 'dose_k2': 1023, 'dose_k3': 1023, 'dose_k4': 1023, - 'dose_k5': 1023, 'drinks_k1': 13, 'drinks_k2': 2, 'drinks_k3': 42, @@ -221,17 +221,14 @@ 'prebrewing_toff_k2': 5, 'prebrewing_toff_k3': 5, 'prebrewing_toff_k4': 5, - 'prebrewing_toff_k5': 5, 'prebrewing_ton_k1': 3, 'prebrewing_ton_k2': 3, 'prebrewing_ton_k3': 3, 'prebrewing_ton_k4': 3, - 'prebrewing_ton_k5': 3, 'preinfusion_k1': 4, 'preinfusion_k2': 4, 'preinfusion_k3': 4, 'preinfusion_k4': 4, - 'preinfusion_k5': 4, 'sat_auto': 'Disabled', 'sat_off_time': '00:00', 'sat_on_time': '00:00', diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 4228252f526..e0b04289f7c 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -20,6 +20,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -68,6 +71,9 @@ 'id': , 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, From 2dc630a4af3a29ca7c6f4d88e46f7f0aafa5bdd0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:12:08 +0100 Subject: [PATCH 1460/1544] Redact location names in proximity diagnostics (#109600) --- .../components/proximity/coordinator.py | 17 +++++------ .../components/proximity/diagnostics.py | 28 ++++++++++++++++--- .../proximity/snapshots/test_diagnostics.ambr | 22 +++++++++++++++ .../components/proximity/test_diagnostics.py | 11 ++++++++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 4ae923276cc..53c1180e832 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -56,14 +56,11 @@ class ProximityData: entities: dict[str, dict[str, str | int | None]] -DEFAULT_DATA = ProximityData( - { - ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, - ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, - ATTR_NEAREST: DEFAULT_NEAREST, - }, - {}, -) +DEFAULT_PROXIMITY_DATA: dict[str, str | float] = { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, +} class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): @@ -92,7 +89,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): update_interval=None, ) - self.data = DEFAULT_DATA + self.data = ProximityData(DEFAULT_PROXIMITY_DATA, {}) self.state_change_data: StateChangedData | None = None @@ -238,7 +235,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.name, self.proximity_zone_id, ) - return DEFAULT_DATA + return ProximityData(DEFAULT_PROXIMITY_DATA, {}) entities_data = self.data.entities diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index ba5e1f53722..3ccecbe1f19 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -4,10 +4,18 @@ from __future__ import annotations from typing import Any from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC -from homeassistant.components.diagnostics import async_redact_data +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 +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -21,6 +29,7 @@ TO_REDACT = { ATTR_MAC, ATTR_USER_ID, "context", + "location_name", } @@ -34,16 +43,27 @@ async def async_get_config_entry_diagnostics( "entry": entry.as_dict(), } + non_sensitiv_states = [ + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ] + [z.name for z in hass.states.async_all(ZONE_DOMAIN)] + tracked_states: dict[str, dict] = {} for tracked_entity_id in coordinator.tracked_entities: if (state := hass.states.get(tracked_entity_id)) is None: continue - tracked_states[tracked_entity_id] = state.as_dict() + tracked_states[tracked_entity_id] = async_redact_data( + state.as_dict(), TO_REDACT + ) + if state.state not in non_sensitiv_states: + tracked_states[tracked_entity_id]["state"] = REDACTED diag_data["data"] = { "proximity": coordinator.data.proximity, "entities": coordinator.data.entities, "entity_mapping": coordinator.entity_mapping, - "tracked_states": async_redact_data(tracked_states, TO_REDACT), + "tracked_states": tracked_states, } return diag_data diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index f8f7d9b014e..a93ff33f443 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -15,6 +15,12 @@ 'is_in_ignored_zone': False, 'name': 'test2', }), + 'device_tracker.test3': dict({ + 'dir_of_travel': None, + 'dist_to_zone': 4077309, + 'is_in_ignored_zone': False, + 'name': 'test3', + }), }), 'entity_mapping': dict({ 'device_tracker.test1': list([ @@ -29,6 +35,10 @@ 'sensor.home_test3_distance', 'sensor.home_test3_direction_of_travel', ]), + 'device_tracker.test4': list([ + 'sensor.home_test4_distance', + 'sensor.home_test4_direction_of_travel', + ]), }), 'proximity': dict({ 'dir_of_travel': 'unknown', @@ -56,6 +66,17 @@ 'entity_id': 'device_tracker.test2', 'state': 'not_home', }), + 'device_tracker.test3': dict({ + 'attributes': dict({ + 'friendly_name': 'test3', + 'latitude': '**REDACTED**', + 'location_name': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'context': '**REDACTED**', + 'entity_id': 'device_tracker.test3', + 'state': '**REDACTED**', + }), }), }), 'entry': dict({ @@ -67,6 +88,7 @@ 'device_tracker.test1', 'device_tracker.test2', 'device_tracker.test3', + 'device_tracker.test4', ]), 'zone': 'zone.home', }), diff --git a/tests/components/proximity/test_diagnostics.py b/tests/components/proximity/test_diagnostics.py index 35ecd152a06..e23d8180672 100644 --- a/tests/components/proximity/test_diagnostics.py +++ b/tests/components/proximity/test_diagnostics.py @@ -35,6 +35,16 @@ async def test_entry_diagnostics( "not_home", {"friendly_name": "test2", "latitude": 150.1, "longitude": 20.1}, ) + hass.states.async_set( + "device_tracker.test3", + "my secret address", + { + "friendly_name": "test3", + "latitude": 150.1, + "longitude": 20.1, + "location_name": "my secret address", + }, + ) mock_entry = MockConfigEntry( domain=DOMAIN, @@ -45,6 +55,7 @@ async def test_entry_diagnostics( "device_tracker.test1", "device_tracker.test2", "device_tracker.test3", + "device_tracker.test4", ], CONF_IGNORED_ZONES: [], CONF_TOLERANCE: 1, From d379a9aaaecc3445d92c04fdcc08a29c5b26ca42 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 15:59:46 +0100 Subject: [PATCH 1461/1544] Bump version to 2024.2.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 2cbb5565ef0..5517e4264b1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -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, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index d75d8efdc08..b56d0e3e217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b4" +version = "2024.2.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a7010e3e8010fa1eb75d951f0b5f6612c929d118 Mon Sep 17 00:00:00 2001 From: Cody C <50791984+codyc1515@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:26:05 +1300 Subject: [PATCH 1462/1544] Handle GeoJSON int to str conversion when the name is an int (#108937) Co-authored-by: Chris Roberts --- .../components/geo_json_events/geo_location.py | 3 ++- tests/components/geo_json_events/test_geo_location.py | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 8915962c4ff..134f6a0e943 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -105,7 +105,8 @@ class GeoJsonLocationEvent(GeolocationEvent): def _update_from_feed(self, feed_entry: GenericFeedEntry) -> None: """Update the internal state from the provided feed entry.""" if feed_entry.properties and "name" in feed_entry.properties: - self._attr_name = feed_entry.properties.get("name") + # The entry name's type can vary, but our own name must be a string + self._attr_name = str(feed_entry.properties["name"]) else: self._attr_name = feed_entry.title self._attr_distance = feed_entry.distance_to_home diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 3176a37ab74..2f3b12ed554 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -58,7 +58,9 @@ async def test_entity_lifecycle( (-31.0, 150.0), {ATTR_NAME: "Properties 1"}, ) - mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) + mock_entry_2 = _generate_mock_feed_entry( + "2345", "271310188", 20.5, (-31.1, 150.1), {ATTR_NAME: 271310188} + ) mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) @@ -89,14 +91,14 @@ async def test_entity_lifecycle( } assert round(abs(float(state.state) - 15.5), 7) == 0 - state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_2") + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.271310188") assert state is not None - assert state.name == "Title 2" + assert state.name == "271310188" assert state.attributes == { ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, - ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FRIENDLY_NAME: "271310188", ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } From fd2469e2a79b96792d8c84b169f81589bec5f089 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 4 Feb 2024 21:25:14 +0100 Subject: [PATCH 1463/1544] Fix imap message part decoding (#109523) --- homeassistant/components/imap/coordinator.py | 18 ++++++++++-------- tests/components/imap/test_init.py | 8 ++++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 5591980b2f1..49938eaaa0a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -9,7 +9,7 @@ from email.header import decode_header, make_header from email.message import Message from email.utils import parseaddr, parsedate_to_datetime import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException @@ -97,9 +97,8 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: class ImapMessage: """Class to parse an RFC822 email message.""" - def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None: + def __init__(self, raw_message: bytes) -> None: """Initialize IMAP message.""" - self._charset = charset self.email_message = email.message_from_bytes(raw_message) @property @@ -153,7 +152,7 @@ class ImapMessage: def text(self) -> str: """Get the message text from the email. - Will look for text/plain or use text/html if not found. + Will look for text/plain or use/ text/html if not found. """ message_text: str | None = None message_html: str | None = None @@ -166,8 +165,13 @@ class ImapMessage: Falls back to the raw content part if decoding fails. """ try: - return str(part.get_payload(decode=True).decode(self._charset)) + decoded_payload: Any = part.get_payload(decode=True) + if TYPE_CHECKING: + assert isinstance(decoded_payload, bytes) + content_charset = part.get_content_charset() or "utf-8" + return decoded_payload.decode(content_charset) except ValueError: + # return undecoded payload return str(part.get_payload()) part: Message @@ -237,9 +241,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Send a event for the last message if the last message was changed.""" response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": - message = ImapMessage( - response.lines[1], charset=self.config_entry.data[CONF_CHARSET] - ) + message = ImapMessage(response.lines[1]) # Set `initial` to `False` if the last message is triggered again initial: bool = True if (message_id := message.message_id) == self._last_message_id: diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index a00f9d9c25d..8a8ac88c8aa 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -8,6 +8,7 @@ from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response import pytest from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.const import CONF_CHARSET from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder from homeassistant.components.sensor.const import SensorStateClass from homeassistant.const import STATE_UNAVAILABLE @@ -131,13 +132,16 @@ async def test_entry_startup_fails( ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize("charset", ["utf-8", "us-ascii"], ids=["utf-8", "us-ascii"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool, charset: str ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config = MOCK_CONFIG.copy() + config[CONF_CHARSET] = charset + config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From bd78c44ac5c045d280aeec77bc5e945fb8219e54 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 18:37:16 +0100 Subject: [PATCH 1464/1544] Update orjson to 3.9.13 (#109614) --- 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 7746745da6b..4ff23275e8f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ janus==1.0.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.12 +orjson==3.9.13 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.2.0 diff --git a/pyproject.toml b/pyproject.toml index b56d0e3e217..d927ad21280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "cryptography==42.0.2", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==24.0.0", - "orjson==3.9.12", + "orjson==3.9.13", "packaging>=23.1", "pip>=21.3.1", "python-slugify==8.0.1", diff --git a/requirements.txt b/requirements.txt index 066855e718b..44c11281517 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ lru-dict==1.3.0 PyJWT==2.8.0 cryptography==42.0.2 pyOpenSSL==24.0.0 -orjson==3.9.12 +orjson==3.9.13 packaging>=23.1 pip>=21.3.1 python-slugify==8.0.1 From f766fbfb98eb7ae00d1cbf38ad7766767cc3165b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 18:58:09 +0100 Subject: [PATCH 1465/1544] Fix Tuya QR code expiry, use native QR selector (#109615) * Fix Tuya QR code expiry, use native QR selector * Adjust tests --- homeassistant/components/tuya/config_flow.py | 52 ++++++++++---------- homeassistant/components/tuya/manifest.json | 2 +- homeassistant/components/tuya/strings.json | 2 +- requirements_all.txt | 3 -- requirements_test_all.txt | 3 -- tests/components/tuya/test_config_flow.py | 5 +- 6 files changed, 28 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 3577a6d6b06..e0ac5375b00 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -2,15 +2,14 @@ from __future__ import annotations from collections.abc import Mapping -from io import BytesIO from typing import Any -import segno from tuya_sharing import LoginControl import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import ( CONF_ENDPOINT, @@ -33,7 +32,6 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): __user_code: str __qr_code: str - __qr_image: str __reauth_entry: ConfigEntry | None = None def __init__(self) -> None: @@ -82,9 +80,17 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="scan", - description_placeholders={ - TUYA_RESPONSE_QR_CODE: self.__qr_image, - }, + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), ) ret, info = await self.hass.async_add_executor_job( @@ -94,11 +100,23 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): self.__user_code, ) if not ret: + # Try to get a new QR code on failure + await self.__async_get_qr_code(self.__user_code) return self.async_show_form( step_id="scan", errors={"base": "login_error"}, + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), description_placeholders={ - TUYA_RESPONSE_QR_CODE: self.__qr_image, TUYA_RESPONSE_MSG: info.get(TUYA_RESPONSE_MSG, "Unknown error"), TUYA_RESPONSE_CODE: info.get(TUYA_RESPONSE_CODE, 0), }, @@ -189,24 +207,4 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): if success := response.get(TUYA_RESPONSE_SUCCESS, False): self.__user_code = user_code self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE] - self.__qr_image = _generate_qr_code(self.__qr_code) return success, response - - -def _generate_qr_code(data: str) -> str: - """Create an SVG QR code that can be scanned with the Smart Life app.""" - qr_code = segno.make(f"tuyaSmart--qrLogin?token={data}", error="h") - with BytesIO() as buffer: - qr_code.save( - buffer, - kind="svg", - border=5, - scale=5, - xmldecl=False, - svgns=False, - svgclass=None, - lineclass=None, - svgversion=2, - dark="#1abcf2", - ) - return str(buffer.getvalue().decode("ascii")) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 71e43c8d445..305a74160de 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-device-sharing-sdk==0.1.9", "segno==1.5.3"] + "requirements": ["tuya-device-sharing-sdk==0.1.9"] } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 6e4848d9cc0..693f799e6e9 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,7 +14,7 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login:\n\n {qrcode} \n\nContinue to the next step once you have completed this step in the app." + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { diff --git a/requirements_all.txt b/requirements_all.txt index c16f2799fd6..6ea902e5de6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2494,9 +2494,6 @@ scsgate==0.1.0 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.tuya -segno==1.5.3 - # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c938651ce3..c08ddb04053 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1901,9 +1901,6 @@ screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.tuya -segno==1.5.3 - # homeassistant.components.emulated_kasa # homeassistant.components.sense sense-energy==0.12.2 diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 66a5d1d226d..c38d8e5f8b5 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import ANY, MockConfigEntry +from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -37,7 +37,6 @@ async def test_user_flow( assert result2.get("type") == FlowResultType.FORM assert result2.get("step_id") == "scan" - assert result2.get("description_placeholders") == {"qrcode": ANY} result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,7 +156,6 @@ async def test_reauth_flow( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "scan" - assert result.get("description_placeholders") == {"qrcode": ANY} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -206,7 +204,6 @@ async def test_reauth_flow_migration( assert result2.get("type") == FlowResultType.FORM assert result2.get("step_id") == "scan" - assert result2.get("description_placeholders") == {"qrcode": ANY} result3 = await hass.config_entries.flow.async_configure( result["flow_id"], From c0efec4a8435118e7fd349ea5f8ebc3f4c8421b7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 15:15:17 -0500 Subject: [PATCH 1466/1544] Fix repairs for remove dates in Workday (#109626) --- .../components/workday/binary_sensor.py | 62 ++++++++----- homeassistant/components/workday/repairs.py | 6 +- homeassistant/components/workday/strings.json | 22 ++++- tests/components/workday/__init__.py | 11 +++ tests/components/workday/test_repairs.py | 89 ++++++++++++++++++- 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index bda3a576563..04a3a2544c1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -1,4 +1,5 @@ """Sensor to indicate whether the current day is a workday.""" + from __future__ import annotations from datetime import date, timedelta @@ -53,7 +54,7 @@ def validate_dates(holiday_list: list[str]) -> list[str]: continue _range: timedelta = d2 - d1 for i in range(_range.days + 1): - day = d1 + timedelta(days=i) + day: date = d1 + timedelta(days=i) calc_holidays.append(day.strftime("%Y-%m-%d")) continue calc_holidays.append(add_date) @@ -123,25 +124,46 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) - async_create_issue( - hass, - DOMAIN, - f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_named_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) + if dt_util.parse_date(remove_holiday): + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index 905434f76ac..1221514da42 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -125,9 +125,9 @@ class HolidayFixFlow(RepairsFlow): self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await self.async_step_named_holiday() + return await self.async_step_fix_remove_holiday() - async def async_step_named_holiday( + async def async_step_fix_remove_holiday( self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle the options step of a fix flow.""" @@ -168,7 +168,7 @@ class HolidayFixFlow(RepairsFlow): {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, ) return self.async_show_form( - step_id="named_holiday", + step_id="fix_remove_holiday", data_schema=new_schema, description_placeholders={ CONF_COUNTRY: self.country if self.country else "-", diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index bbb76676f96..0e618beaf82 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -137,7 +137,7 @@ "title": "Configured named holiday {remove_holidays} for {title} does not exist", "fix_flow": { "step": { - "named_holiday": { + "fix_remove_holiday": { "title": "[%key:component::workday::issues::bad_named_holiday::title%]", "description": "Remove named holiday `{remove_holidays}` as it can't be found in country {country}.", "data": { @@ -152,6 +152,26 @@ "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" } } + }, + "bad_date_holiday": { + "title": "Configured holiday date {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "fix_remove_holiday": { + "title": "[%key:component::workday::issues::bad_date_holiday::title%]", + "description": "Remove holiday date `{remove_holidays}` as it can't be found in country {country}.", + "data": { + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]" + }, + "data_description": { + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]" + } + } + }, + "error": { + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" + } + } } }, "entity": { diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index fb436a57e5c..a7e26765643 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -1,4 +1,5 @@ """Tests the Home Assistant workday binary sensor.""" + from __future__ import annotations from typing import Any @@ -181,6 +182,16 @@ TEST_CONFIG_REMOVE_NAMED = { "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], "language": "en_US", } +TEST_CONFIG_REMOVE_DATE = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2024-02-05", "2024-02-06"], + "language": "en_US", +} TEST_CONFIG_TOMORROW = { "name": DEFAULT_NAME, "country": "DE", diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index fc7bfeb1b0e..60a55e1a347 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -1,4 +1,5 @@ """Test repairs for unifiprotect.""" + from __future__ import annotations from http import HTTPStatus @@ -10,12 +11,13 @@ from homeassistant.components.repairs.websocket_api import ( from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import async_create_issue +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import ( TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_REMOVE_DATE, TEST_CONFIG_REMOVE_NAMED, init_integration, ) @@ -329,6 +331,7 @@ async def test_bad_named_holiday( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, ) -> None: """Test fixing bad province selecting none.""" assert await async_setup_component(hass, "repairs", {}) @@ -337,6 +340,11 @@ async def test_bad_named_holiday( state = hass.states.get("binary_sensor.workday_sensor") assert state + issues = issue_registry.issues.keys() + for issue in issues: + if issue[0] == DOMAIN: + assert issue[1].startswith("bad_named") + ws_client = await hass_ws_client(hass) client = await hass_client() @@ -365,7 +373,7 @@ async def test_bad_named_holiday( CONF_REMOVE_HOLIDAYS: "Not a Holiday", "title": entry.title, } - assert data["step_id"] == "named_holiday" + assert data["step_id"] == "fix_remove_holiday" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post( @@ -402,6 +410,81 @@ async def test_bad_named_holiday( assert not issue +async def test_bad_date_holiday( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_REMOVE_DATE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + issues = issue_registry.issues.keys() + for issue in issues: + if issue[0] == DOMAIN: + assert issue[1].startswith("bad_date") + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_date_holiday-1-2024_02_05": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post( + url, + json={"handler": DOMAIN, "issue_id": "bad_date_holiday-1-2024_02_05"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "US", + CONF_REMOVE_HOLIDAYS: "2024-02-05", + "title": entry.title, + } + assert data["step_id"] == "fix_remove_holiday" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"remove_holidays": ["2024-02-06"]}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_date_holiday-1-2024_02_05": + issue = i + assert not issue + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_date_holiday-1-2024_02_06": + issue = i + assert issue + + async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -428,7 +511,7 @@ async def test_other_fixable_issues( "severity": "error", "translation_key": "issue_1", } - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], From 7ca83a7648fc3f50254667aa9d4e03c3ce9bcd75 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 4 Feb 2024 14:11:28 -0500 Subject: [PATCH 1467/1544] Add debug logger for cpu temp in System Monitor (#109627) --- homeassistant/components/systemmonitor/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 293492b90e8..11d8fa9c062 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -1,4 +1,5 @@ """Utils for System Monitor.""" + import logging import os @@ -71,6 +72,7 @@ def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float temps = psutil.sensors_temperatures() entry: shwtemp + _LOGGER.debug("CPU Temperatures: %s", temps) for name, entries in temps.items(): for i, entry in enumerate(entries, start=1): # In case the label is empty (e.g. on Raspberry PI 4), From e2695ba88fc42b067462a8460e493edde1854f74 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 4 Feb 2024 22:57:11 +0100 Subject: [PATCH 1468/1544] Allow the helper integrations to omit icon translation field (#109648) --- script/hassfest/icons.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 2b28312284a..60dc2b79f56 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -77,9 +77,13 @@ def icon_schema(integration_type: str) -> vol.Schema: ) if integration_type in ("entity", "helper", "system"): + if integration_type != "entity": + field = vol.Optional("entity_component") + else: + field = vol.Required("entity_component") schema = schema.extend( { - vol.Required("entity_component"): vol.All( + field: vol.All( cv.schema_with_slug_keys( icon_schema_slug(vol.Required), slug_validator=vol.Any("_", cv.slug), From ce29b4a7e3d4483e9a70169d7caa1c6f17b05338 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 4 Feb 2024 23:20:46 +0100 Subject: [PATCH 1469/1544] Add icon translations to derivative (#109650) --- homeassistant/components/derivative/icons.json | 9 +++++++++ homeassistant/components/derivative/sensor.py | 6 ++---- 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/derivative/icons.json diff --git a/homeassistant/components/derivative/icons.json b/homeassistant/components/derivative/icons.json new file mode 100644 index 00000000000..d8f2a961c3a --- /dev/null +++ b/homeassistant/components/derivative/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "derivative": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 73d297d7541..cd912ceb24e 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -64,8 +64,6 @@ UNIT_TIME = { UnitOfTime.DAYS: 24 * 60 * 60, } -ICON = "mdi:chart-line" - DEFAULT_ROUND = 3 DEFAULT_TIME_WINDOW = 0 @@ -157,9 +155,9 @@ async def async_setup_platform( class DerivativeSensor(RestoreSensor, SensorEntity): - """Representation of an derivative sensor.""" + """Representation of a derivative sensor.""" - _attr_icon = ICON + _attr_translation_key = "derivative" _attr_should_poll = False def __init__( From d789e838797b76b8c9652bac3c29cb9ee34fa251 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 4 Feb 2024 23:20:14 +0100 Subject: [PATCH 1470/1544] Add icon translations to Counter (#109651) --- homeassistant/components/counter/icons.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 homeassistant/components/counter/icons.json diff --git a/homeassistant/components/counter/icons.json b/homeassistant/components/counter/icons.json new file mode 100644 index 00000000000..1e0ef54bbb7 --- /dev/null +++ b/homeassistant/components/counter/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "decrement": "mdi:numeric-negative-1", + "increment": "mdi:numeric-positive-1", + "reset": "mdi:refresh", + "set_value": "mdi:counter" + } +} From 02ebf1d7f8744897f4d4cffd62337693f927af4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 4 Feb 2024 23:23:10 +0100 Subject: [PATCH 1471/1544] Add icon translations to Random (#109652) --- homeassistant/components/random/binary_sensor.py | 2 ++ homeassistant/components/random/icons.json | 14 ++++++++++++++ homeassistant/components/random/sensor.py | 2 ++ 3 files changed, 18 insertions(+) create mode 100644 homeassistant/components/random/icons.json diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 0c5b4a8b0dd..a6d330e6151 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -54,6 +54,8 @@ async def async_setup_entry( class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" self._attr_name = config.get(CONF_NAME) diff --git a/homeassistant/components/random/icons.json b/homeassistant/components/random/icons.json new file mode 100644 index 00000000000..83d5ecd0688 --- /dev/null +++ b/homeassistant/components/random/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "binary_sensor": { + "random": { + "default": "mdi:dice-multiple" + } + }, + "sensor": { + "random": { + "default": "mdi:dice-multiple" + } + } + } +} diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index f1ca4290d83..8cc21e34ce9 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,6 +65,8 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" self._attr_name = config.get(CONF_NAME) From 5747f8ce9d0985c81d33754c1c1225a9edfaa21e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 22:56:22 +0100 Subject: [PATCH 1472/1544] Improve Tuya token/reauth handling (#109653) --- homeassistant/components/tuya/__init__.py | 32 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ea38c117af7..5a6874fb352 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -59,12 +59,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listener = DeviceListener(hass, manager) manager.add_device_listener(listener) + + # Get all devices from Tuya + try: + await hass.async_add_executor_job(manager.update_device_cache) + except Exception as exc: # pylint: disable=broad-except + # While in general, we should avoid catching broad exceptions, + # we have no other way of detecting this case. + if "sign invalid" in str(exc): + msg = "Authentication failed. Please re-authenticate" + raise ConfigEntryAuthFailed(msg) from exc + raise + + # Connection is successful, store the manager & listener hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( manager=manager, listener=listener ) - # Get devices & clean up device entities - await hass.async_add_executor_job(manager.update_device_cache) + # Cleanup device registry await cleanup_device_registry(hass, manager) # Register known device IDs @@ -102,11 +114,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if tuya.manager.mq is not None: tuya.manager.mq.stop() tuya.manager.remove_device_listener(tuya.listener) - await hass.async_add_executor_job(tuya.manager.unload) del hass.data[DOMAIN][entry.entry_id] return unload_ok +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry. + + This will revoke the credentials from Tuya. + """ + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + ) + await hass.async_add_executor_job(manager.unload) + + class DeviceListener(SharingDeviceListener): """Device Update Listener.""" From 3934524d4a0ddc7c57212a3a1a78b08a2939df54 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 4 Feb 2024 23:21:57 +0100 Subject: [PATCH 1473/1544] Add icon translations to Utility meter helper (#109656) --- homeassistant/components/utility_meter/const.py | 2 -- homeassistant/components/utility_meter/icons.json | 14 ++++++++++++++ homeassistant/components/utility_meter/select.py | 11 +++-------- homeassistant/components/utility_meter/sensor.py | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/utility_meter/icons.json diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 6e1cabac509..4f62925069d 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -1,8 +1,6 @@ """Constants for the utility meter component.""" DOMAIN = "utility_meter" -TARIFF_ICON = "mdi:clock-outline" - QUARTER_HOURLY = "quarter-hourly" HOURLY = "hourly" DAILY = "daily" diff --git a/homeassistant/components/utility_meter/icons.json b/homeassistant/components/utility_meter/icons.json new file mode 100644 index 00000000000..7260fbfbe96 --- /dev/null +++ b/homeassistant/components/utility_meter/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "sensor": { + "utility_meter": { + "default": "mdi:counter" + } + }, + "select": { + "tariff": { + "default": "mdi:clock-outline" + } + } + } +} diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 64b271d4200..86433ca77f8 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -13,13 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ( - CONF_METER, - CONF_SOURCE_SENSOR, - CONF_TARIFFS, - DATA_UTILITY, - TARIFF_ICON, -) +from .const import CONF_METER, CONF_SOURCE_SENSOR, CONF_TARIFFS, DATA_UTILITY _LOGGER = logging.getLogger(__name__) @@ -100,6 +94,8 @@ async def async_setup_platform( class TariffSelect(SelectEntity, RestoreEntity): """Representation of a Tariff selector.""" + _attr_translation_key = "tariff" + def __init__( self, name, @@ -113,7 +109,6 @@ class TariffSelect(SelectEntity, RestoreEntity): self._attr_device_info = device_info self._current_tariff: str | None = None self._tariffs = tariffs - self._attr_icon = TARIFF_ICON self._attr_should_poll = False @property diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ee0d5f85b3b..e9ad7a1ba30 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -362,7 +362,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" - _attr_icon = "mdi:counter" + _attr_translation_key = "utility_meter" _attr_should_poll = False def __init__( From 4d7c96205da1747730c3e2fff3f28abc81a38b00 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 23:21:18 +0100 Subject: [PATCH 1474/1544] Fix Tuya reauth_successful translation string (#109659) --- homeassistant/components/tuya/strings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 693f799e6e9..cfce12273a0 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -19,7 +19,9 @@ }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "login_error": "Login error ({code}): {msg}", + "login_error": "Login error ({code}): {msg}" + }, + "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, From 3a067d445d07bb01ba3558361113ff839a75bd6c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 4 Feb 2024 23:25:49 +0100 Subject: [PATCH 1475/1544] Bump version to 2024.2.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 5517e4264b1..ed681efee14 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -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, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index d927ad21280..67f410c717c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b5" +version = "2024.2.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bca9826e18bd2fb997df1a2bab20d6a7c7853c58 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:03:43 +0100 Subject: [PATCH 1476/1544] Don't create AsusWRT loadavg sensors when unavailable (#106790) --- homeassistant/components/asuswrt/bridge.py | 14 +++++++++++- tests/components/asuswrt/test_sensor.py | 25 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 53a0b5d06b5..cc06c225d22 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession from pyasuswrt import AsusWrtError, AsusWrtHttp +from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError from homeassistant.const import ( CONF_HOST, @@ -354,13 +355,14 @@ class AsusWrtHttpBridge(AsusWrtBridge): async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Return a dictionary of available sensors for this bridge.""" sensors_temperatures = await self._get_available_temperature_sensors() + sensors_loadavg = await self._get_loadavg_sensors_availability() sensors_types = { SENSORS_TYPE_BYTES: { KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, SENSORS_TYPE_LOAD_AVG: { - KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_SENSORS: sensors_loadavg, KEY_METHOD: self._get_load_avg, }, SENSORS_TYPE_RATES: { @@ -393,6 +395,16 @@ class AsusWrtHttpBridge(AsusWrtBridge): return [] return available_sensors + async def _get_loadavg_sensors_availability(self) -> list[str]: + """Check if load avg is available on the router.""" + try: + await self._api.async_get_loadavg() + except AsusWrtNotAvailableInfoError: + return [] + except AsusWrtError: + pass + return SENSORS_LOAD_AVG + @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index e3122f1dfef..0ee90b111f5 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta -from pyasuswrt.asuswrt import AsusWrtError +from pyasuswrt.exceptions import AsusWrtError, AsusWrtNotAvailableInfoError import pytest from homeassistant.components import device_tracker, sensor @@ -226,6 +226,29 @@ async def test_loadavg_sensors_http(hass: HomeAssistant, connect_http) -> None: await _test_loadavg_sensors(hass, CONFIG_DATA_HTTP) +async def test_loadavg_sensors_unaivalable_http( + hass: HomeAssistant, connect_http +) -> None: + """Test load average sensors no available using http.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_LOAD_AVG) + config_entry.add_to_hass(hass) + + connect_http.return_value.async_get_loadavg.side_effect = ( + AsusWrtNotAvailableInfoError + ) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + # assert load average sensors not available + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg1") + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg5") + assert not hass.states.get(f"{sensor_prefix}_sensor_load_avg15") + + async def test_temperature_sensors_http_fail( hass: HomeAssistant, connect_http_sens_fail ) -> None: From 5c1e4379a9046d57c303b36599f7c00524e0cfd4 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Mon, 5 Feb 2024 00:05:28 -0800 Subject: [PATCH 1477/1544] Screenlogic service refactor (#109041) --- .../components/screenlogic/__init__.py | 19 +- homeassistant/components/screenlogic/const.py | 2 + .../components/screenlogic/services.py | 175 +++++++++++------- .../components/screenlogic/services.yaml | 25 ++- .../components/screenlogic/strings.json | 29 ++- 5 files changed, 170 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 6d066f86072..56c686df6b4 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -9,13 +9,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info from .data import ENTITY_MIGRATIONS -from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .services import async_load_screenlogic_services from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,16 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Screenlogic.""" + + async_load_screenlogic_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" @@ -62,8 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - async_load_screenlogic_services(hass, entry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -77,8 +86,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.gateway.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) - async_unload_screenlogic_services(hass) - return unload_ok diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 3125f52989e..104736f300b 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -20,6 +20,8 @@ DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 +ATTR_CONFIG_ENTRY = "config_entry" + SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 2c8e786491c..116a66d97df 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -6,14 +6,19 @@ from screenlogicpy import ScreenLogicError from screenlogicpy.device_const.system import EQUIPMENT_FLAG import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + selector, +) from homeassistant.helpers.service import async_extract_config_entry_ids from .const import ( ATTR_COLOR_MODE, + ATTR_CONFIG_ENTRY, ATTR_RUNTIME, DOMAIN, MAX_RUNTIME, @@ -27,44 +32,103 @@ from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SET_COLOR_MODE_SCHEMA = cv.make_entity_service_schema( +BASE_SERVICE_SCHEMA = vol.Schema( { - vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), - }, + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ) -TURN_ON_SUPER_CHLOR_SCHEMA = cv.make_entity_service_schema( +SET_COLOR_MODE_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), + } + ), + cv.has_at_least_one_key(ATTR_CONFIG_ENTRY, *cv.ENTITY_SERVICE_FIELDS), +) + +TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( { - vol.Optional(ATTR_RUNTIME, default=24): vol.Clamp( - min=MIN_RUNTIME, max=MAX_RUNTIME + vol.Optional(ATTR_RUNTIME, default=24): vol.All( + vol.Coerce(int), vol.Clamp(min=MIN_RUNTIME, max=MAX_RUNTIME) ), } ) @callback -def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): +def async_load_screenlogic_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" async def extract_screenlogic_config_entry_ids(service_call: ServiceCall): if not ( - screenlogic_entry_ids := [ - entry_id - for entry_id in await async_extract_config_entry_ids(hass, service_call) - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - ] + screenlogic_entry_ids := await async_extract_config_entry_ids( + hass, service_call + ) ): - raise HomeAssistantError( - f"Failed to call service '{service_call.service}'. Config entry for" - " target not found" + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry for " + "target not found" ) return screenlogic_entry_ids + async def get_coordinators( + service_call: ServiceCall, + ) -> list[ScreenlogicDataUpdateCoordinator]: + entry_ids: set[str] + if entry_id := service_call.data.get(ATTR_CONFIG_ENTRY): + entry_ids = {entry_id} + else: + ir.async_create_issue( + hass, + DOMAIN, + "service_target_deprecation", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_target_deprecation", + ) + entry_ids = await extract_screenlogic_config_entry_ids(service_call) + + coordinators: list[ScreenlogicDataUpdateCoordinator] = [] + for entry_id in entry_ids: + config_entry: ConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if not config_entry: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not found" + ) + if not config_entry.domain == DOMAIN: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' is not a {DOMAIN} config" + ) + if not config_entry.state == ConfigEntryState.LOADED: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not loaded" + ) + coordinators.append(hass.data[DOMAIN][entry_id]) + + return coordinators + async def async_set_color_mode(service_call: ServiceCall) -> None: color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - for entry_id in await extract_screenlogic_config_entry_ids(service_call): - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): _LOGGER.debug( "Service %s called on %s with mode %s", SERVICE_SET_COLOR_MODE, @@ -83,13 +147,19 @@ def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): is_on: bool, runtime: int | None = None, ) -> None: - for entry_id in await extract_screenlogic_config_entry_ids(service_call): - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): + if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: + raise ServiceValidationError( + f"Equipment configuration for {coordinator.gateway.name} does not" + f" support {service_call.service}" + ) + rt_log = f" with runtime {runtime}" if runtime else "" _LOGGER.debug( - "Service %s called on %s with runtime %s", - SERVICE_START_SUPER_CHLORINATION, + "Service %s called on %s%s", + service_call.service, coordinator.gateway.name, - runtime, + rt_log, ) try: await coordinator.gateway.async_set_scg_config( @@ -107,43 +177,20 @@ def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): async def async_stop_super_chlor(service_call: ServiceCall) -> None: await async_set_super_chlor(service_call, False) - if not hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - hass.services.async_register( - DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA - ) + hass.services.async_register( + DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA + ) - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - equipment_flags = coordinator.gateway.equipment_flags + hass.services.async_register( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + async_start_super_chlor, + TURN_ON_SUPER_CHLOR_SCHEMA, + ) - if EQUIPMENT_FLAG.CHLORINATOR in equipment_flags: - if not hass.services.has_service(DOMAIN, SERVICE_START_SUPER_CHLORINATION): - hass.services.async_register( - DOMAIN, - SERVICE_START_SUPER_CHLORINATION, - async_start_super_chlor, - TURN_ON_SUPER_CHLOR_SCHEMA, - ) - - if not hass.services.has_service(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION): - hass.services.async_register( - DOMAIN, - SERVICE_STOP_SUPER_CHLORINATION, - async_stop_super_chlor, - ) - - -@callback -def async_unload_screenlogic_services(hass: HomeAssistant): - """Unload services for the ScreenLogic integration.""" - - if not hass.data[DOMAIN]: - _LOGGER.debug("Unloading all ScreenLogic services") - for service in hass.services.async_services_for_domain(DOMAIN): - hass.services.async_remove(DOMAIN, service) - elif not any( - EQUIPMENT_FLAG.CHLORINATOR in coordinator.gateway.equipment_flags - for coordinator in hass.data[DOMAIN].values() - ): - _LOGGER.debug("Unloading ScreenLogic chlorination services") - hass.services.async_remove(DOMAIN, SERVICE_START_SUPER_CHLORINATION) - hass.services.async_remove(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION) + hass.services.async_register( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + async_stop_super_chlor, + BASE_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index 7b51d1a21db..f05537640ca 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -1,9 +1,11 @@ # ScreenLogic Services set_color_mode: - target: - device: - integration: screenlogic fields: + config_entry: + required: false + selector: + config_entry: + integration: screenlogic color_mode: required: true selector: @@ -32,10 +34,12 @@ set_color_mode: - thumper - white start_super_chlorination: - target: - device: - integration: screenlogic fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic runtime: default: 24 selector: @@ -45,6 +49,9 @@ start_super_chlorination: unit_of_measurement: hours mode: slider stop_super_chlorination: - target: - device: - integration: screenlogic + fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index fcddbc1d415..755eeb4ffb2 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -41,6 +41,10 @@ "name": "Set Color Mode", "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "color_mode": { "name": "Color Mode", "description": "The ScreenLogic color mode to set." @@ -51,6 +55,10 @@ "name": "Start Super Chlorination", "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "runtime": { "name": "Run Time", "description": "Number of hours for super chlorination to run." @@ -59,7 +67,26 @@ }, "stop_super_chlorination": { "name": "Stop Super Chlorination", - "description": "Stops super chlorination." + "description": "Stops super chlorination.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + } + } + } + }, + "issues": { + "service_target_deprecation": { + "title": "Deprecating use of target for ScreenLogic services", + "fix_flow": { + "step": { + "confirm": { + "title": "Deprecating target for ScreenLogic services", + "description": "Use of an Area, Device, or Entity as a target for ScreenLogic services is being deprecated. Instead, use `config_entry` with the entry_id of the desired ScreenLogic integration.\n\nPlease update your automations and scripts and select **submit** to fix this issue." + } + } + } } } } From 66d8856033fd5fdef186a8f88855a60e436c880c Mon Sep 17 00:00:00 2001 From: Leah Oswald Date: Sun, 4 Feb 2024 23:56:12 +0100 Subject: [PATCH 1478/1544] Fix home connect remaining progress time (#109525) * fix remaining progress time for home connect component The home connect API is sending some default values (on dishwashers) for the remaining progress time after the program finished. This is a problem because this value is stored and on every API event, for example opening the door of a dishwasher, the value for remaining progress time is updated with this wrong value. So I see a wrong value the whole time the dishwasher is not running and therefore has no remaining progress time. This coming fixes this problem and adds a check if the appliance is in running, pause or finished state, because there we have valid data. In the other states the new code just returns none like on other edge cases. Now there is no value if there is no program running. * fix some formating according to the ruff rules * fix some formating according to the ruff rules again * fix alphabetic order of imports * add check if keys exist in dict before accessing them check if BSH_OPERATION_STATE and ATTR_VALUE key values exist before accessing them later in the elif statement * fix formating because forgotten local ruff run --- .../components/home_connect/const.py | 6 ++++- .../components/home_connect/sensor.py | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 9eabc9b5d43..5b0a9e3e9d8 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,10 +10,14 @@ BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" +BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" +BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" +BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" +BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" + COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 07edfb4bd4b..a01cae5862a 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -10,7 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ATTR_VALUE, BSH_OPERATION_STATE, DOMAIN +from .const import ( + ATTR_VALUE, + BSH_OPERATION_STATE, + BSH_OPERATION_STATE_FINISHED, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_RUN, + DOMAIN, +) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) @@ -69,9 +76,20 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - else: + elif ( + BSH_OPERATION_STATE in status + and ATTR_VALUE in status[BSH_OPERATION_STATE] + and status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ): seconds = self._sign * float(status[self._key][ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) + else: + self._attr_native_value = None else: self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: From 8ab1c044bd0fa95ba1787ac7fef6c864f6341e52 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 5 Feb 2024 00:42:07 +0100 Subject: [PATCH 1479/1544] Add zone related sensors in proximity (#109630) * move legacy needed convertions into legacy entity * add zone related sensors * fix test coverage * fix typing * fix entity name translations * rename placeholder to tracked_entity --- .../components/proximity/__init__.py | 19 ++- homeassistant/components/proximity/const.py | 2 + .../components/proximity/coordinator.py | 19 ++- homeassistant/components/proximity/sensor.py | 39 ++++-- .../components/proximity/strings.json | 15 ++- .../proximity/snapshots/test_diagnostics.ambr | 4 +- tests/components/proximity/test_init.py | 126 ++++++++++++++++++ 7 files changed, 193 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 3f28028d703..349658223f3 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -11,6 +12,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant @@ -203,16 +205,21 @@ class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def state(self) -> str | int | float: + def data(self) -> dict[str, str | int | None]: + """Get data from coordinator.""" + return self.coordinator.data.proximity + + @property + def state(self) -> str | float: """Return the state.""" - return self.coordinator.data.proximity[ATTR_DIST_TO] + if isinstance(distance := self.data[ATTR_DIST_TO], str): + return distance + return self.coordinator.convert_legacy(cast(int, distance)) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - ATTR_DIR_OF_TRAVEL: str( - self.coordinator.data.proximity[ATTR_DIR_OF_TRAVEL] - ), - ATTR_NEAREST: str(self.coordinator.data.proximity[ATTR_NEAREST]), + ATTR_DIR_OF_TRAVEL: str(self.data[ATTR_DIR_OF_TRAVEL] or STATE_UNKNOWN), + ATTR_NEAREST: str(self.data[ATTR_NEAREST]), } diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index 7627d550e1f..e5b384b2f70 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -9,6 +9,8 @@ ATTR_DIST_TO: Final = "dist_to_zone" ATTR_ENTITIES_DATA: Final = "entities_data" ATTR_IN_IGNORED_ZONE: Final = "is_in_ignored_zone" ATTR_NEAREST: Final = "nearest" +ATTR_NEAREST_DIR_OF_TRAVEL: Final = "nearest_dir_of_travel" +ATTR_NEAREST_DIST_TO: Final = "nearest_dist_to_zone" ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 53c1180e832..047ab1b6b3a 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -3,6 +3,7 @@ from collections import defaultdict from dataclasses import dataclass import logging +from typing import cast from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -52,11 +53,11 @@ class StateChangedData: class ProximityData: """ProximityCoordinatorData class.""" - proximity: dict[str, str | float] + proximity: dict[str, str | int | None] entities: dict[str, dict[str, str | int | None]] -DEFAULT_PROXIMITY_DATA: dict[str, str | float] = { +DEFAULT_PROXIMITY_DATA: dict[str, str | int | None] = { ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, ATTR_NEAREST: DEFAULT_NEAREST, @@ -130,7 +131,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): }, ) - def _convert(self, value: float | str) -> float | str: + def convert_legacy(self, value: float | str) -> float | str: """Round and convert given distance value.""" if isinstance(value, str): return value @@ -303,7 +304,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # takeover data for legacy proximity entity - proximity_data: dict[str, str | float] = { + proximity_data: dict[str, str | int | None] = { ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, ATTR_NEAREST: DEFAULT_NEAREST, @@ -318,28 +319,26 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug("set first entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) > float(distance_to): + if cast(int, nearest_distance_to) > int(distance_to): _LOGGER.debug("set closer entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) == float(distance_to): + if cast(int, nearest_distance_to) == int(distance_to): _LOGGER.debug("set equally close entity_data: %s", entity_data) proximity_data[ ATTR_NEAREST ] = f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" - proximity_data[ATTR_DIST_TO] = self._convert(proximity_data[ATTR_DIST_TO]) - return ProximityData(proximity_data, entities_data) def _create_removed_tracked_entity_issue(self, entity_id: str) -> None: diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 4b1e1d1f29d..c000aa27683 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -17,38 +17,53 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN +from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_NEAREST, + ATTR_NEAREST_DIR_OF_TRAVEL, + ATTR_NEAREST_DIST_TO, + DOMAIN, +) from .coordinator import ProximityDataUpdateCoordinator +DIRECTIONS = ["arrived", "away_from", "stationary", "towards"] + SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_DIST_TO, - name="Distance", + translation_key=ATTR_DIST_TO, device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, ), SensorEntityDescription( key=ATTR_DIR_OF_TRAVEL, - name="Direction of travel", translation_key=ATTR_DIR_OF_TRAVEL, icon="mdi:compass-outline", device_class=SensorDeviceClass.ENUM, - options=[ - "arrived", - "away_from", - "stationary", - "towards", - ], + options=DIRECTIONS, ), ] SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_NEAREST, - name="Nearest", translation_key=ATTR_NEAREST, icon="mdi:near-me", ), + SensorEntityDescription( + key=ATTR_DIST_TO, + translation_key=ATTR_NEAREST_DIST_TO, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + translation_key=ATTR_NEAREST_DIR_OF_TRAVEL, + icon="mdi:compass-outline", + device_class=SensorDeviceClass.ENUM, + options=DIRECTIONS, + ), ] @@ -151,8 +166,10 @@ class ProximityTrackedEntitySensor( self.tracked_entity_id = tracked_entity_descriptor.entity_id self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" - self._attr_name = f"{self.tracked_entity_id.split('.')[-1]} {description.name}" self._attr_device_info = _device_info(coordinator) + self._attr_translation_placeholders = { + "tracked_entity": self.tracked_entity_id.split(".")[-1] + } async def async_added_to_hass(self) -> None: """Register entity mapping.""" diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index f52f3d03516..72c95eeeeae 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -32,7 +32,7 @@ "entity": { "sensor": { "dir_of_travel": { - "name": "Direction of travel", + "name": "{tracked_entity} Direction of travel", "state": { "arrived": "Arrived", "away_from": "Away from", @@ -40,7 +40,18 @@ "towards": "Towards" } }, - "nearest": { "name": "Nearest device" } + "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "nearest": { "name": "Nearest device" }, + "nearest_dir_of_travel": { + "name": "Nearest direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "nearest_dist_to_zone": { "name": "Nearest distance" } } }, "issues": { diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index a93ff33f443..68270dc3297 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -41,8 +41,8 @@ ]), }), 'proximity': dict({ - 'dir_of_travel': 'unknown', - 'dist_to_zone': 2219, + 'dir_of_travel': None, + 'dist_to_zone': 2218752, 'nearest': 'test1', }), 'tracked_states': dict({ diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 059ba2658ee..bce4c319ce0 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -907,6 +907,132 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.state == "away_from" +async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: + """Test for nearest sensors.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1", "device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 15, "longitude": 8}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 45, "longitude": 22}, + ) + await hass.async_block_till_done() + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "5176058" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "away_from" + + # move the far tracker + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # move the near tracker + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # get unknown distance and direction + hass.states.async_set( + "device_tracker.test1", "not_home", {"friendly_name": "test1"} + ) + hass.states.async_set( + "device_tracker.test2", "not_home", {"friendly_name": "test2"} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == STATE_UNKNOWN + + async def test_create_deprecated_proximity_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry, From 74f1b18b7388e46cc21f4f90d1aca893cc3933f0 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Mon, 5 Feb 2024 10:27:49 +1100 Subject: [PATCH 1480/1544] Bump georss-generic-client to 0.8 (#109658) --- homeassistant/components/geo_rss_events/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index bdf8f126680..17640e37278 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], - "requirements": ["georss-generic-client==0.6"] + "requirements": ["georss-generic-client==0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ea902e5de6..7c319de9663 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -905,7 +905,7 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss-generic-client==0.6 +georss-generic-client==0.8 # homeassistant.components.ign_sismologia georss-ign-sismologia-client==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c08ddb04053..d7de5535aad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -731,7 +731,7 @@ geocachingapi==0.2.1 geopy==2.3.0 # homeassistant.components.geo_rss_events -georss-generic-client==0.6 +georss-generic-client==0.8 # homeassistant.components.ign_sismologia georss-ign-sismologia-client==0.6 From 91b1a8e962bcf896c31c362660145ba35bc52517 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 5 Feb 2024 01:36:11 +0100 Subject: [PATCH 1481/1544] Add icon translation to proximity (#109664) add icon translations --- homeassistant/components/proximity/icons.json | 27 +++++++++++++++++++ homeassistant/components/proximity/sensor.py | 3 --- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/proximity/icons.json diff --git a/homeassistant/components/proximity/icons.json b/homeassistant/components/proximity/icons.json new file mode 100644 index 00000000000..2919c73eda0 --- /dev/null +++ b/homeassistant/components/proximity/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + }, + "nearest": { + "default": "mdi:near-me" + }, + "nearest_dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + } + } + } +} diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index c000aa27683..bd788058869 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -39,7 +39,6 @@ SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_DIR_OF_TRAVEL, translation_key=ATTR_DIR_OF_TRAVEL, - icon="mdi:compass-outline", device_class=SensorDeviceClass.ENUM, options=DIRECTIONS, ), @@ -49,7 +48,6 @@ SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_NEAREST, translation_key=ATTR_NEAREST, - icon="mdi:near-me", ), SensorEntityDescription( key=ATTR_DIST_TO, @@ -60,7 +58,6 @@ SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_DIR_OF_TRAVEL, translation_key=ATTR_NEAREST_DIR_OF_TRAVEL, - icon="mdi:compass-outline", device_class=SensorDeviceClass.ENUM, options=DIRECTIONS, ), From 44ecaa740b989c0a08f808e5770c8d4a908a068b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 5 Feb 2024 01:27:42 +0100 Subject: [PATCH 1482/1544] Add missing translation string to Home Assistant Analytics Insights (#109666) add missing string --- homeassistant/components/analytics_insights/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 96ec59f299b..96f13243868 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "tracked_integrations": "Integrations" + "tracked_integrations": "Integrations", + "tracked_custom_integrations": "Custom integrations" } } }, From f50afd6004756c70e716381fa3fcd049d513cea8 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 5 Feb 2024 02:58:08 -0500 Subject: [PATCH 1483/1544] Buffer TImeoutError in Flo (#109675) --- homeassistant/components/flo/device.py | 2 +- tests/components/flo/test_device.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 3b7469686b4..0c805b932cb 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -46,7 +46,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable= await self._update_device() await self._update_consumption_data() self._failure_count = 0 - except RequestError as error: + except (RequestError, TimeoutError) as error: self._failure_count += 1 if self._failure_count > 3: raise UpdateFailed(error) from error diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 6a633c774ed..eb7785dbd61 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -90,7 +90,7 @@ async def test_device( # test error sending device ping with patch( - "homeassistant.components.flo.device.FloDeviceDataUpdateCoordinator.send_presence_ping", + "aioflo.presence.Presence.ping", side_effect=RequestError, ), pytest.raises(UpdateFailed): # simulate 4 updates failing From ecc6cc280a7e1144413ce24895d9514e7ac3a284 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 5 Feb 2024 09:41:55 +0100 Subject: [PATCH 1484/1544] Bump version to 2024.2.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 ed681efee14..44d046bacd0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -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, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 67f410c717c..a6deaa6937f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b6" +version = "2024.2.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f05ba22b5c5ef28228669d2680b48e526dcb4ba5 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Mon, 5 Feb 2024 20:53:42 +1100 Subject: [PATCH 1485/1544] Show site state in Amberelectric config flow (#104702) --- .../components/amberelectric/config_flow.py | 71 +++++--- .../components/amberelectric/const.py | 1 - .../components/amberelectric/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../amberelectric/test_config_flow.py | 167 ++++++++++++++++-- .../amberelectric/test_coordinator.py | 25 ++- 7 files changed, 220 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index 4011f442ee2..765e219b6d7 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -3,18 +3,46 @@ from __future__ import annotations import amberelectric from amberelectric.api import amber_api -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_TOKEN from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN +from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN API_URL = "https://app.amber.com.au/developers" +def generate_site_selector_name(site: Site) -> str: + """Generate the name to show in the site drop down in the configuration flow.""" + if site.status == SiteStatus.CLOSED: + return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return] + if site.status == SiteStatus.PENDING: + return site.nmi + " (Pending)" # type: ignore[no-any-return] + return site.nmi # type: ignore[no-any-return] + + +def filter_sites(sites: list[Site]) -> list[Site]: + """Deduplicates the list of sites.""" + filtered: list[Site] = [] + filtered_nmi: set[str] = set() + + for site in sorted(sites, key=lambda site: site.status.value): + if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi: + filtered.append(site) + filtered_nmi.add(site.nmi) + + return filtered + + class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -31,7 +59,7 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) try: - sites: list[Site] = api.get_sites() + sites: list[Site] = filter_sites(api.get_sites()) if len(sites) == 0: self._errors[CONF_API_TOKEN] = "no_site" return None @@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._sites is not None assert self._api_token is not None - api_token = self._api_token if user_input is not None: - site_nmi = user_input[CONF_SITE_NMI] - sites = [site for site in self._sites if site.nmi == site_nmi] - site = sites[0] - site_id = site.id + site_id = user_input[CONF_SITE_ID] name = user_input.get(CONF_SITE_NAME, site_id) return self.async_create_entry( title=name, - data={ - CONF_SITE_ID: site_id, - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: site.nmi, - }, + data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token}, ) - user_input = { - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: "", - CONF_SITE_NAME: "", - } - return self.async_show_form( step_id="site", data_schema=vol.Schema( { - vol.Required( - CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] - ): vol.In([site.nmi for site in self._sites]), - vol.Optional( - CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] - ): str, + vol.Required(CONF_SITE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=site.id, + label=generate_site_selector_name(site), + ) + for site in self._sites + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_SITE_NAME): str, } ), errors=self._errors, diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 8416b7ca33c..6166b21c19f 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -6,7 +6,6 @@ from homeassistant.const import Platform DOMAIN = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -CONF_SITE_NMI = "site_nmi" ATTRIBUTION = "Data provided by Amber Electric" diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json index 29de18d96de..13a9f257adb 100644 --- a/homeassistant/components/amberelectric/manifest.json +++ b/homeassistant/components/amberelectric/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amberelectric", "iot_class": "cloud_polling", "loggers": ["amberelectric"], - "requirements": ["amberelectric==1.0.4"] + "requirements": ["amberelectric==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c319de9663..9c1d95e18bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ airtouch5py==0.2.8 alpha-vantage==2.3.1 # homeassistant.components.amberelectric -amberelectric==1.0.4 +amberelectric==1.1.0 # homeassistant.components.amcrest amcrest==1.9.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7de5535aad..9a533250b9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -395,7 +395,7 @@ airtouch4pyapi==1.0.5 airtouch5py==0.2.8 # homeassistant.components.amberelectric -amberelectric==1.0.4 +amberelectric==1.1.0 # homeassistant.components.androidtv androidtv[async]==0.0.73 diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 6325282aff8..2624bd96d31 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -1,17 +1,18 @@ """Tests for the Amber config flow.""" from collections.abc import Generator +from datetime import date from unittest.mock import Mock, patch from amberelectric import ApiException -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus import pytest from homeassistant import data_entry_flow +from homeassistant.components.amberelectric.config_flow import filter_sites from homeassistant.components.amberelectric.const import ( CONF_SITE_ID, CONF_SITE_NAME, - CONF_SITE_NMI, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER @@ -26,29 +27,88 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture(name="invalid_key_api") def mock_invalid_key_api() -> Generator: """Return an authentication error.""" - instance = Mock() - instance.get_sites.side_effect = ApiException(status=403) - with patch("amberelectric.api.AmberApi.create", return_value=instance): - yield instance + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.side_effect = ApiException(status=403) + yield mock @pytest.fixture(name="api_error") def mock_api_error() -> Generator: """Return an authentication error.""" - instance = Mock() - instance.get_sites.side_effect = ApiException(status=500) - - with patch("amberelectric.api.AmberApi.create", return_value=instance): - yield instance + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.side_effect = ApiException(status=500) + yield mock @pytest.fixture(name="single_site_api") def mock_single_site_api() -> Generator: + """Return a single site.""" + site = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.ACTIVE, + date(2002, 1, 1), + None, + ) + + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.return_value = [site] + yield mock + + +@pytest.fixture(name="single_site_pending_api") +def mock_single_site_pending_api() -> Generator: + """Return a single site.""" + site = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.PENDING, + None, + None, + ) + + with patch("amberelectric.api.AmberApi.create") as mock: + mock.return_value.get_sites.return_value = [site] + yield mock + + +@pytest.fixture(name="single_site_rejoin_api") +def mock_single_site_rejoin_api() -> Generator: """Return a single site.""" instance = Mock() - site = Site("01FG0AGP818PXK0DWHXJRRT2DH", "11111111111", []) - instance.get_sites.return_value = [site] + site_1 = Site( + "01HGD9QB72HB3DWQNJ6SSCGXGV", + "11111111111", + [], + "Jemena", + SiteStatus.CLOSED, + date(2002, 1, 1), + date(2002, 6, 1), + ) + site_2 = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111111", + [], + "Jemena", + SiteStatus.ACTIVE, + date(2003, 1, 1), + None, + ) + site_3 = Site( + "01FG0AGP818PXK0DWHXJRRT2DH", + "11111111112", + [], + "Jemena", + SiteStatus.CLOSED, + date(2003, 1, 1), + date(2003, 6, 1), + ) + instance.get_sites.return_value = [site_1, site_2, site_3] with patch("amberelectric.api.AmberApi.create", return_value=instance): yield instance @@ -64,6 +124,39 @@ def mock_no_site_api() -> Generator: yield instance +async def test_single_pending_site( + hass: HomeAssistant, single_site_pending_api: Mock +) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.FlowResultType.FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.FlowResultType.FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + + async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: """Test single site.""" initial_result = await hass.config_entries.flow.async_init( @@ -83,7 +176,40 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: select_site_result = await hass.config_entries.flow.async_configure( enter_api_key_result["flow_id"], - {CONF_SITE_NMI: "11111111111", CONF_SITE_NAME: "Home"}, + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, + ) + + # Show available sites + assert select_site_result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + assert select_site_result.get("title") == "Home" + data = select_site_result.get("data") + assert data + assert data[CONF_API_TOKEN] == API_KEY + assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" + + +async def test_single_site_rejoin( + hass: HomeAssistant, single_site_rejoin_api: Mock +) -> None: + """Test single site.""" + initial_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert initial_result.get("type") == data_entry_flow.FlowResultType.FORM + assert initial_result.get("step_id") == "user" + + # Test filling in API key + enter_api_key_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_API_TOKEN: API_KEY}, + ) + assert enter_api_key_result.get("type") == data_entry_flow.FlowResultType.FORM + assert enter_api_key_result.get("step_id") == "site" + + select_site_result = await hass.config_entries.flow.async_configure( + enter_api_key_result["flow_id"], + {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"}, ) # Show available sites @@ -93,7 +219,6 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None: assert data assert data[CONF_API_TOKEN] == API_KEY assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH" - assert data[CONF_SITE_NMI] == "11111111111" async def test_no_site(hass: HomeAssistant, no_site_api: Mock) -> None: @@ -148,3 +273,15 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None: # Goes back to the user step assert result.get("step_id") == "user" assert result.get("errors") == {"api_token": "unknown_error"} + + +async def test_site_deduplication(single_site_rejoin_api: Mock) -> None: + """Test site deduplication.""" + filtered = filter_sites(single_site_rejoin_api.get_sites()) + assert len(filtered) == 2 + assert ( + next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE + ) + assert ( + next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED + ) diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 64fa39192a6..7808d1adcde 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -2,13 +2,14 @@ from __future__ import annotations from collections.abc import Generator +from datetime import date from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.model.channel import Channel, ChannelType from amberelectric.model.current_interval import CurrentInterval from amberelectric.model.interval import Descriptor, SpikeStatus -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus from dateutil import parser import pytest @@ -38,23 +39,35 @@ def mock_api_current_price() -> Generator: general_site = Site( GENERAL_ONLY_SITE_ID, "11111111111", - [Channel(identifier="E1", type=ChannelType.GENERAL)], + [Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) general_and_controlled_load = Site( GENERAL_AND_CONTROLLED_SITE_ID, "11111111112", [ - Channel(identifier="E1", type=ChannelType.GENERAL), - Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD), + Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), + Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD, tariff="A180"), ], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) general_and_feed_in = Site( GENERAL_AND_FEED_IN_SITE_ID, "11111111113", [ - Channel(identifier="E1", type=ChannelType.GENERAL), - Channel(identifier="E2", type=ChannelType.FEED_IN), + Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"), + Channel(identifier="E2", type=ChannelType.FEED_IN, tariff="A100"), ], + "Jemena", + SiteStatus.ACTIVE, + date(2021, 1, 1), + None, ) instance.get_sites.return_value = [ general_site, From 5930c841d7d6feb217089386e7e4195c0c017436 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 5 Feb 2024 12:40:40 +0100 Subject: [PATCH 1486/1544] Bump python matter server to 5.4.1 (#109692) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 4173e129895..d3d0568342e 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.4.0"] + "requirements": ["python-matter-server==5.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c1d95e18bd..5a35494f074 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2238,7 +2238,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.4.0 +python-matter-server==5.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a533250b9a..4e44854e48e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1711,7 +1711,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.4.0 +python-matter-server==5.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 3183cd346d4fd8e022e220ce27ce7030485e56a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 12:30:09 +0100 Subject: [PATCH 1487/1544] Add data descriptions to analytics insights (#109694) --- .../components/analytics_insights/strings.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 96f13243868..58e47d1df08 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -5,6 +5,10 @@ "data": { "tracked_integrations": "Integrations", "tracked_custom_integrations": "Custom integrations" + }, + "data_description": { + "tracked_integrations": "Select the integrations you want to track", + "tracked_custom_integrations": "Select the custom integrations you want to track" } } }, @@ -17,7 +21,12 @@ "step": { "init": { "data": { - "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]" + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]" + }, + "data_description": { + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]" } } }, From 83a5659d570a62e1fc61b7be2035f7685af8ec86 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 12:41:25 +0100 Subject: [PATCH 1488/1544] Set shorthand attribute in Epion (#109695) --- homeassistant/components/epion/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 826d565c2cd..c722e73ac6c 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -88,9 +88,9 @@ class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): super().__init__(coordinator) self._epion_device_id = epion_device_id self.entity_description = description - self.unique_id = f"{epion_device_id}_{description.key}" + self._attr_unique_id = f"{epion_device_id}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._epion_device_id)}, + identifiers={(DOMAIN, epion_device_id)}, manufacturer="Epion", name=self.device.get("deviceName"), sw_version=self.device.get("fwVersion"), From c48c8c25fa491362b7d196233882c3365a968eb4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 16:32:39 +0100 Subject: [PATCH 1489/1544] Remove obsolete check from Proximity (#109701) --- homeassistant/components/proximity/sensor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index bd788058869..c562467f8be 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -176,18 +176,19 @@ class ProximityTrackedEntitySensor( ) @property - def data(self) -> dict[str, str | int | None] | None: + def data(self) -> dict[str, str | int | None]: """Get data from coordinator.""" - return self.coordinator.data.entities.get(self.tracked_entity_id) + return self.coordinator.data.entities[self.tracked_entity_id] @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.data is not None + return ( + super().available + and self.tracked_entity_id in self.coordinator.data.entities + ) @property def native_value(self) -> str | float | None: """Return native sensor value.""" - if self.data is None: - return None return self.data.get(self.entity_description.key) From dd2cc52119c48a660d76f2dc70f5751deb3879ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 16:03:14 +0100 Subject: [PATCH 1490/1544] Set Analytics Insights as diagnostic (#109702) * Set Analytics Insights as diagnostic * Set Analytics Insights as diagnostic --- homeassistant/components/analytics_insights/sensor.py | 2 ++ .../analytics_insights/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index e0fe2c79413..90e9ff51b87 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( 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 from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -93,6 +94,7 @@ class HomeassistantAnalyticsSensor( """Home Assistant Analytics Sensor.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: AnalyticsSensorEntityDescription diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 474263f68e9..dc4c3d6d795 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.homeassistant_analytics_hacs_custom', 'has_entity_name': True, 'hidden_by': None, @@ -59,7 +59,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.homeassistant_analytics_myq', 'has_entity_name': True, 'hidden_by': None, @@ -106,7 +106,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.homeassistant_analytics_spotify', 'has_entity_name': True, 'hidden_by': None, @@ -153,7 +153,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.homeassistant_analytics_youtube', 'has_entity_name': True, 'hidden_by': None, From 16266703df55832036615e33702c3e72fbd2cc9d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 5 Feb 2024 18:52:58 +0100 Subject: [PATCH 1491/1544] Queue climate calls for Comelit SimpleHome (#109707) --- homeassistant/components/comelit/climate.py | 5 +---- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 877afd1414e..5a879bc2d24 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -1,12 +1,11 @@ """Support for climates.""" from __future__ import annotations -import asyncio from enum import StrEnum from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import CLIMATE, SLEEP_BETWEEN_CALLS +from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( ClimateEntity, @@ -191,7 +190,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.MANUAL ) - await asyncio.sleep(SLEEP_BETWEEN_CALLS) await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.SET, target_temp ) @@ -203,7 +201,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.ON ) - await asyncio.sleep(SLEEP_BETWEEN_CALLS) await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index f1b2cea9e73..bbbb4efe7d6 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.8.2"] + "requirements": ["aiocomelit==0.8.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a35494f074..afa650cb389 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.8.2 +aiocomelit==0.8.3 # homeassistant.components.dhcp aiodiscover==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e44854e48e..6e94b3c102a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.9.1 # homeassistant.components.comelit -aiocomelit==0.8.2 +aiocomelit==0.8.3 # homeassistant.components.dhcp aiodiscover==1.6.0 From 2d90ee8237f766799b078bcdd425ceaca524acb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 5 Feb 2024 15:10:32 +0100 Subject: [PATCH 1492/1544] Fix log string in Traccar Server Coordinator (#109709) --- homeassistant/components/traccar_server/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 90c910e6062..df9b5adaf1a 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -78,7 +78,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.client.get_geofences(), ) except TraccarException as ex: - raise UpdateFailed("Error while updating device data: %s") from ex + raise UpdateFailed(f"Error while updating device data: {ex}") from ex if TYPE_CHECKING: assert isinstance(devices, list[DeviceModel]) # type: ignore[misc] From cc36071612cef00060f378d7ab38ec25d75447f8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 5 Feb 2024 16:09:33 +0100 Subject: [PATCH 1493/1544] Update frontend to 20240205.0 (#109716) --- 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 039328b9cac..1af6a9da7b0 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==20240202.0"] + "requirements": ["home-assistant-frontend==20240205.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ff23275e8f..064675fce08 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.4.0 hass-nabucasa==0.76.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240202.0 +home-assistant-frontend==20240205.0 home-assistant-intents==2024.2.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index afa650cb389..9eb27dc41fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1059,7 +1059,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240202.0 +home-assistant-frontend==20240205.0 # homeassistant.components.conversation home-assistant-intents==2024.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e94b3c102a..11ad3d0e1c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -855,7 +855,7 @@ hole==0.8.0 holidays==0.41 # homeassistant.components.frontend -home-assistant-frontend==20240202.0 +home-assistant-frontend==20240205.0 # homeassistant.components.conversation home-assistant-intents==2024.2.2 From e3191d098f48fbd9822401066c2b222138567936 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 16:26:25 +0100 Subject: [PATCH 1494/1544] Add strings to Ruuvitag BLE (#109717) --- .../components/ruuvitag_ble/strings.json | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 homeassistant/components/ruuvitag_ble/strings.json diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json new file mode 100644 index 00000000000..d1d544c2381 --- /dev/null +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} From a19aa9595a63bb774e7f20bf4a280128832e6c5f Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Mon, 5 Feb 2024 18:51:01 +0100 Subject: [PATCH 1495/1544] Bump python-bring-api to 3.0.0 (#109720) --- homeassistant/components/bring/__init__.py | 11 ++++----- homeassistant/components/bring/config_flow.py | 13 ++++++----- homeassistant/components/bring/coordinator.py | 8 ++----- homeassistant/components/bring/manifest.json | 2 +- homeassistant/components/bring/todo.py | 23 +++++++------------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/conftest.py | 10 ++++---- tests/components/bring/test_config_flow.py | 14 ++++++----- tests/components/bring/test_init.py | 8 +++---- 10 files changed, 42 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index e9501fc64b3..aec3cd53c94 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator @@ -29,14 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: email = entry.data[CONF_EMAIL] password = entry.data[CONF_PASSWORD] - bring = Bring(email, password) - - def login_and_load_lists() -> None: - bring.login() - bring.loadLists() + session = async_get_clientsession(hass) + bring = Bring(email, password, sessionAsync=session) try: - await hass.async_add_executor_job(login_and_load_lists) + await bring.loginAsync() + await bring.loadListsAsync() except BringRequestException as e: raise ConfigEntryNotReady( f"Timeout while connecting for email '{email}'" diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 21774117ff6..122e71feea6 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, @@ -48,14 +49,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) - - def login_and_load_lists() -> None: - bring.login() - bring.loadLists() + session = async_get_clientsession(self.hass) + bring = Bring( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD], sessionAsync=session + ) try: - await self.hass.async_add_executor_job(login_and_load_lists) + await bring.loginAsync() + await bring.loadListsAsync() except BringRequestException: errors["base"] = "cannot_connect" except BringAuthException: diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index a7bd4a35f43..eb28f24e085 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -40,9 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): async def _async_update_data(self) -> dict[str, BringData]: try: - lists_response = await self.hass.async_add_executor_job( - self.bring.loadLists - ) + lists_response = await self.bring.loadListsAsync() except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -51,9 +49,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): list_dict = {} for lst in lists_response["lists"]: try: - items = await self.hass.async_add_executor_job( - self.bring.getItems, lst["listUuid"] - ) + items = await self.bring.getItemsAsync(lst["listUuid"]) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index bc249ecea98..e7d23bfc3df 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["python-bring-api==2.0.0"] + "requirements": ["python-bring-api==3.0.0"] } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index bd87a2d18de..14279c894af 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -91,11 +91,8 @@ class BringTodoListEntity( async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" try: - await self.hass.async_add_executor_job( - self.coordinator.bring.saveItem, - self.bring_list["listUuid"], - item.summary, - item.description or "", + await self.coordinator.bring.saveItemAsync( + self.bring_list["listUuid"], item.summary, item.description or "" ) except BringRequestException as e: raise HomeAssistantError("Unable to save todo item for bring") from e @@ -126,16 +123,14 @@ class BringTodoListEntity( assert item.uid if item.status == TodoItemStatus.COMPLETED: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, + await self.coordinator.bring.removeItemAsync( bring_list["listUuid"], item.uid, ) elif item.summary == item.uid: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.updateItem, + await self.coordinator.bring.updateItemAsync( bring_list["listUuid"], item.uid, item.description or "", @@ -144,13 +139,11 @@ class BringTodoListEntity( raise HomeAssistantError("Unable to update todo item for bring") from e else: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, + await self.coordinator.bring.removeItemAsync( bring_list["listUuid"], item.uid, ) - await self.hass.async_add_executor_job( - self.coordinator.bring.saveItem, + await self.coordinator.bring.saveItemAsync( bring_list["listUuid"], item.summary, item.description or "", @@ -164,8 +157,8 @@ class BringTodoListEntity( """Delete an item from the To-do list.""" for uid in uids: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid + await self.coordinator.bring.removeItemAsync( + self.bring_list["listUuid"], uid ) except BringRequestException as e: raise HomeAssistantError("Unable to delete todo item for bring") from e diff --git a/requirements_all.txt b/requirements_all.txt index 9eb27dc41fe..2d4a4a087bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2181,7 +2181,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bring -python-bring-api==2.0.0 +python-bring-api==3.0.0 # homeassistant.components.bsblan python-bsblan==0.5.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11ad3d0e1c6..3d1c781a5eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1684,7 +1684,7 @@ python-MotionMount==0.3.1 python-awair==0.2.4 # homeassistant.components.bring -python-bring-api==2.0.0 +python-bring-api==3.0.0 # homeassistant.components.bsblan python-bsblan==0.5.18 diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index f8749d3dea9..81a76c9ee3e 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Bring! tests.""" from collections.abc import Generator -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -16,7 +16,7 @@ UUID = "00000000-00000000-00000000-00000000" @pytest.fixture -def mock_setup_entry() -> Generator[Mock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" with patch( "homeassistant.components.bring.async_setup_entry", return_value=True @@ -25,7 +25,7 @@ def mock_setup_entry() -> Generator[Mock, None, None]: @pytest.fixture -def mock_bring_client() -> Generator[Mock, None, None]: +def mock_bring_client() -> Generator[AsyncMock, None, None]: """Mock a Bring client.""" with patch( "homeassistant.components.bring.Bring", @@ -36,8 +36,8 @@ def mock_bring_client() -> Generator[Mock, None, None]: ): client = mock_client.return_value client.uuid = UUID - client.login.return_value = True - client.loadLists.return_value = {"lists": []} + client.loginAsync.return_value = True + client.loadListsAsync.return_value = {"lists": []} yield client diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 063d84a0e97..531554d584e 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Bring! config flow.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock import pytest from python_bring_api.exceptions import ( @@ -25,7 +25,7 @@ MOCK_DATA_STEP = { async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bring_client: Mock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bring_client: AsyncMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -59,10 +59,10 @@ async def test_form( ], ) async def test_flow_user_init_data_unknown_error_and_recover( - hass: HomeAssistant, mock_bring_client: Mock, raise_error, text_error + hass: HomeAssistant, mock_bring_client: AsyncMock, raise_error, text_error ) -> None: """Test unknown errors.""" - mock_bring_client.login.side_effect = raise_error + mock_bring_client.loginAsync.side_effect = raise_error result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -76,7 +76,7 @@ async def test_flow_user_init_data_unknown_error_and_recover( assert result["errors"]["base"] == text_error # Recover - mock_bring_client.login.side_effect = None + mock_bring_client.loginAsync.side_effect = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} ) @@ -92,7 +92,9 @@ async def test_flow_user_init_data_unknown_error_and_recover( async def test_flow_user_init_data_already_configured( - hass: HomeAssistant, mock_bring_client: Mock, bring_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, ) -> None: """Test we abort user data set when entry is already configured.""" diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 3c605143ba0..59628fa59b7 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -1,5 +1,5 @@ """Unit tests for the bring integration.""" -from unittest.mock import Mock +from unittest.mock import AsyncMock import pytest @@ -27,7 +27,7 @@ async def setup_integration( async def test_load_unload( hass: HomeAssistant, - mock_bring_client: Mock, + mock_bring_client: AsyncMock, bring_config_entry: MockConfigEntry, ) -> None: """Test loading and unloading of the config entry.""" @@ -52,12 +52,12 @@ async def test_load_unload( ) async def test_init_failure( hass: HomeAssistant, - mock_bring_client: Mock, + mock_bring_client: AsyncMock, status: ConfigEntryState, exception: Exception, bring_config_entry: MockConfigEntry | None, ) -> None: """Test an initialization error on integration load.""" - mock_bring_client.login.side_effect = exception + mock_bring_client.loginAsync.side_effect = exception await setup_integration(hass, bring_config_entry) assert bring_config_entry.state == status From 1534f99c80bc0732300f0966edce8a4e22871888 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 5 Feb 2024 20:19:38 +0100 Subject: [PATCH 1496/1544] Fix generic camera error when template renders to an invalid URL (#109737) --- homeassistant/components/generic/camera.py | 7 +++++ tests/components/generic/test_camera.py | 30 +++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 902f5ebadde..cadc855ade6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -8,6 +8,7 @@ import logging from typing import Any import httpx +import voluptuous as vol import yarl from homeassistant.components.camera import Camera, CameraEntityFeature @@ -140,6 +141,12 @@ class GenericCamera(Camera): _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) return self._last_image + try: + vol.Schema(vol.Url())(url) + except vol.Invalid as err: + _LOGGER.warning("Invalid URL '%s': %s, returning last image", url, err) + return self._last_image + if url == self._last_url and self._limit_refetch: return self._last_image diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 5a4bae22e9f..9fe394d05c3 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -70,15 +70,20 @@ async def help_setup_mock_config_entry( @respx.mock async def test_fetching_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + fakeimgbytes_png, + caplog: pytest.CaptureFixture, ) -> None: """Test that it fetches the given url.""" - respx.get("http://example.com").respond(stream=fakeimgbytes_png) + hass.states.async_set("sensor.temp", "http://example.com/0a") + respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png) + respx.get("http://example.com/1a").respond(stream=fakeimgbytes_png) options = { "name": "config_test", "platform": "generic", - "still_image_url": "http://example.com", + "still_image_url": "{{ states.sensor.temp.state }}", "username": "user", "password": "pass", "authentication": "basic", @@ -101,6 +106,25 @@ async def test_fetching_url( resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 + # If the template renders to an invalid URL we return the last image from cache + hass.states.async_set("sensor.temp", "invalid url") + + # sleep another .1 seconds to make cached image expire + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + assert respx.calls.call_count == 2 + assert ( + "Invalid URL 'invalid url': expected a URL, returning last image" in caplog.text + ) + + # Restore a valid URL + hass.states.async_set("sensor.temp", "http://example.com/1a") + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + assert respx.calls.call_count == 3 + @respx.mock async def test_image_caching( From 532df5b5f1591aab74179a3f48f468651f42923f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:18:59 +0100 Subject: [PATCH 1497/1544] Use tracked entity friendly name for proximity sensors (#109744) user tracked entity friendly name --- homeassistant/components/proximity/sensor.py | 18 +++++++++++++++--- tests/components/proximity/test_init.py | 13 ++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index c562467f8be..8eb7aae9bb9 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -69,6 +69,7 @@ class TrackedEntityDescriptor(NamedTuple): entity_id: str identifier: str + name: str def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: @@ -95,13 +96,24 @@ async def async_setup_entry( entity_reg = er.async_get(hass) for tracked_entity_id in coordinator.tracked_entities: + tracked_entity_object_id = tracked_entity_id.split(".")[-1] if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: tracked_entity_descriptors.append( - TrackedEntityDescriptor(tracked_entity_id, entity_entry.id) + TrackedEntityDescriptor( + tracked_entity_id, + entity_entry.id, + entity_entry.name + or entity_entry.original_name + or tracked_entity_object_id, + ) ) else: tracked_entity_descriptors.append( - TrackedEntityDescriptor(tracked_entity_id, tracked_entity_id) + TrackedEntityDescriptor( + tracked_entity_id, + tracked_entity_id, + tracked_entity_object_id, + ) ) entities += [ @@ -165,7 +177,7 @@ class ProximityTrackedEntitySensor( self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" self._attr_device_info = _device_info(coordinator) self._attr_translation_placeholders = { - "tracked_entity": self.tracked_entity_id.split(".")[-1] + "tracked_entity": tracked_entity_descriptor.name } async def async_added_to_hass(self) -> None: diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index bce4c319ce0..8fa9e4a1ce1 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -11,7 +11,12 @@ from homeassistant.components.proximity.const import ( DOMAIN, ) from homeassistant.components.script import scripts_with_entity -from homeassistant.const import CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + CONF_ZONE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir @@ -1205,7 +1210,7 @@ async def test_sensor_unique_ids( ) -> None: """Test that when tracked entity is renamed.""" t1 = entity_registry.async_get_or_create( - "device_tracker", "device_tracker", "test1" + "device_tracker", "device_tracker", "test1", original_name="Test tracker 1" ) hass.states.async_set(t1.entity_id, "not_home") @@ -1227,10 +1232,12 @@ async def test_sensor_unique_ids( assert await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() - sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + sensor_t1 = "sensor.home_test_tracker_1_distance" entity = entity_registry.async_get(sensor_t1) assert entity assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + state = hass.states.get(sensor_t1) + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home Test tracker 1 Distance" entity = entity_registry.async_get("sensor.home_test2_distance") assert entity From eb510e36305412a6387dac8e51ae45dd929f0142 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Feb 2024 14:17:13 -0500 Subject: [PATCH 1498/1544] Add missing new climate feature flags to Mill (#109748) --- homeassistant/components/mill/climate.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2e7b22da833..a2e70b8f9c8 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,4 +1,5 @@ """Support for mill wifi-enabled home heaters.""" + from typing import Any import mill @@ -186,9 +187,14 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" From d30a2e36114b3c3c57f05666848ef32711924e74 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:15:51 +0100 Subject: [PATCH 1499/1544] Fix incorrectly assigning supported features for plugwise climates (#109749) --- homeassistant/components/plugwise/climate.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 8e4dccb9e05..3553df02e8d 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -63,11 +63,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self.gateway_data = coordinator.data.devices[gateway_id] # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if HVACMode.OFF in self.hvac_modes: - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) - if ( self.cdr_gateway["cooling_present"] and self.cdr_gateway["smile_name"] != "Adam" @@ -75,6 +70,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if presets := self.device.get("preset_modes"): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets From 65476914ed07f56b4d42fb6e3683eb6af467ec10 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen <8420095+vilppuvuorinen@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:18:01 +0200 Subject: [PATCH 1500/1544] Reduce MELCloud poll frequency to avoid throttling (#109750) --- homeassistant/components/melcloud/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 2187cb5b8b8..909a1506eb1 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -138,8 +138,9 @@ async def mel_devices_setup( all_devices = await get_devices( token, session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), + user_update_interval=timedelta(minutes=30), + conf_update_interval=timedelta(minutes=15), + device_set_debounce=timedelta(seconds=2), ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): From 4c6c5ee63db781a9b282e4592c5214e85edd7711 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 5 Feb 2024 20:14:34 +0100 Subject: [PATCH 1501/1544] Handle startup error in Analytics insights (#109755) --- .../components/analytics_insights/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index a59d2a1c97c..23965a9fcb5 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -3,11 +3,15 @@ from __future__ import annotations from dataclasses import dataclass -from python_homeassistant_analytics import HomeassistantAnalyticsClient +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, +) 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_TRACKED_INTEGRATIONS, DOMAIN @@ -28,7 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homeassistant Analytics from a config entry.""" client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) - integrations = await client.get_integrations() + try: + integrations = await client.get_integrations() + except HomeassistantAnalyticsConnectionError as ex: + raise ConfigEntryNotReady("Could not fetch integration list") from ex names = {} for integration in entry.options[CONF_TRACKED_INTEGRATIONS]: From df88335370544ce98c2800dfc4093e377a00357b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 5 Feb 2024 20:27:40 +0100 Subject: [PATCH 1502/1544] Bump version to 2024.2.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 44d046bacd0..f6b42076575 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index a6deaa6937f..d7aea22d47a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b7" +version = "2024.2.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6b354457c2c2690f1d21793551c4e5bffab33dd4 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 6 Feb 2024 01:12:56 +0100 Subject: [PATCH 1503/1544] Fix ZHA creating unnecessary "summ received" entity after upgrade (#109268) * Do not create `current_summ_received` entity until initialized once * Update zha_devices_list.py to not expect summation received entities The attribute isn't initialized for these devices in the test (which our check now expects it to be), hence we need to remove them from this list. * Update sensor tests to have initial state for current_summ_received entity The attribute needs to be initialized for it to be created which we do by plugging the attribute read. The test expects the initial state to be "unknown", but hence we plugged the attribute (to create the entity), the state is whatever we plug the attribute read as. * Update sensor tests to expect not updating current_summ_received entity if it doesn't exist --- homeassistant/components/zha/sensor.py | 20 +++++++++ tests/components/zha/test_sensor.py | 29 ++++++++++--- tests/components/zha/zha_devices_list.py | 52 ------------------------ 3 files changed, 43 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 15985922ccd..929ac803b10 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -838,6 +838,26 @@ class SmartEnergySummationReceived(PolledSmartEnergySummation): _unique_id_suffix = "summation_received" _attr_translation_key: str = "summation_received" + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + This attribute only started to be initialized in HA 2024.2.0, + so the entity would still be created on the first HA start after the upgrade for existing devices, + as the initialization to see if an attribute is unsupported happens later in the background. + To avoid creating a lot of unnecessary entities for existing devices, + wait until the attribute was properly initialized once for now. + """ + if cluster_handlers[0].cluster.get(cls._attribute_name) is None: + return None + return super().create_entity(unique_id, zha_device, cluster_handlers, **kwargs) + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) # pylint: disable-next=hass-invalid-inheritance # needs fixing diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 005e9b86e3a..4b71fd723ad 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -368,6 +368,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "report_count", "read_plug", "unsupported_attrs", + "initial_sensor_state", ), ( ( @@ -377,6 +378,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.TemperatureMeasurement.cluster_id, @@ -385,6 +387,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.PressureMeasurement.cluster_id, @@ -393,6 +396,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( measurement.IlluminanceMeasurement.cluster_id, @@ -401,6 +405,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( smartenergy.Metering.cluster_id, @@ -415,6 +420,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "status": 0x00, }, {"current_summ_delivered", "current_summ_received"}, + STATE_UNKNOWN, ), ( smartenergy.Metering.cluster_id, @@ -431,6 +437,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "unit_of_measure": 0x00, }, {"instaneneous_demand", "current_summ_received"}, + STATE_UNKNOWN, ), ( smartenergy.Metering.cluster_id, @@ -445,8 +452,10 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "status": 0x00, "summation_formatting": 0b1_0111_010, "unit_of_measure": 0x00, + "current_summ_received": 0, }, {"instaneneous_demand", "current_summ_delivered"}, + "0.0", ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -455,6 +464,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"apparent_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -463,6 +473,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"active_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -471,6 +482,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, {"active_power", "apparent_power", "rms_current", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -479,6 +491,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, {"active_power", "apparent_power", "rms_voltage"}, + STATE_UNKNOWN, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -487,6 +500,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, {"active_power", "apparent_power", "rms_current"}, + STATE_UNKNOWN, ), ( general.PowerConfiguration.cluster_id, @@ -499,6 +513,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "battery_quantity": 3, }, None, + STATE_UNKNOWN, ), ( general.PowerConfiguration.cluster_id, @@ -511,6 +526,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): "battery_quantity": 3, }, None, + STATE_UNKNOWN, ), ( general.DeviceTemperature.cluster_id, @@ -519,6 +535,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 1, None, None, + STATE_UNKNOWN, ), ( hvac.Thermostat.cluster_id, @@ -527,6 +544,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 10, None, None, + STATE_UNKNOWN, ), ( hvac.Thermostat.cluster_id, @@ -535,6 +553,7 @@ async def async_test_pi_heating_demand(hass, cluster, entity_id): 10, None, None, + STATE_UNKNOWN, ), ), ) @@ -548,6 +567,7 @@ async def test_sensor( report_count, read_plug, unsupported_attrs, + initial_sensor_state, ) -> None: """Test ZHA sensor platform.""" @@ -582,8 +602,8 @@ async def test_sensor( # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) - # test that the sensor now have a state of unknown - assert hass.states.get(entity_id).state == STATE_UNKNOWN + # test that the sensor now have their correct initial state (mostly unknown) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic await test_func(hass, cluster, entity_id) @@ -826,7 +846,6 @@ async def test_electrical_measurement_init( }, { "summation_delivered", - "summation_received", }, { "instantaneous_demand", @@ -834,12 +853,11 @@ async def test_electrical_measurement_init( ), ( smartenergy.Metering.cluster_id, - {"instantaneous_demand", "current_summ_delivered", "current_summ_received"}, + {"instantaneous_demand", "current_summ_delivered"}, {}, { "instantaneous_demand", "summation_delivered", - "summation_received", }, ), ( @@ -848,7 +866,6 @@ async def test_electrical_measurement_init( { "instantaneous_demand", "summation_delivered", - "summation_received", }, {}, ), diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 9a9535178d2..4c23244c5e0 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -243,11 +243,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -616,13 +611,6 @@ DEVICES = [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered" ), }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: ( - "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_received" - ), - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1617,11 +1605,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1682,11 +1665,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -1747,11 +1725,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -2370,11 +2343,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -4511,11 +4479,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -5362,11 +5325,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -5420,11 +5378,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", @@ -5478,11 +5431,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_received"): { - DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_received", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", From 03953152675af99c7ee681357397d5cf85d79040 Mon Sep 17 00:00:00 2001 From: spycle <48740594+spycle@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:34:14 +0000 Subject: [PATCH 1504/1544] Bump pyMicrobot to 0.0.10 (#109628) --- homeassistant/components/keymitt_ble/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 760cc67cbd5..ee07881a01e 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,7 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", + "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.9"] + "requirements": ["PyMicroBot==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d4a4a087bd..cc27c58f9f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -76,7 +76,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.9 +PyMicroBot==0.0.10 # homeassistant.components.nina PyNINA==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d1c781a5eb..227a587d3ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -64,7 +64,7 @@ PyMetEireann==2021.8.0 PyMetno==0.11.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.9 +PyMicroBot==0.0.10 # homeassistant.components.nina PyNINA==0.3.3 From 3ba63fc78f03b00aa48b3ea85cba425729a4aa0d Mon Sep 17 00:00:00 2001 From: spycle <48740594+spycle@users.noreply.github.com> Date: Sun, 4 Feb 2024 21:50:28 +0000 Subject: [PATCH 1505/1544] Fix keymitt_ble config-flow (#109644) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/keymitt_ble/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index e8176b152a6..5665dc27d17 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -138,6 +138,8 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): await self._client.connect(init=True) return self.async_show_form(step_id="link") + if not await self._client.is_connected(): + await self._client.connect(init=False) if not await self._client.is_connected(): errors["base"] = "linking" else: From 31c0d212048cc672b019eb072fcab41b1580b224 Mon Sep 17 00:00:00 2001 From: suaveolent <2163625+suaveolent@users.noreply.github.com> Date: Tue, 6 Feb 2024 01:20:14 +0100 Subject: [PATCH 1506/1544] Improve lupusec code quality (#109727) * renamed async_add_devices * fixed typo * patch class instead of __init__ * ensure non blocking get_alarm * exception handling * added test case for json decode error * avoid blockign calls --------- Co-authored-by: suaveolent --- homeassistant/components/lupusec/__init__.py | 10 +++------- .../components/lupusec/alarm_control_panel.py | 6 +++--- .../components/lupusec/binary_sensor.py | 9 ++++++--- homeassistant/components/lupusec/config_flow.py | 5 +++++ homeassistant/components/lupusec/strings.json | 2 +- homeassistant/components/lupusec/switch.py | 9 ++++++--- tests/components/lupusec/test_config_flow.py | 16 ++++++++-------- 7 files changed, 32 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index bf7c30845a3..f937c7edd10 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -1,4 +1,5 @@ """Support for Lupusec Home Security system.""" +from json import JSONDecodeError import logging import lupupy @@ -111,16 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lupusec_system = await hass.async_add_executor_job( lupupy.Lupusec, username, password, host ) - except LupusecException: _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unknown error while trying to connect to Lupusec device at %s: %s", - host, - ex, - ) + except JSONDecodeError: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 2e4ca5cab63..cd4e433bd5d 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -29,14 +29,14 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" data = hass.data[DOMAIN][config_entry.entry_id] - alarm_devices = [LupusecAlarm(data, data.get_alarm(), config_entry.entry_id)] + alarm = await hass.async_add_executor_job(data.get_alarm) - async_add_devices(alarm_devices) + async_add_entities([LupusecAlarm(data, alarm, config_entry.entry_id)]) class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index ecff9a6266d..5cf63579984 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging import lupupy.constants as CONST @@ -25,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up a binary sensors for a Lupusec device.""" @@ -34,10 +35,12 @@ async def async_setup_entry( device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR sensors = [] - for device in data.get_devices(generic_type=device_types): + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: sensors.append(LupusecBinarySensor(device, config_entry.entry_id)) - async_add_devices(sensors) + async_add_entities(sensors) class LupusecBinarySensor(LupusecBaseSensor, BinarySensorEntity): diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py index 64d53ce51f4..aad57897c91 100644 --- a/homeassistant/components/lupusec/config_flow.py +++ b/homeassistant/components/lupusec/config_flow.py @@ -1,5 +1,6 @@ """"Config flow for Lupusec integration.""" +from json import JSONDecodeError import logging from typing import Any @@ -50,6 +51,8 @@ class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await test_host_connection(self.hass, host, username, password) except CannotConnect: errors["base"] = "cannot_connect" + except JSONDecodeError: + errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -80,6 +83,8 @@ class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await test_host_connection(self.hass, host, username, password) except CannotConnect: return self.async_abort(reason="cannot_connect") + except JSONDecodeError: + return self.async_abort(reason="cannot_connect") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/lupusec/strings.json b/homeassistant/components/lupusec/strings.json index 53f84c8b872..6fa59aaeb3d 100644 --- a/homeassistant/components/lupusec/strings.json +++ b/homeassistant/components/lupusec/strings.json @@ -21,7 +21,7 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", - "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." }, "deprecated_yaml_import_issue_unknown": { "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index a2b3796ef5b..e07c974f033 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial from typing import Any import lupupy.constants as CONST @@ -20,7 +21,7 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" @@ -29,10 +30,12 @@ async def async_setup_entry( device_types = CONST.TYPE_SWITCH switches = [] - for device in data.get_devices(generic_type=device_types): + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: switches.append(LupusecSwitch(device, config_entry.entry_id)) - async_add_devices(switches) + async_add_entities(switches) class LupusecSwitch(LupusecBaseSensor, SwitchEntity): diff --git a/tests/components/lupusec/test_config_flow.py b/tests/components/lupusec/test_config_flow.py index 5ef5f98ea00..6b07952ff54 100644 --- a/tests/components/lupusec/test_config_flow.py +++ b/tests/components/lupusec/test_config_flow.py @@ -1,5 +1,6 @@ """"Unit tests for the Lupusec config flow.""" +from json import JSONDecodeError from unittest.mock import patch from lupupy import LupusecException @@ -51,8 +52,7 @@ async def test_form_valid_input(hass: HomeAssistant) -> None: "homeassistant.components.lupusec.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", - return_value=None, + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", ) as mock_initialize_lupusec: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -71,6 +71,7 @@ async def test_form_valid_input(hass: HomeAssistant) -> None: ("raise_error", "text_error"), [ (LupusecException("Test lupusec exception"), "cannot_connect"), + (JSONDecodeError("Test JSONDecodeError", "test", 1), "cannot_connect"), (Exception("Test unknown exception"), "unknown"), ], ) @@ -85,7 +86,7 @@ async def test_flow_user_init_data_error_and_recover( assert result["errors"] == {} with patch( - "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", side_effect=raise_error, ) as mock_initialize_lupusec: result2 = await hass.config_entries.flow.async_configure( @@ -104,8 +105,7 @@ async def test_flow_user_init_data_error_and_recover( "homeassistant.components.lupusec.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", - return_value=None, + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", ) as mock_initialize_lupusec: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -164,8 +164,7 @@ async def test_flow_source_import( "homeassistant.components.lupusec.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", - return_value=None, + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", ) as mock_initialize_lupusec: result = await hass.config_entries.flow.async_init( DOMAIN, @@ -186,6 +185,7 @@ async def test_flow_source_import( ("raise_error", "text_error"), [ (LupusecException("Test lupusec exception"), "cannot_connect"), + (JSONDecodeError("Test JSONDecodeError", "test", 1), "cannot_connect"), (Exception("Test unknown exception"), "unknown"), ], ) @@ -195,7 +195,7 @@ async def test_flow_source_import_error_and_recover( """Test exceptions and recovery.""" with patch( - "homeassistant.components.lupusec.config_flow.lupupy.Lupusec.__init__", + "homeassistant.components.lupusec.config_flow.lupupy.Lupusec", side_effect=raise_error, ) as mock_initialize_lupusec: result = await hass.config_entries.flow.async_init( From c1e5b2e6cc2e01cdbcd46c32427f51a48bae6a18 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen <8420095+vilppuvuorinen@users.noreply.github.com> Date: Mon, 5 Feb 2024 23:30:20 +0200 Subject: [PATCH 1507/1544] Fix compatibility issues with older pymelcloud version (#109757) --- homeassistant/components/melcloud/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 909a1506eb1..d03fed39bb3 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -138,8 +138,7 @@ async def mel_devices_setup( all_devices = await get_devices( token, session, - user_update_interval=timedelta(minutes=30), - conf_update_interval=timedelta(minutes=15), + conf_update_interval=timedelta(minutes=30), device_set_debounce=timedelta(seconds=2), ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} From 9fcdfd1b168d11c16757b946e4cec3859a0ba1b8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 5 Feb 2024 15:59:02 -0500 Subject: [PATCH 1508/1544] Bump holidays to 0.42 (#109760) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 4d26e93e591..0608f8c404e 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.41", "babel==2.13.1"] + "requirements": ["holidays==0.42", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 27d440d4832..05026ae6e99 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.41"] + "requirements": ["holidays==0.42"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc27c58f9f2..07ed634645e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1056,7 +1056,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.41 +holidays==0.42 # homeassistant.components.frontend home-assistant-frontend==20240205.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 227a587d3ee..47669fd7fb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -852,7 +852,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.41 +holidays==0.42 # homeassistant.components.frontend home-assistant-frontend==20240205.0 From ffd5e04a29cf8deb3e0484632878f9eb48d3b02d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 6 Feb 2024 07:18:33 +0100 Subject: [PATCH 1509/1544] Fix Radarr health check singularity (#109762) * Fix Radarr health check singularity * Fix comment --- homeassistant/components/radarr/coordinator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c14603fe9ca..7f395169644 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -96,7 +96,7 @@ class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[RootFolder """Fetch the data.""" root_folders = await self.api_client.async_get_root_folders() if isinstance(root_folders, RootFolder): - root_folders = [root_folders] + return [root_folders] return root_folders @@ -105,7 +105,10 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]): async def _fetch_data(self) -> list[Health]: """Fetch the health data.""" - return await self.api_client.async_get_failed_health_checks() + health = await self.api_client.async_get_failed_health_checks() + if isinstance(health, Health): + return [health] + return health class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): From 5025c15165db1cdca6974834e8d01e3ea3d2746c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 5 Feb 2024 18:39:56 -0500 Subject: [PATCH 1510/1544] Buffer JsonDecodeError in Flo (#109767) --- homeassistant/components/flo/device.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 0c805b932cb..27feb15a97e 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -7,6 +7,7 @@ from typing import Any from aioflo.api import API from aioflo.errors import RequestError +from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -46,7 +47,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable= await self._update_device() await self._update_consumption_data() self._failure_count = 0 - except (RequestError, TimeoutError) as error: + except (RequestError, TimeoutError, JSONDecodeError) as error: self._failure_count += 1 if self._failure_count > 3: raise UpdateFailed(error) from error From 8d79ac67f524061993cb36b3cdc75f2ebd20f1d7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 6 Feb 2024 09:40:29 -0500 Subject: [PATCH 1511/1544] Bump ZHA dependencies (#109770) * Bump ZHA dependencies * Bump universal-silabs-flasher to 0.0.18 * Flip `Server_to_Client` enum in ZHA unit test * Bump zigpy to 0.62.2 --- homeassistant/components/zha/manifest.json | 10 +++++----- requirements_all.txt | 10 +++++----- requirements_test_all.txt | 10 +++++----- tests/components/zha/test_update.py | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9e09e20819f..263b069bbe8 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.6", + "bellows==0.38.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.110", - "zigpy-deconz==0.22.4", - "zigpy==0.61.0", + "zha-quirks==0.0.111", + "zigpy-deconz==0.23.0", + "zigpy==0.62.2", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", - "universal-silabs-flasher==0.0.15", + "universal-silabs-flasher==0.0.18", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index 07ed634645e..cdd2de2a3f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -535,7 +535,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.6 +bellows==0.38.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2757,7 +2757,7 @@ unifi_ap==0.0.1 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.15 +universal-silabs-flasher==0.0.18 # homeassistant.components.upb upb-lib==0.5.4 @@ -2913,7 +2913,7 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.110 +zha-quirks==0.0.111 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2922,7 +2922,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.4 +zigpy-deconz==0.23.0 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2934,7 +2934,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.61.0 +zigpy==0.62.2 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47669fd7fb8..91c58746342 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -457,7 +457,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.6 +bellows==0.38.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -2098,7 +2098,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.15 +universal-silabs-flasher==0.0.18 # homeassistant.components.upb upb-lib==0.5.4 @@ -2233,10 +2233,10 @@ zeroconf==0.131.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.110 +zha-quirks==0.0.111 # homeassistant.components.zha -zigpy-deconz==0.22.4 +zigpy-deconz==0.23.0 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2248,7 +2248,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.61.0 +zigpy==0.62.2 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index 981b8ba5e1b..894b5af9aba 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -205,7 +205,7 @@ def make_packet(zigpy_device, cluster, cmd_name: str, **kwargs): command_id=cluster.commands_by_name[cmd_name].id, schema=cluster.commands_by_name[cmd_name].schema, disable_default_response=False, - direction=foundation.Direction.Server_to_Client, + direction=foundation.Direction.Client_to_Server, args=(), kwargs=kwargs, ) From e25ddf9650825338c6f6e04691b14524e8c030aa Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 6 Feb 2024 09:34:02 +0100 Subject: [PATCH 1512/1544] Change state class of Tesla wall connector session energy entity (#109778) --- homeassistant/components/tesla_wall_connector/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 67d3d4ba22e..da1e974f6a0 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -156,7 +156,7 @@ WALL_CONNECTOR_SENSORS = [ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].session_energy_wh, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), WallConnectorSensorDescription( key="energy_kWh", From 3cf826dc93e75068d8598e27d5da9dcc7fb7c0d1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jan 2024 20:32:27 +0000 Subject: [PATCH 1513/1544] Bump ring_doorbell to 0.8.6 (#109199) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 85cab6f1763..a2ccb2bf444 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.5"] + "requirements": ["ring-doorbell[listen]==0.8.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index cdd2de2a3f2..1090d47c0ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2429,7 +2429,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.5 +ring-doorbell[listen]==0.8.6 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91c58746342..7e0778e16fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ reolink-aio==0.8.7 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.5 +ring-doorbell[listen]==0.8.6 # homeassistant.components.roku rokuecp==0.19.0 From 2481d146321eb939020e71374d04b04e7bef36f5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 6 Feb 2024 09:10:43 +0000 Subject: [PATCH 1514/1544] Bump ring_doorbell to 0.8.7 (#109783) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index a2ccb2bf444..0390db640e5 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.6"] + "requirements": ["ring-doorbell[listen]==0.8.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1090d47c0ec..afd8811f28b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2429,7 +2429,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.6 +ring-doorbell[listen]==0.8.7 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e0778e16fe..fcc2780d65f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ reolink-aio==0.8.7 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell[listen]==0.8.6 +ring-doorbell[listen]==0.8.7 # homeassistant.components.roku rokuecp==0.19.0 From 439f82a4eca559f8b45298a02f08fbeeb19d7123 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 6 Feb 2024 14:30:53 +0100 Subject: [PATCH 1515/1544] Update xknx to 2.12.0 and xknxproject to 3.5.0 (#109787) --- homeassistant/components/knx/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 4159a7a56a5..397af9ac181 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.11.2", - "xknxproject==3.4.0", + "xknx==2.12.0", + "xknxproject==3.5.0", "knx-frontend==2024.1.20.105944" ] } diff --git a/requirements_all.txt b/requirements_all.txt index afd8811f28b..b51efec761b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2856,10 +2856,10 @@ xbox-webapi==2.0.11 xiaomi-ble==0.23.1 # homeassistant.components.knx -xknx==2.11.2 +xknx==2.12.0 # homeassistant.components.knx -xknxproject==3.4.0 +xknxproject==3.5.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcc2780d65f..33dd796c496 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2185,10 +2185,10 @@ xbox-webapi==2.0.11 xiaomi-ble==0.23.1 # homeassistant.components.knx -xknx==2.11.2 +xknx==2.12.0 # homeassistant.components.knx -xknxproject==3.4.0 +xknxproject==3.5.0 # homeassistant.components.bluesound # homeassistant.components.fritz From c1701328278ca58b27a323a16e6715e7643c791b Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen <8420095+vilppuvuorinen@users.noreply.github.com> Date: Tue, 6 Feb 2024 14:27:50 +0200 Subject: [PATCH 1516/1544] Update MELCloud codeowners (#109793) Co-authored-by: Franck Nijhof --- CODEOWNERS | 2 -- homeassistant/components/melcloud/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index af196548bb3..144883db68f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -786,8 +786,6 @@ build.json @home-assistant/supervisor /homeassistant/components/media_source/ @hunterjm /tests/components/media_source/ @hunterjm /homeassistant/components/mediaroom/ @dgomes -/homeassistant/components/melcloud/ @vilppuvuorinen -/tests/components/melcloud/ @vilppuvuorinen /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 8be40b22d9c..fde248467bf 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -1,7 +1,7 @@ { "domain": "melcloud", "name": "MELCloud", - "codeowners": ["@vilppuvuorinen"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", From 35fad529133840cfb57abd37b029e82769d8e00c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 6 Feb 2024 15:16:15 +0100 Subject: [PATCH 1517/1544] Bump aioelectricitymaps to 0.3.1 (#109797) --- homeassistant/components/co2signal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a4cbed00684..4f22ee68910 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.3.0"] + "requirements": ["aioelectricitymaps==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b51efec761b..012fcd988e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -233,7 +233,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.3.0 +aioelectricitymaps==0.3.1 # homeassistant.components.emonitor aioemonitor==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33dd796c496..8e533798504 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioeagle==1.1.0 aioecowitt==2023.5.0 # homeassistant.components.co2signal -aioelectricitymaps==0.3.0 +aioelectricitymaps==0.3.1 # homeassistant.components.emonitor aioemonitor==1.0.5 From d099fb2a26c190786234611bb68a89e5118fd461 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 6 Feb 2024 10:55:51 -0500 Subject: [PATCH 1518/1544] Pin `chacha20poly1305-reuseable>=0.12.1` (#109807) * Pin `chacha20poly1305-reuseable` Prevents a runtime `assert isinstance(cipher, AESGCM)` error * Update `gen_requirements_all.py` as well --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 064675fce08..4c4b41500e0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -188,3 +188,6 @@ dacite>=1.7.0 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. pandas==2.1.4 + +# chacha20poly1305-reuseable==0.12.0 is incompatible with cryptography==42.0.x +chacha20poly1305-reuseable>=0.12.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 64d897b7ee7..3e61a266ae1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -181,6 +181,9 @@ dacite>=1.7.0 # Musle wheels for pandas 2.2.0 cannot be build for any architecture. pandas==2.1.4 + +# chacha20poly1305-reuseable==0.12.0 is incompatible with cryptography==42.0.x +chacha20poly1305-reuseable>=0.12.1 """ GENERATED_MESSAGE = ( From 7032415528e26487e740ccd32078cf19af870fa6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 6 Feb 2024 22:34:53 +0100 Subject: [PATCH 1519/1544] Don't block Supervisor entry setup with refreshing updates (#109809) --- homeassistant/components/hassio/__init__.py | 8 ++- homeassistant/components/hassio/handler.py | 2 +- tests/components/hassio/test_init.py | 60 +++++++++++---------- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 87860644754..1472843e14d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1001,12 +1001,18 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=has raise_on_entry_error: bool = False, ) -> None: """Refresh data.""" - if not scheduled: + if not scheduled and not raise_on_auth_failed: # Force refreshing updates for non-scheduled updates + # If `raise_on_auth_failed` is set, it means this is + # the first refresh and we do not want to delay + # startup or cause a timeout so we only refresh the + # updates if this is not a scheduled refresh and + # we are not doing the first refresh. try: await self.hassio.refresh_updates() except HassioAPIError as err: _LOGGER.warning("Error on Supervisor API: %s", err) + await super()._async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index c3532d553f4..ddaebcbf2a7 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -459,7 +459,7 @@ class HassIO: This method returns a coroutine. """ - return self.send_command("/refresh_updates", timeout=None) + return self.send_command("/refresh_updates", timeout=300) @api_data def retrieve_discovery_messages(self) -> Coroutine: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4bf3e29154e..fe8eeb0b0f6 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -245,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -290,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -309,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -326,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -406,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -423,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -443,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -525,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 24 + assert aioclient_mock.call_count == 23 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 25 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -547,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 27 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -572,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 29 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -591,7 +591,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 31 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -607,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -625,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 33 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -702,12 +702,12 @@ async def test_service_calls_core( await hass.services.async_call("homeassistant", "stop") await hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 5 await hass.services.async_call("homeassistant", "check_config") await hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 5 with patch( "homeassistant.config.async_check_ha_config_file", return_value=None @@ -716,7 +716,7 @@ async def test_service_calls_core( await hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 6 async def test_entry_load_and_unload(hass: HomeAssistant) -> None: @@ -897,14 +897,17 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Initial refresh without stats - assert refresh_updates_mock.call_count == 1 + + # Initial refresh, no update refresh call + assert refresh_updates_mock.call_count == 0 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", ) as refresh_updates_mock: async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) await hass.async_block_till_done() + + # Scheduled refresh, no update refresh call assert refresh_updates_mock.call_count == 0 with patch( @@ -921,13 +924,14 @@ async def test_coordinator_updates( }, blocking=True, ) - assert refresh_updates_mock.call_count == 0 - # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer - async_fire_time_changed( - hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) - ) - await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + assert refresh_updates_mock.call_count == 0 + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", @@ -968,14 +972,14 @@ async def test_coordinator_updates_stats_entities_enabled( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Initial refresh without stats - assert refresh_updates_mock.call_count == 1 + assert refresh_updates_mock.call_count == 0 # Refresh with stats once we know which ones are needed async_fire_time_changed( hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) ) await hass.async_block_till_done() - assert refresh_updates_mock.call_count == 2 + assert refresh_updates_mock.call_count == 1 with patch( "homeassistant.components.hassio.HassIO.refresh_updates", @@ -1059,7 +1063,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 20 + assert aioclient_mock.call_count == 19 assert len(mock_setup_entry.mock_calls) == 1 From 8569ddc5f94803de631816226ea185d21891ec47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Feb 2024 12:41:57 -0600 Subject: [PATCH 1520/1544] Fix entity services targeting entities outside the platform when using areas/devices (#109810) --- homeassistant/helpers/entity_platform.py | 26 +++++++- tests/helpers/test_entity_platform.py | 82 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7cf7ab62495..db2760d554c 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -57,6 +57,7 @@ SLOW_ADD_MIN_TIMEOUT = 500 PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" DATA_DOMAIN_ENTITIES = "domain_entities" +DATA_DOMAIN_PLATFORM_ENTITIES = "domain_platform_entities" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -124,6 +125,8 @@ class EntityPlatform: self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None + # Storage for entities for this specific platform only + # which are indexed by entity_id self.entities: dict[str, Entity] = {} self.component_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {} @@ -145,9 +148,24 @@ class EntityPlatform: # which powers entity_component.add_entities self.parallel_updates_created = platform is None - self.domain_entities: dict[str, Entity] = hass.data.setdefault( + # Storage for entities indexed by domain + # with the child dict indexed by entity_id + # + # This is usually media_player, light, switch, etc. + domain_entities: dict[str, dict[str, Entity]] = hass.data.setdefault( DATA_DOMAIN_ENTITIES, {} - ).setdefault(domain, {}) + ) + self.domain_entities = domain_entities.setdefault(domain, {}) + + # Storage for entities indexed by domain and platform + # with the child dict indexed by entity_id + # + # This is usually media_player.yamaha, light.hue, switch.tplink, etc. + domain_platform_entities: dict[ + tuple[str, str], dict[str, Entity] + ] = hass.data.setdefault(DATA_DOMAIN_PLATFORM_ENTITIES, {}) + key = (domain, platform_name) + self.domain_platform_entities = domain_platform_entities.setdefault(key, {}) def __repr__(self) -> str: """Represent an EntityPlatform.""" @@ -743,6 +761,7 @@ class EntityPlatform: entity_id = entity.entity_id self.entities[entity_id] = entity self.domain_entities[entity_id] = entity + self.domain_platform_entities[entity_id] = entity if not restored: # Reserve the state in the state machine @@ -756,6 +775,7 @@ class EntityPlatform: """Remove entity from entities dict.""" self.entities.pop(entity_id) self.domain_entities.pop(entity_id) + self.domain_platform_entities.pop(entity_id) entity.async_on_remove(remove_entity_cb) @@ -852,7 +872,7 @@ class EntityPlatform: partial( service.entity_service_call, self.hass, - self.domain_entities, + self.domain_platform_entities, service_func, required_features=required_features, ), diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 01558c426c7..f16b5c16b5a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import ( + area_registry as ar, device_registry as dr, entity_platform, entity_registry as er, @@ -1628,6 +1629,87 @@ async def test_register_entity_service_response_data_multiple_matches_raises( ) +async def test_register_entity_service_limited_to_matching_platforms( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test an entity services only targets entities for the platform and domain.""" + + mock_area = area_registry.async_get_or_create("mock_area") + + entity1_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "1234", suggested_object_id="entity1" + ) + entity_registry.async_update_entity(entity1_entry.entity_id, area_id=mock_area.id) + entity2_entry = entity_registry.async_get_or_create( + "base_platform", "mock_platform", "5678", suggested_object_id="entity2" + ) + entity_registry.async_update_entity(entity2_entry.entity_id, area_id=mock_area.id) + entity3_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "7891", suggested_object_id="entity3" + ) + entity_registry.async_update_entity(entity3_entry.entity_id, area_id=mock_area.id) + entity4_entry = entity_registry.async_get_or_create( + "base_platform", "other_mock_platform", "1433", suggested_object_id="entity4" + ) + entity_registry.async_update_entity(entity4_entry.entity_id, area_id=mock_area.id) + + async def generate_response( + target: MockEntity, call: ServiceCall + ) -> ServiceResponse: + assert call.return_response + return {"response-key": f"response-value-{target.entity_id}"} + + entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity( + entity_id=entity1_entry.entity_id, unique_id=entity1_entry.unique_id + ) + entity2 = MockEntity( + entity_id=entity2_entry.entity_id, unique_id=entity2_entry.unique_id + ) + await entity_platform.async_add_entities([entity1, entity2]) + + other_entity_platform = MockEntityPlatform( + hass, domain="base_platform", platform_name="other_mock_platform", platform=None + ) + entity3 = MockEntity( + entity_id=entity3_entry.entity_id, unique_id=entity3_entry.unique_id + ) + entity4 = MockEntity( + entity_id=entity4_entry.entity_id, unique_id=entity4_entry.unique_id + ) + await other_entity_platform.async_add_entities([entity3, entity4]) + + entity_platform.async_register_entity_service( + "hello", + {"some": str}, + generate_response, + supports_response=SupportsResponse.ONLY, + ) + + response_data = await hass.services.async_call( + "mock_platform", + "hello", + service_data={"some": "data"}, + target={"area_id": [mock_area.id]}, + blocking=True, + return_response=True, + ) + # We should not target entity3 and entity4 even though they are in the area + # because they are only part of the domain and not the platform + assert response_data == { + "base_platform.entity1": { + "response-key": "response-value-base_platform.entity1" + }, + "base_platform.entity2": { + "response-key": "response-value-base_platform.entity2" + }, + } + + async def test_invalid_entity_id(hass: HomeAssistant) -> None: """Test specifying an invalid entity id.""" platform = MockEntityPlatform(hass) From 8aa124222120cff4ed99af60ee88e256ffec4206 Mon Sep 17 00:00:00 2001 From: wittypluck Date: Tue, 6 Feb 2024 18:33:10 +0100 Subject: [PATCH 1521/1544] Mark Unifi bandwidth sensors as unavailable when client disconnects (#109812) * Set sensor as unavailable instead of resetting value to 0 on disconnect * Update unit test on unavailable bandwidth sensor --- homeassistant/components/unifi/sensor.py | 6 +++--- tests/components/unifi/test_sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 28db9abb94f..a0cd3a7f1e7 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -430,10 +430,10 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device. - Reset sensor value to 0 when client device is disconnected + Set sensor as unavailable when client device is disconnected """ - if self._attr_native_value != 0: - self._attr_native_value = 0 + if self._attr_available: + self._attr_available = False self.async_write_ha_state() @callback diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 1a3c81ec4c4..9ebdd207b54 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -416,8 +416,8 @@ async def test_bandwidth_sensors( async_fire_time_changed(hass, new_time) await hass.async_block_till_done() - assert hass.states.get("sensor.wireless_client_rx").state == "0" - assert hass.states.get("sensor.wireless_client_tx").state == "0" + assert hass.states.get("sensor.wireless_client_rx").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.wireless_client_tx").state == STATE_UNAVAILABLE # Disable option From 40adb3809f73dfd5f61c7bfc6d36d178418f15f2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 6 Feb 2024 22:36:12 +0100 Subject: [PATCH 1522/1544] Ignore `trackable` without `details` in Tractive integration (#109814) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/tractive/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8dd0ed8e91b..38080fffe6e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -129,6 +129,13 @@ async def _generate_trackables( if not trackable["device_id"]: return None + if "details" not in trackable: + _LOGGER.info( + "Tracker %s has no details and will be skipped. This happens for shared trackers", + trackable["device_id"], + ) + return None + tracker = client.tracker(trackable["device_id"]) tracker_details, hw_info, pos_report = await asyncio.gather( From 2c870f9da978fd15ce301d9cdf150f64c124f7ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 6 Feb 2024 22:37:20 +0100 Subject: [PATCH 1523/1544] Bump aioecowitt to 2024.2.0 (#109817) --- homeassistant/components/ecowitt/entity.py | 4 ++-- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index a5d769e6749..cf62cfb2d94 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -38,8 +38,8 @@ class EcowittEntity(Entity): """Update the state on callback.""" self.async_write_ha_state() - self.ecowitt.update_cb.append(_update_state) # type: ignore[arg-type] # upstream bug - self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) # type: ignore[arg-type] # upstream bug + self.ecowitt.update_cb.append(_update_state) + self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) @property def available(self) -> bool: diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 9f0f668ee81..d3dfe0331ef 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.5.0"] + "requirements": ["aioecowitt==2024.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 012fcd988e0..53a39dadccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -230,7 +230,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.5.0 +aioecowitt==2024.2.0 # homeassistant.components.co2signal aioelectricitymaps==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e533798504..aba70dcda19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,7 +209,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.5.0 +aioecowitt==2024.2.0 # homeassistant.components.co2signal aioelectricitymaps==0.3.1 From 5c83b774bbdecf02ea64217d23e33f1add8c51a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 6 Feb 2024 20:14:45 +0100 Subject: [PATCH 1524/1544] Bump python-otbr-api to 2.6.0 (#109823) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index cf6aba33e80..ca0faa160f0 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0"] + "requirements": ["python-otbr-api==2.6.0"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index eeac24a626f..65d4c9d044c 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 53a39dadccd..6f073b20516 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2257,7 +2257,7 @@ python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.5.0 +python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aba70dcda19..f0911f285e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1727,7 +1727,7 @@ python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.5.0 +python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 From 2103875ff73c2bf26d1cfa367d30fc8c0b4676e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Feb 2024 14:01:10 -0600 Subject: [PATCH 1525/1544] Bump aioesphomeapi to 21.0.2 (#109824) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e3437e5aa73..35b8e91f12b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.1", + "aioesphomeapi==21.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==0.4.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6f073b20516..e3be052b1f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -239,7 +239,7 @@ aioelectricitymaps==0.3.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.1 +aioesphomeapi==21.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0911f285e9..74f58994906 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioelectricitymaps==0.3.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==21.0.1 +aioesphomeapi==21.0.2 # homeassistant.components.flo aioflo==2021.11.0 From 74a75e709f1ae6f6ad4d24e0dad727c1650c30df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 6 Feb 2024 21:57:00 +0100 Subject: [PATCH 1526/1544] Bump awesomeversion from 23.11.0 to 24.2.0 (#109830) --- 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 4c4b41500e0..f8ba47129b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ astral==2.2 async-upnp-client==0.38.1 atomicwrites-homeassistant==1.4.1 attrs==23.2.0 -awesomeversion==23.11.0 +awesomeversion==24.2.0 bcrypt==4.0.1 bleak-retry-connector==3.4.0 bleak==0.21.1 diff --git a/pyproject.toml b/pyproject.toml index d7aea22d47a..fedc663a58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "astral==2.2", "attrs==23.2.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==23.11.0", + "awesomeversion==24.2.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", diff --git a/requirements.txt b/requirements.txt index 44c11281517..63ea582eba8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp-zlib-ng==0.3.1 astral==2.2 attrs==23.2.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==23.11.0 +awesomeversion==24.2.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 From 8c605c29c344ff5b0f05c2453eb10fd9985322e4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 6 Feb 2024 22:49:53 +0100 Subject: [PATCH 1527/1544] Bump version to 2024.2.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f6b42076575..54640a8a7a8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index fedc663a58f..e4c0530b257 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b8" +version = "2024.2.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ebb1912617bab87fdbb4532e70bf49f95891fa89 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 6 Feb 2024 23:03:35 +0100 Subject: [PATCH 1528/1544] Show domain in oauth2 error log (#109708) * Show token url in oauth2 error log * Fix tests * Use domain --- homeassistant/helpers/config_entry_oauth2_flow.py | 5 ++++- tests/helpers/test_config_entry_oauth2_flow.py | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 5b4b803a8d4..7563d4c08b9 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -209,7 +209,10 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): error_code = error_response.get("error", "unknown") error_description = error_response.get("error_description", "unknown error") _LOGGER.error( - "Token request failed (%s): %s", error_code, error_description + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, ) resp.raise_for_status() return cast(dict, await resp.json()) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 8c78b7dadc6..0d10051ca78 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -396,19 +396,19 @@ async def test_abort_discovered_multiple( HTTPStatus.UNAUTHORIZED, {}, "oauth_unauthorized", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.NOT_FOUND, {}, "oauth_failed", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.INTERNAL_SERVER_ERROR, {}, "oauth_failed", - "Token request failed (unknown): unknown", + "Token request for oauth2_test failed (unknown): unknown", ), ( HTTPStatus.BAD_REQUEST, @@ -418,7 +418,7 @@ async def test_abort_discovered_multiple( "error_uri": "See the full API docs at https://authorization-server.com/docs/access_token", }, "oauth_failed", - "Token request failed (invalid_request): Request was missing the", + "Token request for oauth2_test failed (invalid_request): Request was missing the", ), ], ) @@ -541,7 +541,7 @@ async def test_abort_if_oauth_token_closing_error( with caplog.at_level(logging.DEBUG): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert "Token request failed (unknown): unknown" in caplog.text + assert "Token request for oauth2_test failed (unknown): unknown" in caplog.text assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "oauth_unauthorized" From d784a76d32cc58f4929dc34ce91f74b22630c66e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 7 Feb 2024 06:29:26 +0100 Subject: [PATCH 1529/1544] Add tapo virtual integration (#109765) --- homeassistant/brands/tplink.json | 2 +- homeassistant/components/tplink_tapo/__init__.py | 1 + homeassistant/components/tplink_tapo/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tplink_tapo/__init__.py create mode 100644 homeassistant/components/tplink_tapo/manifest.json diff --git a/homeassistant/brands/tplink.json b/homeassistant/brands/tplink.json index bc8d38b3e71..06ab621ed32 100644 --- a/homeassistant/brands/tplink.json +++ b/homeassistant/brands/tplink.json @@ -1,6 +1,6 @@ { "domain": "tplink", "name": "TP-Link", - "integrations": ["tplink", "tplink_omada", "tplink_lte"], + "integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"], "iot_standards": ["matter"] } diff --git a/homeassistant/components/tplink_tapo/__init__.py b/homeassistant/components/tplink_tapo/__init__.py new file mode 100644 index 00000000000..d76870ccea4 --- /dev/null +++ b/homeassistant/components/tplink_tapo/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: TP-Link Tapo.""" diff --git a/homeassistant/components/tplink_tapo/manifest.json b/homeassistant/components/tplink_tapo/manifest.json new file mode 100644 index 00000000000..a0d86b2dc62 --- /dev/null +++ b/homeassistant/components/tplink_tapo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "tplink_tapo", + "name": "Tapo", + "integration_type": "virtual", + "supported_by": "tplink" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2b97365b555..ae839180729 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6156,6 +6156,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "TP-Link LTE" + }, + "tplink_tapo": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "tplink", + "name": "Tapo" } }, "iot_standards": [ From fe94107af7fbee90c6fd5ec8db71e2937545c975 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 7 Feb 2024 06:26:33 +0100 Subject: [PATCH 1530/1544] Make integration fields in Analytics Insights optional (#109789) --- .../analytics_insights/config_flow.py | 53 ++++-- .../analytics_insights/strings.json | 6 + .../analytics_insights/test_config_flow.py | 166 +++++++++++++++++- 3 files changed, 212 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b409a9c0fb9..d2ebdd943a2 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -53,10 +53,25 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" self._async_abort_entries_match() - if user_input: - return self.async_create_entry( - title="Home Assistant Analytics Insights", data={}, options=user_input - ) + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="Home Assistant Analytics Insights", + data={}, + options={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) client = HomeassistantAnalyticsClient( session=async_get_clientsession(self.hass) @@ -78,16 +93,17 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ] return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema( { - vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=options, multiple=True, sort=True, ) ), - vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=list(custom_integrations), multiple=True, @@ -106,8 +122,24 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - if user_input: - return self.async_create_entry(title="", data=user_input) + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="", + data={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) client = HomeassistantAnalyticsClient( session=async_get_clientsession(self.hass) @@ -129,17 +161,18 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): ] return self.async_show_form( step_id="init", + errors=errors, data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=options, multiple=True, sort=True, ) ), - vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=list(custom_integrations), multiple=True, diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 58e47d1df08..6de1ab9dbe4 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -15,6 +15,9 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "no_integration_selected": "You must select at least one integration to track" } }, "options": { @@ -32,6 +35,9 @@ }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]" } }, "entity": { diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index 8cefa29ee7b..6ddbe285df7 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Homeassistant Analytics config flow.""" +from typing import Any from unittest.mock import AsyncMock +import pytest from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError from homeassistant import config_entries @@ -16,8 +18,45 @@ from tests.common import MockConfigEntry from tests.components.analytics_insights import setup_integration +@pytest.mark.parametrize( + ("user_input", "expected_options"), + [ + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + ), + ( + { + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ], +) async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_analytics_client: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_analytics_client: AsyncMock, + user_input: dict[str, Any], + expected_options: dict[str, Any], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -25,6 +64,50 @@ async def test_form( ) assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Analytics Insights" + assert result["data"] == {} + assert result["options"] == expected_options + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + {}, + ], +) +async def test_submitting_empty_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_analytics_client: AsyncMock, + user_input: dict[str, Any], +) -> None: + """Test we can't submit an empty form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "no_integrations_selected"} + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -81,10 +164,45 @@ async def test_form_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("user_input", "expected_options"), + [ + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ( + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + }, + { + CONF_TRACKED_INTEGRATIONS: ["youtube"], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + ), + ( + { + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], + }, + ), + ], +) async def test_options_flow( hass: HomeAssistant, mock_analytics_client: AsyncMock, mock_config_entry: MockConfigEntry, + user_input: dict[str, Any], + expected_options: dict[str, Any], ) -> None: """Test options flow.""" await setup_integration(hass, mock_config_entry) @@ -95,7 +213,50 @@ async def test_options_flow( mock_analytics_client.get_integrations.reset_mock() result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == expected_options + await hass.async_block_till_done() + mock_analytics_client.get_integrations.assert_called_once() + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_TRACKED_INTEGRATIONS: [], + CONF_TRACKED_CUSTOM_INTEGRATIONS: [], + }, + {}, + ], +) +async def test_submitting_empty_options_flow( + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + mock_config_entry: MockConfigEntry, + user_input: dict[str, Any], +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "no_integrations_selected"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { CONF_TRACKED_INTEGRATIONS: ["youtube", "hue"], CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], }, @@ -108,7 +269,6 @@ async def test_options_flow( CONF_TRACKED_CUSTOM_INTEGRATIONS: ["hacs"], } await hass.async_block_till_done() - mock_analytics_client.get_integrations.assert_called_once() async def test_options_flow_cannot_connect( From 27691b7d48ffb1d45149c9541ed71f70da9bf6f7 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen <8420095+vilppuvuorinen@users.noreply.github.com> Date: Wed, 7 Feb 2024 09:45:27 +0200 Subject: [PATCH 1531/1544] Disable energy report based operations with API lib upgrade (#109832) Co-authored-by: Franck Nijhof --- homeassistant/components/melcloud/__init__.py | 5 ----- .../components/melcloud/manifest.json | 2 +- homeassistant/components/melcloud/sensor.py | 20 ------------------- .../components/melcloud/strings.json | 3 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 3 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index d03fed39bb3..2fa7e87d737 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -123,11 +123,6 @@ class MelCloudDevice: via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), ) - @property - def daily_energy_consumed(self) -> float | None: - """Return energy consumed during the current day in kWh.""" - return self.device.daily_energy_consumed - async def mel_devices_setup( hass: HomeAssistant, token: str diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index fde248467bf..0122c840373 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", "loggers": ["pymelcloud"], - "requirements": ["pymelcloud==2.5.8"] + "requirements": ["pymelcloud==2.5.9"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index cf53fe42b77..d3d1f4976f6 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -58,16 +58,6 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( @@ -90,16 +80,6 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 3abb30bf9ac..6a98b88e2d3 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -65,9 +65,6 @@ "room_temperature": { "name": "Room temperature" }, - "daily_energy": { - "name": "Daily energy consumed" - }, "outside_temperature": { "name": "Outside temperature" }, diff --git a/requirements_all.txt b/requirements_all.txt index e3be052b1f6..bf8af74810b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1940,7 +1940,7 @@ pymata-express==1.19 pymediaroom==0.6.5.4 # homeassistant.components.melcloud -pymelcloud==2.5.8 +pymelcloud==2.5.9 # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74f58994906..03bc77167e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1494,7 +1494,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.melcloud -pymelcloud==2.5.8 +pymelcloud==2.5.9 # homeassistant.components.meteoclimatic pymeteoclimatic==0.1.0 From 031aadff00e2a2ecfae9c3f5750498d83bf3cbf8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 7 Feb 2024 06:23:57 +0100 Subject: [PATCH 1532/1544] Bump motionblinds to 0.6.20 (#109837) --- homeassistant/components/motion_blinds/cover.py | 2 ++ homeassistant/components/motion_blinds/manifest.json | 2 +- homeassistant/components/motion_blinds/sensor.py | 5 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 833d2640202..c987e1bb10a 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -51,6 +51,7 @@ POSITION_DEVICE_MAP = { BlindType.CurtainLeft: CoverDeviceClass.CURTAIN, BlindType.CurtainRight: CoverDeviceClass.CURTAIN, BlindType.SkylightBlind: CoverDeviceClass.SHADE, + BlindType.InsectScreen: CoverDeviceClass.SHADE, } TILT_DEVICE_MAP = { @@ -69,6 +70,7 @@ TILT_ONLY_DEVICE_MAP = { TDBU_DEVICE_MAP = { BlindType.TopDownBottomUp: CoverDeviceClass.SHADE, + BlindType.TriangleBlind: CoverDeviceClass.BLIND, } diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index f9115cd8146..6f7b7dfae38 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.19"] + "requirements": ["motionblinds==0.6.20"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index e71abe09069..dddcb0e00fd 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,6 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_WIFI +from motionblinds.motion_blinds import DEVICE_TYPE_TDBU from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -29,7 +30,7 @@ async def async_setup_entry( for blind in motion_gateway.device_list.values(): entities.append(MotionSignalStrengthSensor(coordinator, blind)) - if blind.type == BlindType.TopDownBottomUp: + if blind.device_type == DEVICE_TYPE_TDBU: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) elif blind.battery_voltage is not None and blind.battery_voltage > 0: diff --git a/requirements_all.txt b/requirements_all.txt index bf8af74810b..a32064d625a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1310,7 +1310,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.19 +motionblinds==0.6.20 # homeassistant.components.motioneye motioneye-client==0.3.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03bc77167e9..9bd5db424cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1046,7 +1046,7 @@ moehlenhoff-alpha2==1.3.0 mopeka-iot-ble==0.5.0 # homeassistant.components.motion_blinds -motionblinds==0.6.19 +motionblinds==0.6.20 # homeassistant.components.motioneye motioneye-client==0.3.14 From 40cfc31dcb5eefb22cb623f45e70638fb7d18169 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 7 Feb 2024 00:22:54 -0500 Subject: [PATCH 1533/1544] Bump ZHA dependency zigpy to 0.62.3 (#109848) --- 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 263b069bbe8..e9ab98fa6bf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.111", "zigpy-deconz==0.23.0", - "zigpy==0.62.2", + "zigpy==0.62.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index a32064d625a..e6183e425b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2934,7 +2934,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.62.2 +zigpy==0.62.3 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bd5db424cd..8bb6cd57486 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2248,7 +2248,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.62.2 +zigpy==0.62.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From ea4bdbb3a05c748c0330a66967e0cc8e3a18f1cc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Feb 2024 08:48:17 +0100 Subject: [PATCH 1534/1544] Bump version to 2024.2.0b10 --- 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 54640a8a7a8..b0421bf6b26 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0b10" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index e4c0530b257..76d71ed4602 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b9" +version = "2024.2.0b10" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2ca3bbaea54fd3a0b41714f64c6c559143fbe9bd Mon Sep 17 00:00:00 2001 From: Jiayi Chen Date: Wed, 7 Feb 2024 09:35:50 +0100 Subject: [PATCH 1535/1544] Update Growatt server URLs (#109122) --- .coveragerc | 1 + homeassistant/components/growatt_server/const.py | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.coveragerc b/.coveragerc index bcd4e349668..4be573201d6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -485,6 +485,7 @@ omit = homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py + homeassistant/components/growatt_server/const.py homeassistant/components/growatt_server/sensor.py homeassistant/components/growatt_server/sensor_types/* homeassistant/components/gstreamer/media_player.py diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4e548ef2c2a..e4e7c638fa3 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -8,13 +8,16 @@ DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" SERVER_URLS = [ - "https://server-api.growatt.com/", - "https://server-us.growatt.com/", - "http://server.smten.com/", + "https://openapi.growatt.com/", # Other regional server + "https://openapi-cn.growatt.com/", # Chinese server + "https://openapi-us.growatt.com/", # North American server + "http://server.smten.com/", # smten server ] DEPRECATED_URLS = [ "https://server.growatt.com/", + "https://server-api.growatt.com/", + "https://server-us.growatt.com/", ] DEFAULT_URL = SERVER_URLS[0] From 881707e1fe8f77c959ebdbfbef1a0d1ba4e42f99 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 6 Feb 2024 22:06:59 +0100 Subject: [PATCH 1536/1544] Update nibe to 2.8.0 with LOG.SET fixes (#109825) Update nibe to 2.8.0 --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index c5c94145e4b..970f53837ea 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.7.0"] + "requirements": ["nibe==2.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6183e425b1..170c0331f3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1367,7 +1367,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.7.0 +nibe==2.8.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bb6cd57486..a61c494026b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1094,7 +1094,7 @@ nextcord==2.0.0a8 nextdns==2.1.0 # homeassistant.components.nibe_heatpump -nibe==2.7.0 +nibe==2.8.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 75b308c1aac3891569341890137858522cafb9c7 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 7 Feb 2024 20:27:10 +1100 Subject: [PATCH 1537/1544] Bump aio-georss-gdacs to 0.9 (#109859) --- homeassistant/components/gdacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index b6fb3d8cee3..d743dd00424 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio-georss-gdacs==0.8"] + "requirements": ["aio-georss-gdacs==0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 170c0331f3d..7a87c34f1ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio-georss-gdacs==0.8 +aio-georss-gdacs==0.9 # homeassistant.components.airq aioairq==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a61c494026b..907bfc5f855 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -161,7 +161,7 @@ aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-usgs-earthquakes==0.2 # homeassistant.components.gdacs -aio-georss-gdacs==0.8 +aio-georss-gdacs==0.9 # homeassistant.components.airq aioairq==0.3.2 From bd21490a574c81b54be602f5c4e9e1f81c6722e6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Feb 2024 12:29:06 +0100 Subject: [PATCH 1538/1544] Update frontend to 20240207.0 (#109871) --- 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 1af6a9da7b0..d998871a60b 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==20240205.0"] + "requirements": ["home-assistant-frontend==20240207.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f8ba47129b0..e3a82474d8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ habluetooth==2.4.0 hass-nabucasa==0.76.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240205.0 +home-assistant-frontend==20240207.0 home-assistant-intents==2024.2.2 httpx==0.26.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7a87c34f1ee..549c269f8a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1059,7 +1059,7 @@ hole==0.8.0 holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240205.0 +home-assistant-frontend==20240207.0 # homeassistant.components.conversation home-assistant-intents==2024.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 907bfc5f855..1e107e71cd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -855,7 +855,7 @@ hole==0.8.0 holidays==0.42 # homeassistant.components.frontend -home-assistant-frontend==20240205.0 +home-assistant-frontend==20240207.0 # homeassistant.components.conversation home-assistant-intents==2024.2.2 From e720b398d2e2f21854ee70c4b65cd2e295960ae1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Feb 2024 12:44:50 +0100 Subject: [PATCH 1539/1544] Bump version to 2024.2.0b11 --- 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 b0421bf6b26..6f288309837 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b10" +PATCH_VERSION: Final = "0b11" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 76d71ed4602..b5e275c7d8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b10" +version = "2024.2.0b11" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f61c70b6862bc18493872c3bc0e3a52b8a1e2afb Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 7 Feb 2024 20:42:33 +0800 Subject: [PATCH 1540/1544] Fix YoLink SpeakerHub support (#107925) * improve * Fix when hub offline/online message pushing * fix as suggestion * check config entry load state * Add exception translation --- homeassistant/components/yolink/__init__.py | 14 ++++++++++++-- homeassistant/components/yolink/number.py | 18 ++++++++++++++++-- homeassistant/components/yolink/services.py | 16 ++++++++++++++-- homeassistant/components/yolink/services.yaml | 2 -- homeassistant/components/yolink/strings.json | 5 +++++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 473c85d563a..a1017a488d1 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -19,8 +19,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, + config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.typing import ConfigType from . import api from .const import DOMAIN, YOLINK_EVENT @@ -30,6 +32,8 @@ from .services import async_register_services SCAN_INTERVAL = timedelta(minutes=5) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + PLATFORMS = [ Platform.BINARY_SENSOR, @@ -96,6 +100,14 @@ class YoLinkHomeStore: device_coordinators: dict[str, YoLinkCoordinator] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up YoLink.""" + + async_register_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up yolink from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -147,8 +159,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_register_services(hass, entry) - async def async_yolink_unload(event) -> None: """Unload yolink.""" await yolink_home.async_unload() diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py index 1ec20cd4d17..a7ba89e1f6c 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from yolink.client_request import ClientRequest from yolink.const import ATTR_DEVICE_SPEAKER_HUB @@ -30,6 +31,7 @@ class YoLinkNumberTypeConfigEntityDescription(NumberEntityDescription): """YoLink NumberEntity description.""" exists_fn: Callable[[YoLinkDevice], bool] + should_update_entity: Callable value: Callable @@ -37,6 +39,14 @@ NUMBER_TYPE_CONF_SUPPORT_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] SUPPORT_SET_VOLUME_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + +def get_volume_value(state: dict[str, Any]) -> int | None: + """Get volume option.""" + if (options := state.get("options")) is not None: + return options.get("volume") + return None + + DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] = ( YoLinkNumberTypeConfigEntityDescription( key=OPTIONS_VALUME, @@ -48,7 +58,8 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] native_unit_of_measurement=None, icon="mdi:volume-high", exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES, - value=lambda state: state["options"]["volume"], + should_update_entity=lambda value: value is not None, + value=get_volume_value, ), ) @@ -98,7 +109,10 @@ class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity): @callback def update_entity_state(self, state: dict) -> None: """Update HA Entity State.""" - attr_val = self.entity_description.value(state) + if ( + attr_val := self.entity_description.value(state) + ) is None and self.entity_description.should_update_entity(attr_val) is False: + return self._attr_native_value = attr_val self.async_write_ha_state() diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index bb2c660ef56..e41e3dce260 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -3,8 +3,9 @@ import voluptuous as vol from yolink.client_request import ClientRequest -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( @@ -19,7 +20,7 @@ from .const import ( SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" -def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: @@ -28,6 +29,17 @@ def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None: device_registry = dr.async_get(hass) device_entry = device_registry.async_get(service_data[ATTR_TARGET_DEVICE]) if device_entry is not None: + for entry_id in device_entry.config_entries: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + break + if entry is None or entry.state == ConfigEntryState.NOT_LOADED: + raise ServiceValidationError( + "Config entry not found or not loaded!", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + ) home_store = hass.data[DOMAIN][entry.entry_id] for identifier in device_entry.identifiers: if ( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 939eba3e7f5..5f7a3ec3122 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -7,9 +7,7 @@ play_on_speaker_hub: device: filter: - integration: yolink - manufacturer: YoLink model: SpeakerHub - message: required: true example: hello, yolink diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 9661abe096c..83e712328f9 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -37,6 +37,11 @@ "button_4_long_press": "Button_4 (long press)" } }, + "exceptions": { + "invalid_config_entry": { + "message": "Config entry not found or not loaded!" + } + }, "entity": { "switch": { "usb_ports": { "name": "USB ports" }, From 3030870de0f6125aeee3a9bb54e49846b56b6848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 7 Feb 2024 15:26:00 +0100 Subject: [PATCH 1541/1544] Remove soft hyphens from myuplink sensor names (#109845) Remove soft hyphens from sensor names --- homeassistant/components/myuplink/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 31cb6715e0c..5b08b26a306 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -75,7 +75,7 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): # Internal properties self.point_id = device_point.parameter_id - self._attr_name = device_point.parameter_name + self._attr_name = device_point.parameter_name.replace("\u002d", "") if entity_description is not None: self.entity_description = entity_description From 8375fc235d6ae6d3e2bb093f7b529d4de13729c7 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Thu, 8 Feb 2024 02:24:25 +1100 Subject: [PATCH 1542/1544] Bump aio-geojson-geonetnz-quakes to 0.16 (#109873) --- homeassistant/components/geonetnz_quakes/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 9ed59b2bc97..2314dabcf0f 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], "quality_scale": "platinum", - "requirements": ["aio-geojson-geonetnz-quakes==0.15"] + "requirements": ["aio-geojson-geonetnz-quakes==0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 549c269f8a3..a61d360c4e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -170,7 +170,7 @@ agent-py==0.0.23 aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes -aio-geojson-geonetnz-quakes==0.15 +aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano aio-geojson-geonetnz-volcano==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e107e71cd7..67c0775c2f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -149,7 +149,7 @@ agent-py==0.0.23 aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes -aio-geojson-geonetnz-quakes==0.15 +aio-geojson-geonetnz-quakes==0.16 # homeassistant.components.geonetnz_volcano aio-geojson-geonetnz-volcano==0.8 From f63aaf8b5a99432199bb4365b8d760ca427b6f34 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Feb 2024 16:28:11 +0100 Subject: [PATCH 1543/1544] Bump version to 2024.2.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 6f288309837..fb6e8ef896b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b11" +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, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b5e275c7d8e..6a038aa1c5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.2.0b11" +version = "2024.2.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9e47d030864a8576173f68302b6d5522b368ecdc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 31 Jan 2024 22:10:32 +0100 Subject: [PATCH 1544/1544] Fix kitchen sink tests (#109243) --- tests/components/kitchen_sink/test_init.py | 47 +++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 71f3a83c701..c65d53478d2 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -102,6 +102,7 @@ async def test_demo_statistics_growth( assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.freeze_time("2023-10-21") async def test_issues_created( mock_history, hass: HomeAssistant, @@ -125,7 +126,7 @@ async def test_issues_created( "issues": [ { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -139,7 +140,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -153,7 +154,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -167,7 +168,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -181,7 +182,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "is_fixable": True, @@ -193,6 +194,20 @@ async def test_issues_created( "translation_placeholders": None, "ignored": False, }, + { + "breaks_in_ha_version": None, + "created": "2023-10-21T00:00:00+00:00", + "dismissed_version": None, + "domain": "homeassistant", + "is_fixable": False, + "issue_domain": DOMAIN, + "issue_id": ANY, + "learn_more_url": None, + "severity": "error", + "translation_key": "config_entry_reauth", + "translation_placeholders": None, + "ignored": False, + }, ] } @@ -242,7 +257,7 @@ async def test_issues_created( "issues": [ { "breaks_in_ha_version": "2023.1.1", - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -256,7 +271,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -270,7 +285,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "ignored": False, @@ -284,7 +299,7 @@ async def test_issues_created( }, { "breaks_in_ha_version": None, - "created": ANY, + "created": "2023-10-21T00:00:00+00:00", "dismissed_version": None, "domain": DOMAIN, "is_fixable": True, @@ -296,5 +311,19 @@ async def test_issues_created( "translation_placeholders": None, "ignored": False, }, + { + "breaks_in_ha_version": None, + "created": "2023-10-21T00:00:00+00:00", + "dismissed_version": None, + "domain": "homeassistant", + "is_fixable": False, + "issue_domain": DOMAIN, + "issue_id": ANY, + "learn_more_url": None, + "severity": "error", + "translation_key": "config_entry_reauth", + "translation_placeholders": None, + "ignored": False, + }, ] }